P13 measured this lift and REFUSED it, because okf >=0.8.5 emits the ownership
stamp as the V1 flow mapping `generated: { by: process:okf-ingest, at: ... }`
where 0.3.2 emitted `true`, and `_carries_complete_ingest_stamp` read the new
form as NOT a stamp -- write_concept_file's forgery refusal would have shipped
DISARMED with the whole fail-closed suite green. That blocker is closed first,
red-first, and then the pin moves.
ROW 1, THE SECURITY HALF. `_claims_ingest_ownership` widens the predicate from
"reads as boolean True" to "claims ingest ownership", of which the boolean is
the pre-V1 spelling. The recogniser for the new half is `decode_flow_value` --
the module's ONE flow decoder, the same argument write_concept_file already
makes for `verified`: the writer refuses exactly what the reader can read. A
value the decoder REFUSES is therefore not an ownership claim and writes
through, which is what keeps this from collapsing into "any non-empty
generated". Two arms red before the fix; no YAML library introduced.
THE PIN. okf v0.3.2 -> v0.8.5, guard v0.3.4 -> v1.4.0 spelled `tag =`, not
`rev =`, and not the declared floor 1.2.0 -- both P13 premises hold and the
reason now lives next to the pin in pyproject.toml. The ":40" comment is
corrected: okf has ONE runtime dependency, the guard, and that is what binds
the two lines together. 27/27 imported names resolve across five modules.
THE GOLDENS, REGENERATED AS A DECISION. Seven concept files across four
examples/ingest-golden-* bundles, one line each. Two were regenerated by the
REAL materializer; the other five are derived (http/sql/mcp cannot materialize
outside the tests' stubs) and then MEASURED -- all four golden suites compare
byte for byte against what the stubs produce, and all four are green. The four
`generated == "true"` asserts now read ONE source, conftest.
expected_generated_stamp: four literals for one emitter fact are four places a
later release can leave half-corrected, which is exactly how the pre-V1 form
survived until P13 measured it. tests/test_okf.py keeps its literal on purpose
-- that one round-trips a CURATED half-stamp through our own writer.
THE BLOCK READER. Measured with the full denominator: all four delivered
knowledge bases write `sources` as a BLOCK sequence and none in flow form
(n100 446/446, n200 1133/1133, n500 270/270, r761 2756/2756 = 4605/4605), and
`evidence_for` reported `unreadable` on 4605 of 4605 -- the falsification layer
had no address for any document in any base. `okf.decode_block_mappings` is the
second CARRIER of one grammar, never a second grammar: colon-SPACE separator,
unquote_scalar, duplicate keys refused, SPEC 5.2's actor rule applied. okf's
consume.read_sources was READ for the form and not called; po calls no okf
reader, which is measured and deliberate. After: 4605 present / 4605 entries.
Reading is not a licence to WRITE -- the emitter is untouched and both writers
still refuse what decode_flow_value refuses.
THREE FINDINGS. (1) The first block reader INVENTED data on `- { k: v }` items
-- SPEC-canonical, and the shape tests/golden/block-form-provenance writes for
`verified` -- decoding it as `{'{ id': '...'}`. No arm caught it: the 5.2 actor
rule shielded the fixture by accident. Closed with a flow-decoder branch and
four new arms. (2) One of my own arms was VACUOUS, found by my own mutation M5:
it claimed to prove the colon-SPACE rule and stayed green under first-colon,
because the two rules agree on every delivered value. Renamed, labelled, and
the claim moved to the arm that actually witnesses it. (3) OPEN, and it needs
the operator: the commons-owned worked example declares its second concept
`unreadable`/`block-sequence`, which is now false for po. `shared/` is
pull-only, so closing it needs a commons amendment; the test asserts the
divergence instead of skipping it, keeping the discriminating half (the example
says two entries were seen and the reader returns exactly two).
NINE EXISTING ARMS REWRITTEN, NONE WEAKENED. All nine pinned "the block form is
unreadable" -- the behaviour this order changes. Each keeps its claim on a
specimen that is still unreadable for a reason of its own (5.2: an entry naming
no actor), or pins the REVERSED direction where the old arm stood so the change
cannot be silent. Two got STRONGER: multi-verified.md was authored for "a reader
keeping the last entry reports machine-confirmed for a concept a human signed",
and that could not be tested while the form was unreadable. Three node ids were
renamed; nothing was removed in substance.
Suite 1582 -> 1606 passed / 5 skipped. Both demo goldens byte-unchanged
(ea8c534... / ede3e2f..., shasum -a 1 of the CONTENT, never the git blob id).
ruff check / ruff format / mypy green. shared/ untouched.
Six mutations, all red against the WHOLE suite, each with its own signature:
row 1 detached (2) / block reader detached (17) / flow-item branch detached (7)
/ a stray indented line folds into an INVENTED entry (4) / separator becomes the
first colon (1 -- and that is finding 2) / the stamp expectation reverts to
"true" (4).
Order: 20260912T195112Z-995611104-from-.claude
Record: docs/2026-09-12-p13b-okf-bump.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
16 KiB
Python
382 lines
16 KiB
Python
"""I2 Steps 2–4 tests — CSV connector, deterministic materialization, index semantics.
|
||
|
||
Covers the §4 connector rules (fail-closed ``root`` boundary via ``safe_resolve``, streaming
|
||
``max_rows`` cap — error, never silent truncation §8), the §5 rendering rules (text-verbatim
|
||
cells, escape order ``\\`` BEFORE ``|``, newlines → single space with CRLF as ONE unit,
|
||
header cells escaped identically to data cells, LF-only + exactly one trailing newline), the
|
||
§5/§7 provenance frontmatter (exact key order, verbatim ``ingested_at``, ``{stem}@{hash16}``
|
||
stamp), and the §3/§6 bundle semantics (bundle_summary index, managed-link removal + in-place
|
||
label refresh, curated-collision gate, §10 idempotence). Golden regression lives separately
|
||
in tests/test_ingest_golden.py; the detach-RED seam quartet in tests/test_ingest_loadbearing.py.
|
||
"""
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from llm_ingestion_okf import MaterializationError, SourceError
|
||
|
||
from conftest import expected_generated_stamp
|
||
from portfolio_optimiser import okf
|
||
from portfolio_optimiser.ingest import IngestError, materialize, read_csv, render_table
|
||
|
||
_INGESTED_AT = "2026-07-03T12:00:00Z"
|
||
|
||
# --- local CSV-catalogue builder (conftest's LLM fixtures are irrelevant to ingest) ---
|
||
|
||
|
||
def _catalogue(tmp_path: Path, files: dict[str, bytes]) -> Path:
|
||
root = tmp_path / "catalogue"
|
||
root.mkdir()
|
||
for name, content in files.items():
|
||
(root / name).write_bytes(content)
|
||
return root
|
||
|
||
|
||
def _read(root: Path, query: str = "data.csv", max_rows: int = 100) -> Any:
|
||
return read_csv(root, query, max_rows=max_rows)
|
||
|
||
|
||
# --- Step 2: connector + renderer ---
|
||
|
||
|
||
def test_table_rendering_header_separator_rows_in_source_order(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"a,b\n3,4\n1,2\n"})
|
||
header, rows = _read(root)
|
||
assert render_table(header, rows) == "| a | b |\n| --- | --- |\n| 3 | 4 |\n| 1 | 2 |\n"
|
||
|
||
|
||
def test_cell_escaping_backslash(tmp_path: Path) -> None:
|
||
assert render_table(["h"], [["a\\b"]]).splitlines()[2] == "| a\\\\b |"
|
||
|
||
|
||
def test_cell_escaping_pipe(tmp_path: Path) -> None:
|
||
assert render_table(["h"], [["a|b"]]).splitlines()[2] == "| a\\|b |"
|
||
|
||
|
||
def test_cell_escaping_embedded_newline_to_single_space(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b'h\n"x\ny"\n'})
|
||
header, rows = _read(root)
|
||
assert rows == [["x\ny"]] # csv preserves the quoted newline...
|
||
assert render_table(header, rows).splitlines()[2] == "| x y |" # ...rendering collapses it
|
||
|
||
|
||
def test_escape_order_backslash_before_pipe(tmp_path: Path) -> None:
|
||
# Input cell `\|`: escaping `\`→`\\` FIRST then `|`→`\|` yields `\\\|`; the reverse
|
||
# order would double-escape the introduced backslash.
|
||
assert render_table(["h"], [["\\|"]]).splitlines()[2] == "| \\\\\\| |"
|
||
|
||
|
||
def test_crlf_embedded_newline_is_one_space(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b'h\n"x\r\ny"\n'})
|
||
header, rows = _read(root)
|
||
assert render_table(header, rows).splitlines()[2] == "| x y |" # ONE space, not two
|
||
|
||
|
||
def test_header_cells_escaped_like_data_cells(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b'"h|1","h\n2"\nv1,v2\n'})
|
||
header, rows = _read(root)
|
||
assert render_table(header, rows).splitlines()[0] == "| h\\|1 | h 2 |"
|
||
|
||
|
||
def test_header_only_csv_renders_no_data_rows(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"a,b\n"})
|
||
header, rows = _read(root)
|
||
assert render_table(header, rows) == "| a | b |\n| --- | --- |\n"
|
||
|
||
|
||
def test_empty_csv_raises(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b""})
|
||
with pytest.raises(IngestError):
|
||
_read(root)
|
||
|
||
|
||
def test_ragged_row_raises(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"a,b\n1\n"})
|
||
with pytest.raises(IngestError):
|
||
_read(root)
|
||
|
||
|
||
def test_max_rows_cap_is_an_error_not_truncation(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"a\n1\n2\n3\n"})
|
||
with pytest.raises(IngestError):
|
||
_read(root, max_rows=2)
|
||
|
||
|
||
def test_path_escape_raises(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"a\n"})
|
||
(tmp_path / "outside.csv").write_bytes(b"a\n1\n")
|
||
# Fail-closed containment is unchanged; the library raises its own typed refusal
|
||
# (SourceError, code="path_escape") where the repo-local seam raised PathSecurityError.
|
||
with pytest.raises(SourceError) as exc:
|
||
_read(root, query="../outside.csv")
|
||
assert exc.value.code == "path_escape"
|
||
|
||
|
||
def test_missing_root_raises_ingest_error(tmp_path: Path) -> None:
|
||
with pytest.raises(IngestError):
|
||
read_csv(tmp_path / "no-such-root", "data.csv", max_rows=10)
|
||
|
||
|
||
def test_missing_query_file_raises_ingest_error(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"a\n"})
|
||
with pytest.raises(IngestError):
|
||
_read(root, query="absent.csv")
|
||
|
||
|
||
def test_bom_never_leaks_into_first_header_cell(tmp_path: Path) -> None:
|
||
root = _catalogue(tmp_path, {"data.csv": b"\xef\xbb\xbfa,b\n1,2\n"})
|
||
header, _rows = _read(root)
|
||
assert header == ["a", "b"]
|
||
|
||
|
||
# --- Step 3: materialization — provenance frontmatter, LF bytes, staging ---
|
||
|
||
|
||
def _project(
|
||
tmp_path: Path,
|
||
*,
|
||
extractions: list[dict[str, Any]] | None = None,
|
||
source: dict[str, Any] | None = None,
|
||
files: dict[str, bytes] | None = None,
|
||
) -> tuple[Path, Path]:
|
||
"""Write a manifest + CSV catalogue under tmp_path; return (manifest_path, bundle_dir)."""
|
||
catalogue = tmp_path / "catalogue"
|
||
catalogue.mkdir(exist_ok=True)
|
||
for name, content in (files or {"costs.csv": b"item,cost\nled,120\nhvac,80\n"}).items():
|
||
(catalogue / name).write_bytes(content)
|
||
manifest = {
|
||
"manifest_version": 1,
|
||
# Relative root — pinned decision: resolves against the manifest file's directory.
|
||
"source": source or {"type": "file", "id": "prosjekt-arkiv", "root": "catalogue"},
|
||
"bundle_summary": "Cost extracts from the project archive.",
|
||
"extractions": extractions
|
||
or [
|
||
{
|
||
"id": "costs",
|
||
"title": "Project costs",
|
||
"query": "costs.csv",
|
||
"okf_type": "dataset",
|
||
"max_rows": 100,
|
||
}
|
||
],
|
||
}
|
||
manifest_path = tmp_path / "manifest.json"
|
||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||
return manifest_path, tmp_path / "bundle"
|
||
|
||
|
||
def test_frontmatter_keys_in_exact_spec_order_raw_text(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
(written,) = materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
lines = written.read_text(encoding="utf-8").splitlines()
|
||
keys = [line.partition(":")[0] for line in lines[1:8]]
|
||
# §5: exactly these keys, in exactly this order.
|
||
assert lines[0] == "---" and lines[8] == "---"
|
||
assert keys == [
|
||
"type",
|
||
"title",
|
||
"source_system",
|
||
"source_query",
|
||
"ingested_at",
|
||
"ingest_manifest",
|
||
"generated",
|
||
]
|
||
|
||
|
||
def test_provenance_roundtrips_via_unchanged_parse_frontmatter(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
(written,) = materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
fm = okf.parse_frontmatter(written)
|
||
assert fm["type"] == "dataset"
|
||
assert fm["title"] == "Project costs"
|
||
assert fm["source_system"] == "prosjekt-arkiv"
|
||
assert fm["source_query"] == "costs.csv"
|
||
assert fm["ingested_at"] == _INGESTED_AT # verbatim (§5)
|
||
assert fm["generated"] == expected_generated_stamp(_INGESTED_AT) # strings, never booleans
|
||
expected = "manifest@" + hashlib.sha256(manifest_path.read_bytes()).hexdigest()[:16]
|
||
assert fm["ingest_manifest"] == expected
|
||
|
||
|
||
def test_generated_file_is_lf_only_with_one_trailing_newline(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
(written,) = materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
data = written.read_bytes()
|
||
assert b"\r" not in data
|
||
assert data.endswith(b"\n") and not data.endswith(b"\n\n")
|
||
|
||
|
||
def test_invalid_ingested_at_is_refused(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
# §5 format is ISO-8601 UTC with a Z suffix — validated by regex (datetime.fromisoformat
|
||
# rejects 'Z' on Python 3.10, the repo floor). The refusal is unchanged; its type moved
|
||
# from ValueError to the library's MaterializationError.
|
||
for bad in ("2026-07-03 12:00:00", "2026-07-03T12:00:00+00:00", "2026-07-03", ""):
|
||
with pytest.raises(MaterializationError) as exc:
|
||
materialize(manifest_path, bundle_dir, ingested_at=bad)
|
||
assert exc.value.code == "ingested_at_invalid"
|
||
# The refusal is fail-fast: nothing was written before the format was checked.
|
||
assert not bundle_dir.exists()
|
||
|
||
|
||
def test_materialize_creates_nonexistent_nested_bundle_dir(tmp_path: Path) -> None:
|
||
manifest_path, _ = _project(tmp_path)
|
||
nested = tmp_path / "deep" / "nested" / "bundle"
|
||
(written,) = materialize(manifest_path, nested, ingested_at=_INGESTED_AT)
|
||
assert written.is_file() and written.parent == nested
|
||
|
||
|
||
def test_source_call_logged_with_id_timestamp_rowcount(
|
||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
# The §8 audit log is still emitted per source call, but the CHANNEL moved with the
|
||
# implementation: "portfolio_optimiser.ingest" -> "llm_ingestion_okf.materialize"
|
||
# (accepted 2026-07-20; nothing in the repo consumed the old logger name).
|
||
with caplog.at_level(logging.INFO, logger="llm_ingestion_okf.materialize"):
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
# §8: which source, when (the deterministic ingested_at argument), row count.
|
||
joined = " ".join(record.getMessage() for record in caplog.records)
|
||
assert "prosjekt-arkiv" in joined and _INGESTED_AT in joined and "rows=2" in joined
|
||
# §8 also bounds what may be logged: never cell contents.
|
||
assert "led-retrofit" not in joined
|
||
|
||
|
||
def test_two_runs_with_identical_inputs_are_byte_identical(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
first = {p.name: p.read_bytes() for p in sorted(bundle_dir.iterdir())}
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
second = {p.name: p.read_bytes() for p in sorted(bundle_dir.iterdir())}
|
||
assert first == second # §10 idempotence at file level
|
||
|
||
|
||
def test_http_source_is_refused_without_network_flag(tmp_path: Path) -> None:
|
||
# The polymorphic schema VALIDATES http (brief assumption 1). Since I6 the http connector
|
||
# EXISTS, but materialize refuses it fail-fast unless the per-run allow_network flag is set —
|
||
# the manifest cannot grant itself network access (§8). Default (no flag) → explicit refusal,
|
||
# never a silent no-op. (The allow-branch is covered in tests/test_ingest_http_loadbearing.py.)
|
||
manifest_path, bundle_dir = _project(
|
||
tmp_path, source={"type": "http", "id": "api", "base_url": "https://host/api"}
|
||
)
|
||
with pytest.raises(IngestError):
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
|
||
|
||
# --- Step 4: index generation + stamped-replacement semantics ---
|
||
|
||
_TWO_EXTRACTIONS = [
|
||
{
|
||
"id": "costs",
|
||
"title": "Project costs",
|
||
"query": "costs.csv",
|
||
"okf_type": "dataset",
|
||
"max_rows": 100,
|
||
},
|
||
{
|
||
"id": "meta",
|
||
"title": "Catalogue metadata",
|
||
"query": "meta.csv",
|
||
"okf_type": "reference",
|
||
"max_rows": 10,
|
||
},
|
||
]
|
||
_TWO_FILES = {
|
||
"costs.csv": b"item,cost\nled,120\n",
|
||
"meta.csv": b"key,value\nowner,anlegg\n",
|
||
}
|
||
|
||
|
||
def _bundle_bytes(bundle_dir: Path) -> dict[str, bytes]:
|
||
return {p.name: p.read_bytes() for p in sorted(bundle_dir.iterdir()) if p.is_file()}
|
||
|
||
|
||
def test_fresh_dir_gets_index_with_bundle_summary_and_links_in_manifest_order(
|
||
tmp_path: Path,
|
||
) -> None:
|
||
manifest_path, bundle_dir = _project(
|
||
tmp_path, extractions=copy.deepcopy(_TWO_EXTRACTIONS), files=dict(_TWO_FILES)
|
||
)
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
index = (bundle_dir / "index.md").read_text(encoding="utf-8")
|
||
assert index.startswith("Cost extracts from the project archive.\n")
|
||
link_lines = [line for line in index.splitlines() if line.startswith("- [")]
|
||
assert link_lines == [
|
||
"- [Project costs](ingest-costs.md)",
|
||
"- [Catalogue metadata](ingest-meta.md)",
|
||
]
|
||
|
||
|
||
def test_curated_index_line_preserved_byte_for_byte(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
bundle_dir.mkdir()
|
||
curated = "Hand-written summary.\n\n- [Curated note](note.md)\n"
|
||
(bundle_dir / "index.md").write_bytes(curated.encode("utf-8"))
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
index = (bundle_dir / "index.md").read_text(encoding="utf-8")
|
||
assert index.startswith(curated) # §6: every unmanaged line preserved verbatim
|
||
assert "- [Project costs](ingest-costs.md)" in index
|
||
|
||
|
||
def test_removed_extraction_drops_file_and_index_link(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(
|
||
tmp_path, extractions=copy.deepcopy(_TWO_EXTRACTIONS), files=dict(_TWO_FILES)
|
||
)
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
# Re-materialize with `meta` dropped from the manifest.
|
||
manifest_path2, _ = _project(tmp_path, files=dict(_TWO_FILES))
|
||
materialize(manifest_path2, bundle_dir, ingested_at=_INGESTED_AT)
|
||
assert not (bundle_dir / "ingest-meta.md").exists()
|
||
index = (bundle_dir / "index.md").read_text(encoding="utf-8")
|
||
assert "](ingest-meta.md)" not in index # §6: link to removed ingest file is gone
|
||
assert "- [Project costs](ingest-costs.md)" in index # sibling link intact
|
||
|
||
|
||
def test_changed_title_refreshes_label_in_place(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(
|
||
tmp_path, extractions=copy.deepcopy(_TWO_EXTRACTIONS), files=dict(_TWO_FILES)
|
||
)
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
renamed = copy.deepcopy(_TWO_EXTRACTIONS)
|
||
renamed[0]["title"] = "Renamed costs"
|
||
manifest_path2, _ = _project(tmp_path, extractions=renamed, files=dict(_TWO_FILES))
|
||
materialize(manifest_path2, bundle_dir, ingested_at=_INGESTED_AT)
|
||
link_lines = [
|
||
line
|
||
for line in (bundle_dir / "index.md").read_text(encoding="utf-8").splitlines()
|
||
if line.startswith("- [")
|
||
]
|
||
# §6: the label IS the title — refreshed in place, position preserved (no stale label,
|
||
# no move-to-end).
|
||
assert link_lines == [
|
||
"- [Renamed costs](ingest-costs.md)",
|
||
"- [Catalogue metadata](ingest-meta.md)",
|
||
]
|
||
|
||
|
||
def test_unstamped_collision_fails_and_leaves_bundle_unmodified(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(tmp_path)
|
||
bundle_dir.mkdir()
|
||
(bundle_dir / "index.md").write_bytes(b"Curated.\n")
|
||
# A curated file occupying a generated filename WITHOUT the ingest stamp (§3): never
|
||
# overwrite curated content.
|
||
(bundle_dir / "ingest-costs.md").write_bytes(b"---\ntype: note\n---\n\nCurated body.\n")
|
||
before = _bundle_bytes(bundle_dir)
|
||
with pytest.raises(IngestError):
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
assert _bundle_bytes(bundle_dir) == before
|
||
|
||
|
||
def test_rematerialization_twice_is_byte_identical_including_index(tmp_path: Path) -> None:
|
||
manifest_path, bundle_dir = _project(
|
||
tmp_path, extractions=copy.deepcopy(_TWO_EXTRACTIONS), files=dict(_TWO_FILES)
|
||
)
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
first = _bundle_bytes(bundle_dir)
|
||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||
assert _bundle_bytes(bundle_dir) == first # §10, index.md included
|