diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 891b4bc..ed3f1d6 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -3676,7 +3676,7 @@ impl BooksServer { } for line in entry.lines() { let account = match ledger.chart().get(line.account) { - Some(a) if !a.placeholder => a, + Some(a) => a, _ => continue, }; let signed = match line.side { @@ -4589,6 +4589,7 @@ mod tests { fn periods_are_a_year_and_a_month() { assert_eq!(parse_period_mcp("2026-01"), Ok((2026, 1))); assert_eq!(parse_period_mcp("2026-12"), Ok((2026, 12))); + assert_eq!(parse_period_mcp("0001-01"), Ok((1, 1))); for bad in [ "2026-13", "2026-00", @@ -4713,6 +4714,44 @@ mod tests { ); } + /// Only a closed period moves where the unclosed part starts: an open + /// one dated before as_of doesn't, and a close that ended before the + /// fiscal year began leaves the year starting on its first day (last + /// December's income is never this year's). + #[test] + fn year_earnings_count_only_closes_and_never_before_the_year() { + let mut s = store("earnings-open"); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let sales = s + .add_account("4000", "Sales", AccountType::Revenue, None, false) + .unwrap(); + post(&mut s, d(2025, 12, 15), cash, sales, "999.00"); + post(&mut s, d(2026, 1, 10), cash, sales, "1000.00"); + s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let server = BooksServer::new(s, ReceiptPolicy::default()); + let as_of = d(2026, 2, 28); + let thousand = Decimal::from_str("1000.00").unwrap(); + { + let store = server.lock().unwrap(); + let e = server.year_earnings(store.ledger(), as_of).unwrap(); + assert_eq!(e.unclosed, thousand, "an open January closes nothing"); + } + { + let mut store = server.lock().unwrap(); + let to_nov = store + .create_period("2025-01..11", d(2025, 1, 1), d(2025, 11, 30)) + .unwrap(); + store.close_period(to_nov).unwrap(); + } + let store = server.lock().unwrap(); + let e = server.year_earnings(store.ledger(), as_of).unwrap(); + assert_eq!(e.net_income_ytd, thousand); + assert_eq!(e.unclosed, thousand, "December 2025 is not this year's"); + } + /// Unlinking a receipt is refused only from a posted, non-reversal /// entry the policy covers (crate::policy). #[test] diff --git a/crates/ledger-mcp/src/wire.rs b/crates/ledger-mcp/src/wire.rs index cb4062c..fc7377a 100644 --- a/crates/ledger-mcp/src/wire.rs +++ b/crates/ledger-mcp/src/wire.rs @@ -194,4 +194,43 @@ mod tests { ); assert_eq!(classify(" "), Line::Skip); } + + /// Counts the lines written to it; every flush fails. + struct FailingFlush(std::sync::Arc); + + impl AsyncWrite for FailingFlush { + fn poll_write( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let lines = buf.iter().filter(|&&b| b == b'\n').count(); + self.0.fetch_add(lines, std::sync::atomic::Ordering::SeqCst); + std::task::Poll::Ready(Ok(buf.len())) + } + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Err(std::io::Error::other("closed"))) + } + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + } + + /// A client that can't take a flushed reply is gone: the writer stops + /// after the first, rather than writing on into a dead channel. + #[tokio::test] + async fn a_failed_flush_stops_the_writer() { + let written = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let input = std::io::Cursor::new(b"not json\nnor this\n".to_vec()); + let (rmcp_in, rmcp_out, writer) = spawn(input, FailingFlush(written.clone())); + drop((rmcp_in, rmcp_out)); + writer.await.unwrap(); + assert_eq!(written.load(std::sync::atomic::Ordering::SeqCst), 1); + } } diff --git a/crates/ledger-mcp/tests/startup.rs b/crates/ledger-mcp/tests/startup.rs index 577d9a7..a157a1b 100644 --- a/crates/ledger-mcp/tests/startup.rs +++ b/crates/ledger-mcp/tests/startup.rs @@ -116,6 +116,31 @@ fn the_settings_beside_the_books_are_read() { ); } +/// The cash flow roles in wanderbooks.toml beside the data folder are +/// read without --books-toml: its cash account lets cash_flow run with no +/// cash_accounts of its own. +#[test] +fn the_cash_roles_beside_the_books_are_read() { + let dir = temp("roles"); + std::fs::write( + dir.join("wanderbooks.toml"), + "[books]\ncash_accounts = [\"1000\"]\n", + ) + .unwrap(); + let data = dir.join("books-data"); + let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap(); + store + .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) + .unwrap(); + drop(store); + let reply = one_call( + Command::new(BIN).arg("--data-dir").arg(&data), + "books.cash_flow", + serde_json::json!({"start": "2026-01-01", "end": "2026-03-31"}), + ); + assert!(reply.contains("\"isError\":false"), "{reply}"); +} + /// A writer that keeps the books is a conflict: the server gives up with /// an error (after its ten-second wait), never hangs. #[test] diff --git a/docs/testing.md b/docs/testing.md index 61ac2c8..3856ec2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,9 +3,10 @@ How wanderbooks knows its books are right. Strongest oracle first: a test's worth is where its idea of "correct" comes from, not what it runs. Formal proofs are in `docs/verification.md`; the authorities every rule -comes from are in `docs/spec/`. Scope so far: `ledger-core` in full, and -the store in full; the import crate in progress; MCP, config, and GUI -next (gap list). +comes from are in `docs/spec/`. Every crate is covered: `ledger-core`, the +store, import, config, and MCP in full; the GUI where it computes and +writes numbers (its view model and formatting) and in its read-only +promise. ## Layers @@ -30,6 +31,9 @@ next (gap list). | Mutation | can the tests fail? | `ledger-core` (2026-10-13: 1,011 caught + 13 timeouts of 1,024 viable, 0 missed) | `just mutants` | ~3 h | weekly | | Mutation (store) | can the tests fail? | `ledger-store` (2026-10-13: 357 caught + 1 timeout of 365 viable, from 82%; three of its seven survivors then killed; four equivalent left: `sort_value`'s two arms, `OpenFlags` `|` vs `^`, the long form of a sweepless close) | `just mutants-store` | ~12 min | weekly | | Mutation (import) | can the tests fail? | `ledger-import` (2026-10-13: from 91 of 171 (53%) to 208 of 239 viable on the full run; the 31 survivors (XLSX layout, borders, alignment, exact-text grouping, IIF transliteration, tax-summary optional sections, day-equals-month dates) then killed by targeted tests, re-run clean but one: a blank amount cell written unbordered on an unruled row, invisible, so equivalent) | `just mutants-import` | ~1 h | weekly | +| Mutation (config) | can the tests fail? | `ledger-config` (2026-10-14: 55 caught of 55 viable) | `just mutants-config` | minutes | weekly | +| Mutation (MCP) | can the tests fail? | `ledger-mcp` (2026-10-14: from 197 caught + 5 timeouts of 328 viable (62%) to 314 caught + 2 timeouts of 331; of its 15 survivors, five then killed by targeted tests (the settings file beside the books, year earnings with an open period and with last year's partial close, year 1, a failed flush), one dead guard removed (`profit_and_loss_by_class` skipping placeholders, which never take postings), nine equivalent: `money` rescaling an amount already at two places, the lock wait's deadline instant, `year_earnings` starting the day the year starts either way, the bank import's rehearsal sides (a line's side can't make a draft refuse) and its zero-amount side (zero rows are dropped by the parser), a disposal's zero gain adding zero, and the CPA package index summing debits instead of credits (entries balance)) | `just mutants-mcp` | ~1 h | weekly | +| Mutation (GUI) | can the tests fail? | `ledger-gui` view model and formatting (2026-10-14: 140 caught of 176 viable on the full run, then 92 of 99 on a targeted rerun of the money paths after new tests; of its 7 survivors, one then killed by a test (burn over exactly one month, confirmed by a rerun of `compute_kpis`'s 48 mutants), two dead arms removed (`parties`' bill arms: the ledger tags bill entries with their vendor, which wins), four equivalent: an entry's debits vs its credits (they balance), the three-month burn boundary (both paths give 3), burn over zero covered months (only when the first entry is after the window, which then holds no loss), and `compact`'s sign test at zero (zero prints "$0" unsigned either way)) | `just mutants-gui` | ~2 h | weekly | CI: `.github/workflows/check.yml` runs `just check` on every push; `scheduled.yml` runs proofs, the oracle, fuzzing, and mutation weekly. @@ -113,4 +117,4 @@ B-14) are the first. | ASC paragraphs not yet read | DONE (2026-10-14) | read in the ASC basic view (`docs/spec/sources/fasb-asc.md`): all confirm the rules; one citation fixed (230-10-45-14(b), not (2)) | | GnuCash's own close | OPEN | the oracle books closes as ordinary entries; comparing GnuCash's Close Book needs its Scheme API | | Import: spec-blind suite, footing properties, mutation | DONE | sources vendored (Intuit bank CSV and IIF, ECMA-376/Excel numbers, IRS form lines); specs on every module; tests/spec_import.rs (spec-blind, footing and round-trip properties, XLSX read back from its XML, layout and styles); fuzz_import_csv; templates checked against the engine's control accounts; mutation one equivalent left | -| MCP, config, GUI crates | NEXT | the same playbook | +| MCP, config, GUI crates | DONE (2026-10-14) | the same playbook: sourced module specs (config: Pub 538, Treas. Reg. 301.7701-3; MCP: the MCP tool spec and JSON-RPC 2.0); spec-blind suites `spec_config.rs`, `spec_mcp.rs`, `spec_gui.rs`; the MCP oracle `oracle_mcp.rs` (every export against ledger-core); startup, HTTP concurrency, and the viewer's read-only promise (`read_only.rs`) tested; `fuzz_config_parse`; mutation above |