Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify

- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 12:18:00 -06:00
1 parent 4e96b5f54d
commit cd085108c1
53 files changed
+7872 -104

No files matched your search

@@ -0,0 +1,221 @@
//! Spec tests for the fixed asset register PDF
//! (`ledger_import::asset_register`); the XLSX checks live in
//! spec_import.rs beside its workbook reader. Each test names the module
//! INVARIANT it checks.
use chrono::NaiveDate;
use ledger_core::{AssetRegister, AssetRegisterRow};
use ledger_import::asset_register::{layout, typeset_asset_register};
use ledger_import::report::ReportMeta;
use ledger_import::{PdfError, asset_register_pdf};
use rust_decimal::Decimal;
use typst::layout::{Frame, FrameItem};
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn meta() -> ReportMeta {
ReportMeta {
company: "Fernhill Labs LLC".into(),
basis: "Accrual".into(),
prepared_on: d(2026, 10, 15),
journal_seq: 314,
}
}
fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow {
AssetRegisterRow {
asset_id: id,
tag: Some(tag.into()),
description: desc.into(),
serial: None,
in_service: d(2026, 1, 15),
cost: cents(cost),
salvage: Decimal::ZERO,
useful_life_months: 36,
accumulated: cents(acc),
book_value: cents(cost - acc),
disposed: None,
}
}
fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister {
let held = || rows.iter().filter(|r| r.disposed.is_none());
let total_cost = held().map(|r| r.cost).sum();
let total_accumulated = held().map(|r| r.accumulated).sum();
AssetRegister {
as_of: d(2026, 10, 15),
total_book_value: total_cost - total_accumulated,
ledger_cost: total_cost,
ledger_accumulated: total_accumulated - cents(1234),
total_cost,
total_accumulated,
rows,
}
}
fn sample() -> AssetRegister {
let mut disposed = row(3, "FL-0003", "Old monitor", 40000, 0);
disposed.book_value = Decimal::ZERO;
disposed.disposed = Some((d(2026, 9, 30), cents(4500)));
let mut laptop = row(
1,
"FL-0001",
"Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)",
250000,
52778,
);
laptop.serial = Some("FRANBMCP0A1234".into());
laptop.salvage = cents(10000);
register(vec![
laptop,
row(2, "FL-0002", "MacBook Air", 129900, 32475),
disposed,
])
}
fn texts(frame: &Frame, out: &mut String) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(g) => texts(&g.frame, out),
FrameItem::Text(t) => {
out.push_str(&t.text);
out.push(' ');
}
_ => {}
}
}
}
fn all_text(r: &AssetRegister, m: &ReportMeta) -> String {
let doc = typeset_asset_register(r, m).expect("typesets");
let mut out = String::new();
for p in doc.pages() {
texts(&p.frame, &mut out);
}
// Runs end at line breaks: collapse the seams to single spaces.
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// INVARIANT 6: the same register renders to the same bytes.
#[test]
fn pdf_is_deterministic() {
let r = sample();
let a = asset_register_pdf(&r, &meta()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, asset_register_pdf(&r, &meta()).unwrap());
}
/// INVARIANTS 1, 2, 3, 5: header, every asset, the status of each, the
/// register's totals, and the reconciliation with both differences.
#[test]
fn pdf_shows_assets_totals_and_reconciliation() {
let t = all_text(&sample(), &meta());
for s in [
"Fernhill Labs LLC",
"Fixed Asset Register",
"As of October 15, 2026",
"FL-0001",
"FRANBMCP0A1234",
"MacBook Air",
"Active",
"Disposed 2026-09-30, proceeds 45.00",
"Total, assets held",
"3,799.00", // cost held: 2,500.00 + 1,299.00
"852.53", // accumulated held
"2,946.47", // book value held
"Cost per register",
"Cost per books",
"Difference (unregistered cost)",
"Accumulated depreciation per schedule",
"Accumulated depreciation per books",
"Difference (not yet posted)",
"12.34",
"Journal seq 314",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
// The disposed asset's cost (400.00) is shown but in no total.
assert!(t.contains("400.00"));
let l = layout(&sample());
assert_eq!(l.totals, [cents(379900), cents(85253), cents(294647)]);
}
/// INVARIANT 4: an empty register renders a "No fixed assets" line, its
/// totals, and the reconciliation.
#[test]
fn empty_register_renders() {
let r = register(vec![]);
let pdf = asset_register_pdf(&r, &meta()).expect("empty renders");
assert!(pdf.starts_with(b"%PDF"));
let t = all_text(&r, &meta());
for s in [
"No fixed assets",
"Total, assets held",
"Difference (not yet posted)",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
}
/// INVARIANT 6: markup in every text field prints literally and never runs.
#[test]
fn text_is_data() {
let mut a = row(1, "#FL-*1*", "#panic() *bold* $x$ <l> @r [x] \\", 100, 0);
a.serial = Some("_u_ // /*".into());
let m = ReportMeta {
company: "#co *Inc*".into(),
..meta()
};
let t = all_text(&register(vec![a]), &m);
for s in [
"#FL-*1*", "#panic()", "*bold*", "$x$", "<l>", "@r", "[x]", "_u_", "#co", "*Inc*",
] {
assert!(t.contains(s), "{s:?} not literal in {t:?}");
}
for s in [
"#panic()", "*", "_", "$", "[", "]", "\\", "#", "<a>", "@a", "//", "/*", "\"", "`",
] {
let mut a = row(1, s, s, 100, 0);
a.serial = Some(s.into());
let m = ReportMeta {
company: s.into(),
..meta()
};
let r: Result<Vec<u8>, PdfError> = asset_register_pdf(&register(vec![a]), &m);
r.unwrap_or_else(|e| panic!("{s:?}: {e}"));
}
}
/// A long register flows onto more landscape pages instead of being cut.
#[test]
fn long_register_paginates_landscape() {
let rows = (1..=120)
.map(|i| {
row(
i,
&format!("FL-{i:04}"),
&format!("Asset {i}"),
100_000,
1_000,
)
})
.collect();
let doc = typeset_asset_register(&register(rows), &meta()).unwrap();
assert!(doc.pages().len() > 1);
let size = doc.pages()[0].frame.size();
assert!(size.x > size.y, "landscape");
}
/// The sample register for eyeballing: writes `$REGISTER_SAMPLE` (a path).
#[test]
#[ignore = "writes a sample PDF for a human to look at"]
fn write_sample() {
let out = std::env::var("REGISTER_SAMPLE").expect("set REGISTER_SAMPLE to the output path");
std::fs::write(out, asset_register_pdf(&sample(), &meta()).unwrap()).unwrap();
}
+393
View File
@@ -0,0 +1,393 @@
//! Spec tests for `ledger_import::labels` (asset label sheets). Each test
//! names the module INVARIANT it checks.
use ledger_import::labels::{SheetGeometry, qr_svg, template_data, typeset_labels};
use ledger_import::{AssetLabel, LabelSheet, PdfError, asset_labels_pdf};
use typst::layout::{Abs, Frame, FrameItem, Point, Transform};
const COMPANY: &str = "Fernhill Labs LLC";
fn label(i: usize) -> AssetLabel {
AssetLabel {
tag: format!("FL-{i:04}"),
description: format!("Asset number {i}"),
serial: i.is_multiple_of(2).then(|| format!("SN{i:08}")),
}
}
fn labels(n: usize) -> Vec<AssetLabel> {
(1..=n).map(label).collect()
}
fn pages(n: usize, skip: usize) -> usize {
typeset_labels(&labels(n), COMPANY, LabelSheet::Avery5160, skip)
.expect("typesets")
.pages()
.len()
}
/// SOURCE: docs/spec/sources/avery-5160.md, figure by figure, in inches.
#[test]
fn avery_5160_geometry_matches_the_vendored_spec() {
let g = LabelSheet::Avery5160.geometry();
let inch = |x: f64| x * 72.0;
assert_eq!(g.page_width, inch(8.5));
assert_eq!(g.page_height, inch(11.0));
assert_eq!(g.label_width, inch(2.625));
assert_eq!(g.label_height, inch(1.0));
assert_eq!(g.top, inch(0.5));
assert_eq!(g.left, inch(0.1875));
assert_eq!(g.pitch_x, inch(2.75));
assert_eq!(g.pitch_y, inch(1.0));
assert_eq!((g.columns, g.rows, g.per_sheet()), (3, 10, 30));
// The template's cell edges, in PDF points from the bottom-left corner.
let lefts: Vec<f64> = (0..3).map(|c| g.cell(c).0).collect();
assert_eq!(lefts, [13.5, 211.5, 409.5]);
let bottoms: Vec<f64> = (0..10)
.map(|r| g.page_height - (g.cell(r * 3).1 + g.label_height))
.collect();
assert_eq!(
bottoms,
[
684.0, 612.0, 540.0, 468.0, 396.0, 324.0, 252.0, 180.0, 108.0, 36.0
]
);
// Margins and gaps close the page exactly.
let gap = g.pitch_x - g.label_width;
assert_eq!(gap, inch(0.125));
assert_eq!(2.0 * g.left + 3.0 * g.label_width + 2.0 * gap, g.page_width);
assert_eq!(2.0 * g.top + 10.0 * g.pitch_y, g.page_height);
}
/// INVARIANT 3: pages = ceil((skip + labels) / 30).
#[test]
fn one_page_per_sheet() {
assert_eq!(pages(1, 0), 1);
assert_eq!(pages(30, 0), 1);
assert_eq!(pages(30, 1), 2);
assert_eq!(pages(61, 0), 3);
assert_eq!(pages(7, 23), 1);
assert_eq!(pages(8, 23), 2);
}
/// INVARIANT 7: a skip of a whole sheet or more, and no labels, are refused.
#[test]
fn refusals() {
let s = LabelSheet::Avery5160;
for skip in [30, 31, usize::MAX] {
let e = asset_labels_pdf(&labels(1), COMPANY, s, skip).unwrap_err();
assert!(
matches!(e, PdfError::SkipOutOfRange { skip: k, per_sheet: 30 } if k == skip),
"{e:?}"
);
assert!(e.to_string().contains("0 to 29"), "{e}");
}
let e = asset_labels_pdf(&[], COMPANY, s, 0).unwrap_err();
assert!(matches!(e, PdfError::NoLabels), "{e:?}");
assert!(asset_labels_pdf(&labels(1), COMPANY, s, 29).is_ok());
}
/// INVARIANT 5: the same input renders to the same bytes.
#[test]
fn deterministic_bytes() {
let ls = labels(5);
let a = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 3).unwrap();
let b = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 3).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, b);
}
/// A laid-out item with its absolute bounding box on the page, in points
/// from the top-left corner.
#[derive(Debug)]
struct Placed {
text: Option<String>,
image: bool,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
}
fn walk(frame: &Frame, ts: Transform, out: &mut Vec<Placed>) {
for (pos, item) in frame.items() {
let at = |dx: Abs, dy: Abs| Point::new(pos.x + dx, pos.y + dy).transform(ts);
match item {
FrameItem::Group(g) => {
let ts = ts
.pre_concat(Transform::translate(pos.x, pos.y))
.pre_concat(g.transform);
walk(&g.frame, ts, out);
}
FrameItem::Text(t) => {
// The run's ink box: baseline at pos.y; Inter's ascender and
// descender are within 1em above and 0.3em below.
let a = at(Abs::zero(), -t.size);
let b = at(t.width(), t.size * 0.3);
out.push(Placed {
text: Some(t.text.to_string()),
image: false,
x0: a.x.to_pt(),
y0: a.y.to_pt(),
x1: b.x.to_pt(),
y1: b.y.to_pt(),
});
}
FrameItem::Image(_, size, _) => {
let (a, b) = (at(Abs::zero(), Abs::zero()), at(size.x, size.y));
out.push(Placed {
text: None,
image: true,
x0: a.x.to_pt(),
y0: a.y.to_pt(),
x1: b.x.to_pt(),
y1: b.y.to_pt(),
});
}
FrameItem::Shape(..) | FrameItem::Link(..) | FrameItem::Tag(..) => {}
}
}
}
fn placed(doc: &typst_layout::PagedDocument, page: usize) -> Vec<Placed> {
let mut out = Vec::new();
walk(&doc.pages()[page].frame, Transform::identity(), &mut out);
out
}
fn inside(p: &Placed, (x, y, w, h): (f64, f64, f64, f64)) -> bool {
const EPS: f64 = 1e-6;
p.x0 >= x - EPS && p.y0 >= y - EPS && p.x1 <= x + w + EPS && p.y1 <= y + h + EPS
}
fn cell_of(g: &SheetGeometry, p: &Placed) -> Option<usize> {
(0..g.per_sheet()).find(|&i| inside(p, g.cell(i)))
}
/// INVARIANTS 1 and 2: every text run and image lies wholly inside one cell,
/// each label's tag and QR code sit in the cell its order and the skip put
/// it in, and the description, however long, never leaves the cell.
#[test]
fn every_label_lies_inside_its_cell_in_order() {
let g = LabelSheet::Avery5160.geometry();
let mut ls = labels(40);
ls[3].description = "An extraordinarily long description that goes on and on, \
far longer than any label could hold on two lines at any legible size"
.repeat(3);
ls[4].tag = "FL-0005-LONGER-TAG".to_string();
// An absurd tag: cut with an ellipsis (the QR code holds it whole), but
// still inside its cell.
ls[6].tag = "X".repeat(60);
ls[5].serial = Some("S".repeat(80));
let skip = 7;
let doc = typeset_labels(&ls, COMPANY, LabelSheet::Avery5160, skip).unwrap();
assert_eq!(doc.pages().len(), 2);
for page in 0..2 {
let items = placed(&doc, page);
assert!(!items.is_empty());
for p in &items {
assert!(
cell_of(&g, p).is_some(),
"page {page}: outside every cell: {p:?}"
);
}
// The cells holding something are exactly this page's labels.
let mut used: Vec<usize> = items.iter().filter_map(|p| cell_of(&g, p)).collect();
used.sort_unstable();
used.dedup();
let expected: Vec<usize> = if page == 0 {
(skip..30).collect()
} else {
(0..17).collect()
};
assert_eq!(used, expected, "page {page}");
// One QR image per label, in its cell.
let images: Vec<usize> = items
.iter()
.filter(|p| p.image)
.filter_map(|p| cell_of(&g, p))
.collect();
assert_eq!(images, expected, "page {page}: one QR per label, in order");
}
// Each tag is printed whole in its own cell.
for (i, l) in ls.iter().enumerate() {
let (page, pos) = ((skip + i) / 30, (skip + i) % 30);
if i == 6 {
assert!(cell_text(&doc, page, pos).contains("XXX…"));
continue;
}
let items = placed(&doc, page);
let text: String = items
.iter()
.filter(|p| cell_of(&g, p) == Some(pos))
.filter_map(|p| p.text.clone())
.collect::<Vec<_>>()
.join(" ");
assert!(
text.contains(&l.tag),
"label {i} at page {page} pos {pos}: {text:?}"
);
}
}
/// The text runs in one cell, joined.
fn cell_text(doc: &typst_layout::PagedDocument, page: usize, pos: usize) -> String {
let g = LabelSheet::Avery5160.geometry();
placed(doc, page)
.iter()
.filter(|p| cell_of(&g, p) == Some(pos))
.filter_map(|p| p.text.clone())
.collect::<Vec<_>>()
.join(" ")
}
/// INVARIANT 6: markup in any field prints literally and never runs.
#[test]
fn text_is_data() {
let l = AssetLabel {
tag: "#FL-*1*".to_string(),
description: "#panic() *bold* $x$".to_string(),
serial: Some("_u_ @ref".to_string()),
};
let doc = typeset_labels(&[l], "<co> #co", LabelSheet::Avery5160, 0).expect("markup is inert");
let text = cell_text(&doc, 0, 0);
for s in [
"#FL-*1*", "#panic()", "*bold*", "$x$", "S/N", "_u_", "@ref", "<co>", "#co",
] {
assert!(text.contains(s), "{s:?} not printed literally in {text:?}");
}
// Each markup string alone, in every field, still renders.
for m in [
"#panic()", "*", "_", "$", "[", "]", "\\", "#", "<a>", "@a", "//", "/*", "\"", "`",
] {
let l = AssetLabel {
tag: m.to_string(),
description: m.to_string(),
serial: Some(m.to_string()),
};
asset_labels_pdf(&[l], m, LabelSheet::Avery5160, 0)
.unwrap_or_else(|e| panic!("{m:?}: {e}"));
}
}
/// INVARIANT 1: an overlong description is shortened with an ellipsis;
/// a short one prints whole.
#[test]
fn long_text_is_ellipsized_short_text_is_whole() {
let long = "word ".repeat(80);
let ls = [
AssetLabel {
tag: "FL-0001".into(),
description: long.clone(),
serial: None,
},
AssetLabel {
tag: "FL-0002".into(),
description: "MacBook Air".into(),
serial: None,
},
];
let doc = typeset_labels(&ls, COMPANY, LabelSheet::Avery5160, 0).unwrap();
let t0 = cell_text(&doc, 0, 0);
assert!(t0.contains('…'), "{t0:?}");
assert!(t0.matches("word").count() < 80);
let t1 = cell_text(&doc, 0, 1);
assert!(t1.contains("MacBook Air") && !t1.contains('…'), "{t1:?}");
assert!(!t1.contains("S/N"), "no serial, no S/N line: {t1:?}");
}
/// Rasterize the SVG the template receives (qrcode's renderer: one
/// background rect, then `M{x} {y}h{w}v{h}H{x}V{y}` per dark run).
fn rasterize(svg: &str, scale: usize) -> (usize, Vec<bool>) {
let attr = |name: &str| -> usize {
let at = svg.find(&format!(" {name}=\"")).unwrap() + name.len() + 3;
svg[at..].split('"').next().unwrap().parse().unwrap()
};
let (w, h) = (attr("width"), attr("height"));
assert_eq!(w, h);
let d = svg
.split(" d=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap();
let mut dark = vec![false; w * w];
for run in d.split('M').filter(|s| !s.is_empty()) {
let nums: Vec<usize> = run
.split(|c: char| c.is_ascii_alphabetic() || c == ' ')
.filter(|s| !s.is_empty())
.map(|s| s.parse().unwrap())
.collect();
let [x, y, rw, rh, ..] = nums[..] else {
panic!("bad run {run:?}")
};
for yy in y..y + rh {
for xx in x..x + rw {
dark[yy * w + xx] = true;
}
}
}
let n = w * scale;
let px = (0..n * n)
.map(|i| dark[(i / n / scale) * w + (i % n) / scale])
.collect();
(n, px)
}
/// INVARIANT 4: the QR SVG each label prints decodes, with an independent
/// decoder (rqrr), to exactly its tag; and it is the SVG the template gets.
#[test]
fn qr_decodes_to_exactly_the_tag() {
let tags = [
"FL-0001",
"FL-0002",
"FL-9999",
"A",
"fl-0001 lower case & symbols: /?#",
"FL-0005-WITH-A-RATHER-LONG-TAG",
];
for tag in tags {
let svg = qr_svg(tag).unwrap();
let (n, px) = rasterize(&svg, 4);
let mut img = rqrr::PreparedImage::prepare_from_bitmap(n, n, |x, y| px[y * n + x]);
let grids = img.detect_grids();
assert_eq!(grids.len(), 1, "{tag:?}: one code");
let (meta, content) = grids[0].decode().unwrap();
assert_eq!(content, tag);
assert_eq!(meta.ecc_level, 0, "{tag:?}: level M (rqrr numbers M as 0)");
}
let data = template_data(&[label(1)], COMPANY, LabelSheet::Avery5160, 0).unwrap();
assert_eq!(data["pages"][0][0]["qr"], qr_svg("FL-0001").unwrap());
}
/// The sample sheet, for eyeballing: writes `$LABELS_SAMPLE` (a path).
/// Run with `LABELS_SAMPLE=/path/out.pdf cargo test -p ledger-import --test
/// labels -- --ignored sample`.
#[test]
#[ignore = "writes a sample PDF for a human to look at"]
fn sample() {
let out = std::env::var("LABELS_SAMPLE").expect("set LABELS_SAMPLE to the output path");
let ls = [
AssetLabel {
tag: "FL-0001".into(),
description: "Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)".into(),
serial: Some("FRANBMCP0A1234".into()),
},
AssetLabel {
tag: "FL-0002".into(),
description: "MacBook Air".into(),
serial: None,
},
AssetLabel {
tag: "FL-0003".into(),
description: "Studio monitor arm with dual gas springs, cable management channel, \
VESA 75/100 adapter plate, desk clamp and grommet mount, and a spare set of \
every bolt the box came with"
.into(),
serial: Some("ARM-2026-0099887766".into()),
},
];
let pdf = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 0).unwrap();
std::fs::write(out, pdf).unwrap();
}
+162
View File
@@ -3004,3 +3004,165 @@ fn tax_summary_optional_sections() {
assert!(!has(&bare, "Cost of goods sold"));
assert!(!has(&bare, "Not yet categorized"));
}
// ---------------------------------------------------------------------------
// Fixed asset register (ledger_import::asset_register)
// ---------------------------------------------------------------------------
/// A register as ledger_core::fixed_asset_register would return it: two
/// assets held, one disposed (shown, in no total), and books that differ
/// from the register on both counts.
fn asset_register_fixture() -> ledger_core::AssetRegister {
use ledger_core::AssetRegisterRow;
let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| {
AssetRegisterRow {
asset_id: id,
tag: tag.map(Into::into),
description: desc.into(),
serial: serial.map(Into::into),
in_service: d(2026, 1, 15),
cost: cents(cost),
salvage: cents(10000),
useful_life_months: 36,
accumulated: cents(acc),
book_value: cents(cost - acc),
disposed,
}
};
ledger_core::AssetRegister {
as_of: d(2026, 10, 15),
rows: vec![
row(
1,
Some("FL-0001"),
"Framework Laptop 13",
Some("FRAN123"),
250000,
52778,
None,
),
row(2, None, "Desk", None, 120000, 25000, None),
{
let mut r = row(
3,
Some("FL-0003"),
"Old monitor",
None,
40000,
0,
Some((d(2026, 9, 30), cents(4500))),
);
r.book_value = Decimal::ZERO;
r
},
],
total_cost: cents(370000),
total_accumulated: cents(77778),
total_book_value: cents(292222),
ledger_cost: cents(400000),
ledger_accumulated: cents(70000),
}
}
fn number_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> Decimal {
match cells.get(&at) {
Some(Cell::Num(v)) => parse_num(v),
other => panic!("{at:?}: expected a number, got {other:?}"),
}
}
fn text_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> String {
match cells.get(&at) {
Some(Cell::Text(t)) => t.clone(),
other => panic!("{at:?}: expected text, got {other:?}"),
}
}
/// asset_register INVARIANTS 1-3 and 5, read back from the workbook's XML:
/// header, headings, one row per asset in order (disposed marked), the
/// register's own totals, and the reconciliation under Cost and
/// Accumulated depreciation.
#[test]
fn asset_register_xlsx_cells() {
let r = asset_register_fixture();
let x = ledger_import::asset_register_xlsx(&r, &meta()).unwrap();
let c = sheet_cells(&x);
assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC");
assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register");
assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026");
let heads: Vec<String> = (0..10).map(|i| text_cell(&c, (5, i))).collect();
assert_eq!(
heads,
[
"TAG",
"DESCRIPTION",
"SERIAL",
"IN SERVICE",
"COST",
"SALVAGE",
"LIFE (MONTHS)",
"ACCUMULATED DEPRECIATION",
"BOOK VALUE",
"STATUS"
]
);
assert_eq!(text_cell(&c, (6, 0)), "FL-0001");
assert_eq!(text_cell(&c, (6, 1)), "Framework Laptop 13");
assert_eq!(text_cell(&c, (6, 2)), "FRAN123");
assert_eq!(text_cell(&c, (6, 3)), "2026-01-15");
assert_eq!(number_cell(&c, (6, 4)), dec("2500.00"));
assert_eq!(number_cell(&c, (6, 5)), dec("100.00"));
assert_eq!(number_cell(&c, (6, 6)), dec("36"));
assert_eq!(number_cell(&c, (6, 7)), dec("527.78"));
assert_eq!(number_cell(&c, (6, 8)), dec("1972.22"));
assert_eq!(text_cell(&c, (6, 9)), "Active");
assert_eq!(text_cell(&c, (7, 1)), "Desk");
assert_eq!(text_cell(&c, (8, 0)), "FL-0003");
assert_eq!(text_cell(&c, (8, 9)), "Disposed 2026-09-30, proceeds 45.00");
assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO);
// Totals: the register's (held assets only), not a re-sum of the rows.
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
assert_eq!(number_cell(&c, (9, 4)), dec("3700.00"));
assert_eq!(number_cell(&c, (9, 7)), dec("777.78"));
assert_eq!(number_cell(&c, (9, 8)), dec("2922.22"));
assert!(!c.contains_key(&(9, 5)), "no salvage total");
// Reconciliation.
assert_eq!(text_cell(&c, (11, 0)), "Reconciliation to the books");
let recon = [
(12, "Cost per register", 4, "3700.00"),
(13, "Cost per books", 4, "4000.00"),
(14, "Difference (unregistered cost)", 4, "300.00"),
(16, "Accumulated depreciation per schedule", 7, "777.78"),
(17, "Accumulated depreciation per books", 7, "700.00"),
(18, "Difference (not yet posted)", 7, "77.78"),
];
for (row, label, col, amt) in recon {
assert_eq!(text_cell(&c, (row, 0)), label);
assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}");
}
let footer = c
.values()
.any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42")));
assert!(footer, "footer present");
}
/// asset_register INVARIANT 4: an empty register says so, still totals,
/// and still reconciles (the books may carry unregistered cost).
#[test]
fn asset_register_xlsx_empty() {
let r = ledger_core::AssetRegister {
as_of: d(2026, 10, 15),
rows: vec![],
total_cost: Decimal::ZERO,
total_accumulated: Decimal::ZERO,
total_book_value: Decimal::ZERO,
ledger_cost: cents(99900),
ledger_accumulated: Decimal::ZERO,
};
let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap());
assert_eq!(text_cell(&c, (6, 0)), "No fixed assets");
assert_eq!(text_cell(&c, (7, 0)), "Total, assets held");
assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO);
assert_eq!(text_cell(&c, (12, 0)), "Difference (unregistered cost)");
assert_eq!(number_cell(&c, (12, 4)), dec("999.00"));
}