test(accounting): row 6 sees a refusal, a second real corpus, and 34 of 34 mutants

Row 6 was GREEN with R761 100 % rejected: every element of a refused document
is booked as a coded rejection, so u = 0 and d = 0 and the bundle is empty.
`refused_whole` asks that question on its own now -- the build order asked for
an honest red there, and PM re-measured the green on 2026-09-18 with
`okf build` exiting 1 unseen.

A skipped row no longer leaves the verdict unqualified (`GATE GREEN (row 6 not
run: ...)`), and the exit code is non-zero locally when a corpus source is on
the machine and its row did not run. m-2.

N200 Vegbygging:2024 joins R761 as a second real corpus. R761 holds 0 `fig`,
0 formulas and 0 references, so the only real corpus could not have found the
hole in the STS role map; N200 carries 194 citations, 49 figures and 135
footnotes. A `.json` whose root holds an STS node tree is counted as STS
rather than as keys and leaves -- the container is not the content.

M-4: the review's 26 mutants, ported to the code as it stands, plus 8 for the
new checks. 34 of 34 killed. `tools/okf_gate_mutants.py` runs on a copy of the
tree, and a pattern that does not match is an ERROR and exit 2 -- a mutant
that could not be applied was never measured. That fired once, on M13, after a
refactor moved the line it edits.

m-3: `APPROVED_EXCEPTIONS` was read by no row, so approving one changed
nothing. Each pair is now checked against the witness's own vocabulary and the
run says why it moves no denominator. The gate also prints its OWN limits
beside the verdict, m-5 among them.

The product's accounting tests state the new truth instead of the old one:
`okf build --accounting` over the fixture corpus exits 1 with SIX unaccounted
elements in its own vocabulary -- its first real finding, reachable only now
that fixtures carry the constructs. Four shared element names disagree with
the witness, each pinned with its cause; one of the four is a double count
this package makes (a text box's paragraph, once inside the box and again in
the paragraph carrying it).

Three fixture defects were found and fixed while building them, each of which
would have reported a loss the build never had: a shared string table not
related to the workbook, a `graphicData` with no `uri`, and an odt
`styles.xml` without `<office:styles/>`.

Report: docs/2026-09-18-regnskapsgaten-herdet.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 02:48:34 +02:00
commit ed8d9d709f
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
11 changed files with 1146 additions and 85 deletions

View file

@ -120,7 +120,9 @@ def _slide(title: str, body: str, *, hidden: bool = False) -> str:
+ f"<p:txBody><a:p><a:r><a:t>{title}</a:t></a:r></a:p></p:txBody></p:sp>"
+ "<p:sp><p:nvSpPr><p:nvPr/></p:nvSpPr>"
+ f"<p:txBody><a:p><a:r><a:t>{body}</a:t></a:r></a:p></p:txBody></p:sp>"
+ "<p:graphicFrame><a:graphic><a:graphicData><a:tbl><a:tr>"
+ "<p:graphicFrame><a:graphic><a:graphicData "
+ 'uri="http://schemas.openxmlformats.org/drawingml/2006/table">'
+ "<a:tbl><a:tr>"
+ "<a:tc><a:txBody><a:p><a:r><a:t>Post</a:t></a:r></a:p></a:txBody></a:tc>"
+ "<a:tc><a:txBody><a:p><a:r><a:t>84.1</a:t></a:r></a:p></a:txBody></a:tc>"
+ "</a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"
@ -141,11 +143,23 @@ _PPTX_PARTS: dict[str, str | bytes] = {
f'openxmlformats-officedocument.presentationml.slide+xml"/>'
for n in (1, 2)
)
+ '<Override PartName="/ppt/notesSlides/notesSlide1.xml" ContentType="application/vnd.'
+ 'openxmlformats-officedocument.presentationml.notesSlide+xml"/>'
+ "</Types>",
"_rels/.rels": _XML
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>',
"ppt/_rels/presentation.xml.rels": _XML
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/slide" Target="slides/slide1.xml"/>'
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/slide" Target="slides/slide2.xml"/></Relationships>',
"ppt/slides/_rels/slide1.xml.rels": _XML
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/notesSlide" Target="../notesSlides/notesSlide1.xml"/></Relationships>',
"ppt/presentation.xml": _XML
+ f"<p:presentation {_P}><p:sldIdLst>"
+ '<p:sldId id="256" r:id="rId1"/><p:sldId id="257" r:id="rId2"/>'
@ -171,6 +185,15 @@ _XLSX_PARTS: dict[str, str | bytes] = {
+ 'relationships+xml"/>'
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-'
+ 'officedocument.spreadsheetml.sheet.main+xml"/>'
# Declaring the parts is not decoration. Without the sharedStrings
# override every `t="s"` cell converts to an EMPTY cell -- the fixture
# would have reported four cells lost that the build never lost.
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.'
+ 'openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
+ '<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.'
+ 'openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
+ '<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.'
+ 'openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>'
+ "</Types>",
"_rels/.rels": _XML
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
@ -187,7 +210,12 @@ _XLSX_PARTS: dict[str, str | bytes] = {
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/worksheet" Target="worksheets/sheet1.xml"/>'
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/worksheet" Target="worksheets/sheet2.xml"/></Relationships>',
+ 'relationships/worksheet" Target="worksheets/sheet2.xml"/>'
# The shared string table is reached through the WORKBOOK's relationship,
# not by its path: without this line every `t="s"` cell converts empty and
# the fixture reports four cells lost that the build never lost.
+ '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
+ 'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
"xl/sharedStrings.xml": _XML
+ f'<sst {_S} count="{len(_XLSX_STRINGS)}" uniqueCount="{len(_XLSX_STRINGS)}">'
+ "".join(f"<si><t>{value}</t></si>" for value in _XLSX_STRINGS)
@ -237,7 +265,8 @@ _ODT_PARTS: dict[str, str | bytes] = {
+ '<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>'
+ "</manifest:manifest>",
"content.xml": _XML
+ f"<office:document-content {_ODT_NS}><office:body><office:text>"
+ f'<office:document-content {_ODT_NS} office:version="1.3">'
+ "<office:body><office:text>"
+ '<text:h text:outline-level="1">Drift av gangbruer</text:h>'
+ "<text:p>Gangbruer inspiseres hvert aar.</text:p>"
+ "<text:list><text:list-item><text:p>Rekkverk</text:p></text:list-item>"
@ -252,7 +281,8 @@ _ODT_PARTS: dict[str, str | bytes] = {
+ "</table:table-row></table:table>"
+ "</office:text></office:body></office:document-content>",
"styles.xml": _XML
+ f"<office:document-styles {_ODT_NS}><office:master-styles>"
+ f'<office:document-styles {_ODT_NS} office:version="1.3">'
+ "<office:styles/><office:master-styles>"
+ '<style:master-page style:name="Standard">'
+ "<style:header><text:p>Intern arbeidsversjon</text:p></style:header>"
+ "<style:footer><text:p>Vegdirektoratet</text:p></style:footer>"

View file

@ -0,0 +1,23 @@
{
"productInfo": {"meta": {"productTitle": "Label inside title"}},
"standardContent": {
"id": null, "tag": "#document", "a": {},
"c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "standard", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "body", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "sec", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "title", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "label", "a": {}, "c": [
{"e": 2, "t": "84.1", "x": null}
]}},
{"e": 2, "t": "Toleranser", "x": null}
]}},
{"e": 1, "t": null, "x": {"id": null, "tag": "p", "a": {}, "c": [
{"e": 2, "t": "Toleranseklasse er gitt i tabell 84-2.", "x": null}
]}}
]}}
]}}
]}}
]
}
}

View file

@ -614,10 +614,16 @@ def test_an_image_booked_carried_without_its_bytes_is_unverified(tmp_path: Path)
def test_a_negative_booking_is_never_clean(tmp_path: Path) -> None:
declared = _all_carried()
declared["documents"][0]["fates"]["heading"] = {"carried": 25, "rejected": {"x": -15}}
declared["documents"][0]["fates"]["heading"] = {
"carried": 25,
"rejected": {"extractor_unknown": -15},
}
unit = gate.account(_inventory(), _build(accounting=declared), _corpus(tmp_path))[0]
assert unit.invalid >= 1
assert not unit.clean
# 25 + (-15) = 10 booked against a source holding 2, so eight are booked
# twice. Absorbing the sign would read 40 and report thirty-eight.
assert unit.double == 8
def test_a_document_declared_persisted_that_is_not_in_the_bundle_is_never_clean(
@ -686,6 +692,29 @@ def test_an_asset_with_the_right_name_and_the_wrong_bytes_is_not_carried(tmp_pat
assert gate.account(_inventory(), build, corpus)[1].unaccounted == 1
def test_an_asset_under_a_reduced_name_still_proves_the_carry(tmp_path: Path) -> None:
"""The build lowercases and folds the source's basename and sniffs the
suffix from the bytes. Measured on R761, a judge checking the FULL name
called 50 of 50 carried images missing -- the judge's defect, not the
build's, so the content address is the check and the readable tail is not.
"""
corpus = _corpus(tmp_path)
source = corpus / "graphics" / "x.png"
digest = gate._sha256(source)
reduced = {f"{digest[:12]}-25-0143-tabeller-r761-r762.jpeg": digest}
build = _build(accounting=_declared(fate="carried"), assets=reduced)
assert gate.account(_inventory(), build, corpus)[1].clean
def test_an_asset_holding_the_bytes_under_a_foreign_address_is_not_a_carry(
tmp_path: Path,
) -> None:
corpus = _corpus(tmp_path)
source = corpus / "graphics" / "x.png"
build = _build(accounting=_declared(fate="carried"), assets={"x.png": gate._sha256(source)})
assert gate.account(_inventory(), build, corpus)[1].unaccounted == 1
def test_the_cheat_that_books_everything_carried_makes_row3_red(tmp_path: Path) -> None:
"""The review's `MODE=carried`: a report that changes not one byte of the
bundle and books every element as carried."""
@ -747,13 +776,146 @@ def test_row5_is_red_when_a_witness_is_missing() -> None:
def test_row6_without_its_source_is_red_locally_and_skipped_in_ci(tmp_path: Path) -> None:
missing = tmp_path / "absent"
local = gate.row6(missing, ci=False)
ci = gate.row6(missing, ci=True)
local = gate.row6(missing, None, ci=False)
ci = gate.row6(missing, None, ci=True)
assert (local.status, local.fails) == (gate.RED, True)
assert (ci.status, ci.fails) == (gate.SKIPPED, False)
assert "source missing" in ci.reason
def test_a_skipped_row_never_leaves_the_verdict_unqualified() -> None:
"""m-2: `CI=1` with a missing source printed `GATE GREEN` with nothing
beside it, so the one line most readers stop at said the corpus passed."""
skipped = gate.Row(6, "real corpora", 0, 0, gate.SKIPPED, "not measured, source missing: x")
rendered = gate.render([skipped])
assert "GATE GREEN (row 6 not run: not measured, source missing: x)" in rendered
def test_a_corpus_refused_whole_under_the_default_gate_is_red(tmp_path: Path) -> None:
"""Row 6 was GREEN with R761 100 % rejected: every element booked as a
coded rejection satisfies u = 0 and d = 0. The build order asked for an
honest red there, so the row says this on its own."""
inventory = {
"documents": {
"a.md": {
"suffix": ".md",
"elements": {"heading": 2},
"texts": {"heading": [["A"], ["B"]]},
"images": [],
}
},
"files": {},
}
declared = {
"accounting_version": 1,
"documents": [
{
"source_file": "a.md",
"status": "rejected",
"code": "fail_secure",
"inventory": {"heading": 2},
"fates": {"heading": {"rejected": {"fail_secure": 2}}},
}
],
"files": [],
}
build = _build(accounting=declared, sources=set(), exit_code=1)
units = gate.account(inventory, build, tmp_path)
assert all(u.clean for u in units), "every element has a coded fate"
assert gate.refused_whole(inventory["documents"], build) is not None
def test_the_two_real_corpora_are_named_and_the_second_is_not_r761() -> None:
"""R761 holds 0 `fig`, 0 formulas and 0 references, so the gate's only
real corpus could not see the hole in the STS role map."""
corpora = gate.real_corpora(Path("/r761"), Path("/n200.json"))
assert [c.label.split()[0] for c in corpora] == ["R761", "N200"]
def test_a_unit_clean_in_only_one_of_the_two_builds_is_not_clean() -> None:
"""M13: the two gates see different things, so either build could cover
for the other."""
clean = gate.Unit("a", "document", 0, 0)
dirty = gate.Unit("a", "document", 1, 0)
assert gate.clean_in_every_run([[clean], [clean]]) == 1
assert gate.clean_in_every_run([[clean], [dirty]]) == 0
def test_the_gate_exits_one_when_a_row_is_red(monkeypatch: pytest.MonkeyPatch) -> None:
"""M14: nothing checked the real command's exit code, so `main` could
return 0 over a red table and no test would notice."""
pytest.importorskip("pdfplumber")
pytest.importorskip("pypandoc")
monkeypatch.delenv("CI", raising=False)
code = gate.main(["--r761", "/no/such/corpus", "--n200", "/no/such/file.json"])
assert code == 1
def test_a_file_with_no_declaration_is_unaccounted_when_conservation_failed(
tmp_path: Path,
) -> None:
"""M17: without the door, a file that is not merged counts as a coded
rejection ONLY because the build's own conservation identity held. A run
that exited non-zero has not earned that inference."""
corpus = _corpus(tmp_path)
failed = _build(exit_code=1, log="K1b FAILED")
assert gate.account(_inventory(), failed, corpus)[1].unaccounted == 1
assert gate.account(_inventory(), _build(), corpus)[1].clean
def test_a_merged_file_declared_carried_without_its_bytes_is_still_a_false_claim(
tmp_path: Path,
) -> None:
"""M05: with `fates` summing to one anyway, the false claim is the only
thing that can see it."""
corpus = _corpus(tmp_path)
build = _build(accounting=_declared(fate="carried"), sources={"a.md", "graphics/x.png"})
unit = gate.account(_inventory(), build, corpus)[1]
assert (unit.unaccounted, unit.clean) == (1, False)
def test_a_rejection_code_inside_an_element_fate_must_also_be_known(tmp_path: Path) -> None:
"""M24: the document's own `code` was checked and the per-element ones
were not, so a report could name any reason it liked for an element."""
declared = _all_carried()
declared["documents"][0]["fates"]["heading"] = {"rejected": {"because_i_said_so": 2}}
unit = gate.account(_inventory(), _build(accounting=declared), _corpus(tmp_path))[0]
assert unit.invalid >= 1
assert "because_i_said_so" in "; ".join(unit.notes)
def test_the_json_twin_is_read_with_the_json_role_map(tmp_path: Path) -> None:
"""M-2, measured on R761 2026-09-18: the XML delivery places a section's
label at `sec/label` (7 714, and 0 inside a title); the JSON delivery puts
2 760 of them inside the title. Read with the XML map, the twin loses
every one of those."""
twin = gate.FIXTURES / "witness" / "sts-label-in-title.twin.json"
assert witness.count_sts_json(twin.read_bytes()).counts["section_label"] == 1
assert witness._sts_role_xml("label", "title", "sec") is None
assert witness._sts_role_json("label", "title", "sec") == "section_label"
def test_an_approved_exception_is_read_and_says_what_it_does() -> None:
"""m-3: `APPROVED_EXCEPTIONS` was read by no row, so approving one changed
nothing and the list could have said anything."""
for suffix, element in gate.APPROVED_EXCEPTIONS:
assert "no denominator moves" in gate.exception_effect(suffix, element)
assert "WARNING" in gate.exception_effect(".pdf", "page")
assert "names nothing" in gate.exception_effect(".doc", "heading")
rendered = gate.render([])
assert "no denominator moves" in rendered
def test_the_gate_states_its_own_limits() -> None:
rendered = gate.render([])
assert "what this gate cannot check" in rendered
assert len(gate.LIMITS) >= 5
def test_every_witnessed_type_has_a_vocabulary() -> None:
assert set(gate.FORMAT_VOCABULARY) == set(witness.WITNESSED_SUFFIXES)
def test_the_proposed_exceptions_are_not_applied() -> None:
assert "NOT APPROVED" in gate.render([])
assert not {suffix for suffix, _ in gate.APPROVED_EXCEPTIONS} & {
@ -789,7 +951,7 @@ def test_bad_usage_exits_two() -> None:
def real_rows() -> list[gate.Row]:
pytest.importorskip("pdfplumber")
pytest.importorskip("pypandoc")
return gate.evaluate(r761=None, ci=True, consume=False)
return gate.evaluate(r761=None, n200=None, ci=True, consume=False)
def _cheating_report(inventory: dict[str, Any], mode: str) -> dict[str, Any]:

View file

@ -77,7 +77,7 @@ def test_the_log_separates_carried_files_from_rejections(tmp_path: Path) -> None
code, bundle, _ = _build(CORPUS, tmp_path)
log = (bundle / "log.md").read_text(encoding="utf-8")
assert code == 0
assert "merged + files carried through a document + coded rejections = 13 + 2 + 0 = 15" in log
assert "merged + files carried through a document + coded rejections = 20 + 2 + 0 = 22" in log
assert "`extractor_unknown`" not in log
@ -123,12 +123,75 @@ def _witness(path: Path) -> dict[str, Any]:
return data
#: What the witness counts and this package does not, per file type. Added
#: 2026-09-18 when the gate's witnesses were widened: rows 2 and 3 of the gate
#: are RED on exactly these, and that is the finding rather than a regression.
#: Closing one here turns this test red, which is the point -- the list is the
#: standing statement of what a bundle built by this package leaves behind.
NOT_IN_THIS_PACKAGES_VOCABULARY = {
"comment",
"endnote",
"header_footer",
"text_box",
"note",
"hidden_slide",
"formula",
"hidden_sheet",
"annotation",
"citation",
"math",
"figure",
"figure_caption",
}
#: Shared element names on which the two now DISAGREE, with the cause of each.
#: Three are reclassifications -- the witness gives the element its own role
#: and this package still calls it the ordinary one -- and the fourth is a
#: double count this package makes. Measured 2026-09-18.
VOCABULARY_SHIFTS: dict[tuple[str, str], tuple[int, int, str]] = {
("notater-og-skjult.pptx", "slide"): (2, 1, "a hidden slide counts as an ordinary slide"),
("skjult-ark-og-formel.xlsx", "sheet"): (2, 1, "a hidden sheet counts as an ordinary sheet"),
("liste-og-bilde.odt", "paragraph"): (5, 4, "an annotation counts as prose"),
(
"topptekst-og-kommentar.docx",
"paragraph",
): (
5,
3,
"a text box's paragraph is counted TWICE: inside the box, and again in the "
"paragraph that carries the box",
),
}
@pytest.mark.parametrize("name", sorted(_witness(FIXTURES / "inventory.json")["documents"]))
def test_the_inventory_equals_the_witness(name: str) -> None:
def test_the_inventory_equals_the_witness_on_every_shared_element(name: str) -> None:
"""The coupling is unchanged where the two vocabularies meet: one number
off on a shared element is a red test unless it is in the table above,
with its cause written down."""
pytest.importorskip("pdfplumber")
want = _witness(FIXTURES / "inventory.json")["documents"][name]["elements"]
got = accounting.inventory(CORPUS, CORPUS / name)
assert got.counts() == want
got = accounting.inventory(CORPUS, CORPUS / name).counts()
shared = {k: v for k, v in want.items() if k not in NOT_IN_THIS_PACKAGES_VOCABULARY}
differences = {k: (got[k], v) for k, v in shared.items() if got.get(k) != v}
expected = {
element: (mine, theirs)
for (document, element), (mine, theirs, _) in VOCABULARY_SHIFTS.items()
if document == name
}
assert differences == expected
@pytest.mark.parametrize("name", sorted(_witness(FIXTURES / "inventory.json")["documents"]))
def test_the_classes_this_package_does_not_count_are_named(name: str) -> None:
"""A known-negative: every element the witness counts and this package
does not is on the list above, by name. Nothing is missing quietly."""
pytest.importorskip("pdfplumber")
want = _witness(FIXTURES / "inventory.json")["documents"][name]["elements"]
got = accounting.inventory(CORPUS, CORPUS / name).counts()
assert set(want) - set(got) <= NOT_IN_THIS_PACKAGES_VOCABULARY
assert not set(got) - set(want), "this package counts something no witness does"
def test_the_inventory_resolves_the_files_a_document_points_at() -> None:
@ -144,15 +207,43 @@ def test_the_inventory_of_an_unread_type_is_empty() -> None:
# --- the door ----------------------------------------------------------------
def test_the_door_books_every_element_of_the_fixture_corpus_once(tmp_path: Path) -> None:
#: The elements this package loses from the fixture corpus, in its OWN
#: vocabulary, measured 2026-09-18 on the widened corpus. Each is a real loss
#: with a named cause, and the door exits 1 because of them -- which is the
#: behaviour the flag was built for, exercised for the first time by fixtures
#: that actually carry the constructs.
KNOWN_LOSSES = {
("liste-og-bilde.odt", "image"): (0, 1),
("liste-og-bilde.odt", "paragraph"): (4, 5),
("skjult-ark-og-formel.xlsx", "image"): (0, 1),
("topptekst-og-kommentar.docx", "footnote"): (0, 1),
("topptekst-og-kommentar.docx", "paragraph"): (3, 5),
}
def test_the_door_books_every_element_of_the_fixture_corpus_but_the_known_losses(
tmp_path: Path,
) -> None:
pytest.importorskip("pdfplumber")
path = tmp_path / "accounting.json"
code, _, err = _build(CORPUS, tmp_path, "--accounting", str(path))
assert code == 0, err
data = json.loads(path.read_text(encoding="utf-8"))
assert data["accounting_version"] == 1
assert data["unaccounted"] == 0
assert data["double_booked"] == 0
assert data["unaccounted"] == 6, err
assert code == 1, "a run that loses content does not exit zero"
found = {}
for document in data["documents"]:
for kind, number in document["inventory"].items():
fate = document.get("fates", {}).get(kind, {})
booked = (
fate.get("carried", 0)
+ fate.get("pointer", 0)
+ sum(fate.get("rejected", {}).values())
)
if booked != number:
found[(document["source_file"], kind)] = (booked, number)
assert found == KNOWN_LOSSES
files = {entry["source_file"]: entry["fate"] for entry in data["files"]}
assert files == {"graphics/figur-84-1.png": "carried", "graphics/tabell-84-2.png": "carried"}
web = next(d for d in data["documents"] if d["source_file"] == "prosess-84-web.html")
@ -163,8 +254,8 @@ def test_the_door_writes_the_accounting_into_the_log(tmp_path: Path) -> None:
pytest.importorskip("pdfplumber")
_, bundle, _ = _build(CORPUS, tmp_path, "--accounting", str(tmp_path / "a.json"))
log = (bundle / "log.md").read_text(encoding="utf-8")
assert "* **Accounting**: 13 document(s) and 2 other file(s);" in log
assert "0 unaccounted, 0 double-booked." in log
assert "* **Accounting**: 20 document(s) and 2 other file(s);" in log
assert "6 unaccounted, 0 double-booked." in log
def test_a_rejected_document_is_logged_with_what_its_source_held(tmp_path: Path) -> None: