Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A project is a class with facts (description, status, kind, party, dates, links, notes) in one project.set event behind the format feature projects. Four tools (books.add_project, update_project, projects, project); documents and decisions link to projects; B-37: on-spec costs are expensed as incurred, repayment is revenue, and the unrecovered cost is never below zero (Kani). Viewer: a Projects list and a project page with its figures, by month and by account. The sidebar measures its footer instead of reserving a fixed 172 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+7446
-217
No files matched your search
@@ -1366,6 +1366,7 @@ fn reads_of_old_books() -> Vec<(&'static str, Value)> {
|
||||
("books.list_receipts", json!({})),
|
||||
("books.list_vendors", json!({})),
|
||||
("books.list_classes", json!({})),
|
||||
("books.projects", json!({})),
|
||||
("books.parties", json!({})),
|
||||
("books.statements", json!({})),
|
||||
("books.reconciliations", json!({})),
|
||||
@@ -1715,3 +1716,20 @@ fn hostile_renames_ranges_and_categories_are_answered() {
|
||||
);
|
||||
reopens(m, &p, "P3's hostile inputs");
|
||||
}
|
||||
|
||||
/// books.project over the whole calendar answers, its months the last 240
|
||||
/// (each month is a statement: a hundred thousand of them would not).
|
||||
#[test]
|
||||
fn a_project_over_the_whole_calendar_answers_quickly() {
|
||||
let p = places("project-wide");
|
||||
let mut m = Mcp::start(&p);
|
||||
fixture(&mut m, &p);
|
||||
m.ok("books.add_project", json!({"name": "Harbor"}));
|
||||
let t = std::time::Instant::now();
|
||||
let p = m.ok(
|
||||
"books.project",
|
||||
json!({"project": "Harbor", "start": "0001-01-01", "end": "9999-12-31"}),
|
||||
);
|
||||
assert_eq!(p["period"]["by_month"].as_array().unwrap().len(), 240);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
@@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 112, "expected 112 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 116, "expected 116 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
|
||||
@@ -3259,6 +3259,16 @@ fn references_resolve_unless_truly_ambiguous() {
|
||||
let dir = test_dir("refs");
|
||||
let s = Store::create(&data_dir(&dir), Currency::USD).unwrap();
|
||||
drop(s);
|
||||
// Classes as an earlier version could write them (a name of digits
|
||||
// only can't be added now): 1 "placeholder-name", 2 named "1", 3
|
||||
// named "3".
|
||||
let mut w = ledger_store::JournalWriter::open(&data_dir(&dir).join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap();
|
||||
for (i, n) in ["placeholder-name", "1", "3"].iter().enumerate() {
|
||||
w.append("class.added", json!({"id": i + 1, "name": n}))
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
// An account whose code is its own id.
|
||||
let first = m.ok(
|
||||
@@ -3286,24 +3296,16 @@ fn references_resolve_unless_truly_ambiguous() {
|
||||
json!({"entry_id": e, "account": first.to_string(), "side": "debit", "amount": "5.00"}),
|
||||
);
|
||||
|
||||
// A class named by its own id.
|
||||
let c = m.ok("books.add_class", json!({"name": "placeholder-name"}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let c2 = m.ok("books.add_class", json!({"name": c.to_string()}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
// Class 2 is named "1", class 1's id.
|
||||
let c = 1u64;
|
||||
// "c" is class c's id and class c2's name: ambiguous.
|
||||
m.refuse(
|
||||
"books.add_line",
|
||||
json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00",
|
||||
"class": c.to_string()}),
|
||||
);
|
||||
// c2 named by its own id: only one class is meant.
|
||||
let c3 = m.ok("books.add_class", json!({"name": (c2 + 1).to_string()}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(c3, c2 + 1, "classes number in order");
|
||||
// c3 named by its own id: only one class is meant.
|
||||
let c3 = 3u64;
|
||||
m.ok(
|
||||
"books.add_line",
|
||||
json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00",
|
||||
|
||||
@@ -775,6 +775,16 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// Classes, parties, 1099.
|
||||
m.ok("books.add_class", json!({"name": "Rondo"}));
|
||||
m.ok("books.list_classes", json!({}));
|
||||
m.ok(
|
||||
"books.add_project",
|
||||
json!({"name": "Harbor app", "kind": "on_spec", "started_on": "2026-01-05"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.update_project",
|
||||
json!({"project": "Rondo", "kind": "internal", "description": "The Rondo app"}),
|
||||
);
|
||||
m.ok("books.projects", json!({}));
|
||||
m.ok("books.project", json!({"project": "Rondo"}));
|
||||
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -2194,15 +2204,12 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
fn inv6_ambiguous_class_reference_refused() {
|
||||
let dir = test_dir("inv6-class");
|
||||
let toml = books_toml(&dir, "1000000.00", "");
|
||||
old_classes(&dir, &["Alpha", "1"]);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
let alpha = m.ok("books.add_class", json!({"name": "Alpha"}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let named = m.ok("books.add_class", json!({"name": alpha.to_string()}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_ne!(alpha, named);
|
||||
let (alpha, named) = (1u64, 2u64);
|
||||
// No new name of digits only.
|
||||
m.refuse("books.add_class", json!({"name": "3"}));
|
||||
let e = m.draft("2026-06-01", "classy", &[]);
|
||||
// add_line with an ambiguous class is refused too, but writes the
|
||||
// line first: see add_line_bad_class_leaves_journal_unchanged.
|
||||
@@ -2220,6 +2227,19 @@ fn inv6_ambiguous_class_reference_refused() {
|
||||
m.ok("books.profit_and_loss", json!({"class": "Alpha"}));
|
||||
}
|
||||
|
||||
/// Books an earlier version wrote holding these classes, in id order: a
|
||||
/// name of digits only can't be added now (it would read as an id), so
|
||||
/// only old books hold the id/name pairs invariant 6 refuses.
|
||||
fn old_classes(dir: &Path, names: &[&str]) {
|
||||
let books = dir.join("books-data");
|
||||
drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap());
|
||||
let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
w.append("class.added", json!({"id": i + 1, "name": n}))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant 2: a refused add_line leaves the journal unchanged, here when
|
||||
/// the line's class doesn't resolve (unknown, or ambiguous per invariant
|
||||
/// 6).
|
||||
@@ -2227,10 +2247,9 @@ fn inv6_ambiguous_class_reference_refused() {
|
||||
fn add_line_bad_class_leaves_journal_unchanged() {
|
||||
let dir = test_dir("bug-class");
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
old_classes(&dir, &["Alpha", "1"]);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
m.ok("books.add_class", json!({"name": "Alpha"}));
|
||||
m.ok("books.add_class", json!({"name": "1"}));
|
||||
let e = m.draft("2026-06-01", "classy", &[]);
|
||||
for class in ["Nope", "99", "1"] {
|
||||
// Mcp::tool asserts the journal is unchanged on refusal.
|
||||
@@ -2251,10 +2270,9 @@ fn add_line_bad_class_leaves_journal_unchanged() {
|
||||
fn profit_and_loss_by_class_validates_class() {
|
||||
let dir = test_dir("bug-pnl-class");
|
||||
let toml = books_toml(&dir, "1000000.00", "");
|
||||
old_classes(&dir, &["Alpha", "1"]);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
m.ok("books.add_class", json!({"name": "Alpha"}));
|
||||
m.ok("books.add_class", json!({"name": "1"}));
|
||||
m.refuse("books.profit_and_loss_by_class", json!({"class": "Nope"}));
|
||||
m.refuse("books.profit_and_loss_by_class", json!({"class": "1"}));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2071,6 +2071,7 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
("6300", "Insurance", "expense"),
|
||||
]);
|
||||
m.add_party("Dana Employee", &["employee"]);
|
||||
m.ok("books.add_project", json!({"name": "Harbor"}));
|
||||
let entry = m.post(
|
||||
"2026-02-01",
|
||||
json!({}),
|
||||
@@ -2193,6 +2194,10 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
"books.set_fx_rate",
|
||||
json!({"currency": "EUR", "date": "2026-01-02", "rate": "1.10"}),
|
||||
),
|
||||
(
|
||||
"books.update_project",
|
||||
json!({"project": "Harbor", "kind": "client", "status": "paused"}),
|
||||
),
|
||||
];
|
||||
let list = m.rpc("tools/list", json!({}));
|
||||
let marked: std::collections::BTreeSet<String> = list["result"]["tools"]
|
||||
|
||||
Reference in new issue
Block a user