ci: shard the weekly mutation runs; no viewer job; six import mutants caught

The first scheduled run (2026-10-19) failed four ways: the viewer's
mutation job called the recipe retired in P2 slice 1 (owner, 2026-10-18:
no mutation runs on the GUI); the core and MCP runs were stopped at
GitHub's six hours; the import run missed six mutants.

- Every mutation recipe takes a shard (k/n, default all of it); the
  workflow runs the core in 8 shards, the store in 4, the importers in 2,
  the MCP server in 16, each its own job with a 350-minute limit.
- Test timeouts are three times each run's own baseline, never under the
  recipe's old fixed value, so slower runners don't report false
  timeouts.
- The mutants-gui job is gone.
- ledger-import: rule_marks_match_their_notes,
  asset_register_xlsx_headings_and_totals_rule and
  xlsx_missing_amounts_are_cells_only_on_ruled_rows catch the six
  (the tax-basis rule marks, a heading's alignment, the totals row's
  blank cells, and a blank amount cell only on ruled rows), each checked
  against its mutant by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-19 10:00:38 -06:00
1 parent b47331e9d5
commit 13b09aa766
5 files changed
+219 -40

No files matched your search

+46 -1
View File
@@ -6,7 +6,8 @@
use chrono::NaiveDate;
use ledger_core::{AssetRegister, AssetRegisterRow, TaxBasisRule};
use ledger_import::asset_register::{
layout, missing_line, notes, rule_cited, rule_label, typeset_asset_register,
layout, missing_line, notes, rule_cited, rule_label, rule_mark, template_data,
typeset_asset_register,
};
use ledger_import::report::ReportMeta;
use ledger_import::{PdfError, asset_register_pdf};
@@ -277,6 +278,50 @@ fn rule_words() {
);
}
/// INVARIANT 9: the notes number the rules 1, 2, 3 (the general note
/// first and unnumbered, the built-in gain note 4), each rule's note
/// cites that rule, and the PDF marks each recorded line's rule with its
/// note's number; a line with no rule recorded has no mark.
#[test]
fn rule_marks_match_their_notes() {
use TaxBasisRule::*;
let rules = [Cost, Carryover, PersonalUseConversion];
assert_eq!(rules.map(rule_mark), ["1", "2", "3"]);
let n = notes();
let marks: Vec<&str> = n.iter().map(|n| n.mark.as_str()).collect();
assert_eq!(marks, ["", "1", "2", "3", "4"]);
for rule in rules {
let note = n.iter().find(|n| n.mark == rule_mark(rule)).unwrap();
let cites = format!("{}: ", rule_cited(rule));
assert!(note.text.starts_with(&cites), "{rule:?}: {:?}", note.text);
}
// The template's input: each line its rule's mark, in register order.
let r = sample();
let data = template_data(&r, &meta());
let marks: Vec<&str> = data["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["rule_mark"].as_str().unwrap())
.collect();
assert_eq!(marks, ["1", "2", "1", "3", ""]);
// The PDF: the mark right after the rule, the note under its number.
let t = all_text(&r, &meta());
for s in [
"(Ryzen 7 7840U, 64 GB, 4 TB) Cost 1 2,500.00",
"MacBook Air Carryover 2 1,299.00",
"Old monitor Cost 1 400.00",
"Sony A7 IV camera Personal-use conversion 3 1,650.00",
"Standing desk Not recorded 800.00",
"1 Cost: property the company bought",
"2 Carryover (IRC §723): property a member contributes",
"3 Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted",
"4 Built-in gain (loss):",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
}
/// INVARIANT 4: an empty register renders a "No fixed assets" line, its
/// totals, and the reconciliation.
#[test]
+102
View File
@@ -2828,6 +2828,63 @@ fn xlsx_statement_layout_and_rules() {
assert!(w.contains_key(&1) && w.contains_key(&2), "{w:?}");
}
/// INVARIANT 5's blank cells: a missing amount on a ruled row (a subtotal
/// under amounts, a total) is a blank cell in that row's ruled amount
/// format; on any other row (a line, a subtotal with nothing above it)
/// it writes no cell at all.
#[test]
fn xlsx_missing_amounts_are_cells_only_on_ruled_rows() {
let row = |kind, label: &str, amounts: Vec<Option<Decimal>>| Row {
kind,
label: label.into(),
amounts,
currency: false,
rule_above: false,
};
let st = report::statement(
"Blanks",
"July 2025".into(),
&["Book", "Tax"],
vec![
row(RowKind::Section, "Income", vec![]),
row(RowKind::Line, "Sales", vec![Some(cents(100)), None]),
row(RowKind::Line, "Fees", vec![Some(cents(-5)), None]),
row(
RowKind::Subtotal,
"Total income",
vec![Some(cents(95)), None],
),
row(RowKind::Spacer, "", vec![]),
row(RowKind::Section, "Empty", vec![]),
row(RowKind::Subtotal, "Total empty", vec![Some(cents(0)), None]),
row(RowKind::Spacer, "", vec![]),
row(RowKind::Total, "Net", vec![Some(cents(95)), None]),
],
);
let ruled: Vec<&str> = st
.rows
.iter()
.filter(|r| r.rule_above)
.map(|r| r.label.as_str())
.collect();
assert_eq!(ruled, ["Total income", "Net"]);
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta()).unwrap();
let cells = sheet_cells(&bytes);
let styles = cell_styles(&bytes);
for r in st.rows.iter().filter(|r| !r.amounts.is_empty()) {
let at = row_of(&cells, &r.label, 0).unwrap();
assert!(cells.contains_key(&(at, 1)), "{:?}: its amount", r.label);
assert!(!cells.contains_key(&(at, 2)), "{:?}: no value", r.label);
if r.rule_above {
let blank = styles.get(&(at, 2));
assert!(blank.is_some_and(|s| s.top_border), "{:?}", r.label);
assert_eq!(blank, styles.get(&(at, 1)), "{:?}: as its amount", r.label);
} else {
assert!(!styles.contains_key(&(at, 2)), "{:?}: no cell", r.label);
}
}
}
/// INVARIANT 4 on the general ledger and journal: amount headings
/// right-aligned and the others not, accounts one empty row apart, and the
/// footer one row below (also with nothing to show).
@@ -3217,12 +3274,57 @@ fn asset_register_xlsx_cells() {
assert!(
text_cell(&c, (22, 0)).starts_with("Book value is the fair market value at contribution")
);
for (row, start) in [
(23, "1. Cost: "),
(24, "2. Carryover (IRC §723): "),
(
25,
"3. Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): ",
),
(26, "4. Built-in gain (loss): "),
] {
assert!(text_cell(&c, (row, 0)).starts_with(start), "note {start:?}");
}
let footer = c
.values()
.any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42")));
assert!(footer, "footer present");
}
/// asset_register INVARIANTS 2 and 5 as laid out (crate::xlsx INVARIANTS
/// 4-5): the headings over the amounts and over the life in months
/// right-aligned and the others not, and the totals row ruled across
/// every column after its label in the totals format, blank cells and
/// totals alike; the asset rows above it aren't ruled.
#[test]
fn asset_register_xlsx_headings_and_totals_rule() {
let x = ledger_import::asset_register_xlsx(&asset_register_fixture(), &meta()).unwrap();
let c = sheet_cells(&x);
let styles = cell_styles(&x);
let right_aligned = [4, 5, 6, 7, 8, 10, 11, 12];
for col in 0..14 {
let right = styles[&(5, col)].halign.as_deref() == Some("right");
assert_eq!(right, right_aligned.contains(&col), "heading column {col}");
}
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
assert!(!styles[&(9, 0)].top_border, "the label isn't ruled");
let total = &styles[&(9, 4)];
assert!(total.top_border, "{total:?}");
for col in 1..14 {
assert_eq!(
styles.get(&(9, col)),
Some(total),
"totals row column {col}"
);
}
for row in 6..=8 {
for col in 0..14 {
let ruled = styles.get(&(row, col)).is_some_and(|s| s.top_border);
assert!(!ruled, "asset row {row} column {col}");
}
}
}
/// asset_register INVARIANT 4: an empty register says so, still totals,
/// and still reconciles (the books may carry unregistered cost).
#[test]