354 lines
15 KiB
Python
354 lines
15 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 portfolio_optimiser import okf
|
||
from portfolio_optimiser.ingest import IngestError, materialize, read_csv, render_table
|
||
from portfolio_optimiser.retrieval import PathSecurityError
|
||
|
||
_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")
|
||
with pytest.raises(PathSecurityError):
|
||
_read(root, query="../outside.csv")
|
||
|
||
|
||
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"] == "true" # parse_frontmatter returns 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_raises_value_error(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).
|
||
for bad in ("2026-07-03 12:00:00", "2026-07-03T12:00:00+00:00", "2026-07-03", ""):
|
||
with pytest.raises(ValueError):
|
||
materialize(manifest_path, bundle_dir, ingested_at=bad)
|
||
|
||
|
||
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)
|
||
with caplog.at_level(logging.INFO, logger="portfolio_optimiser.ingest"):
|
||
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
|
||
|
||
|
||
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_sql_source_has_no_connector_in_i2(tmp_path: Path) -> None:
|
||
# The polymorphic schema VALIDATES sql (brief assumption 1); executing it is I4 —
|
||
# attempting to materialize is an explicit refusal, never a silent no-op.
|
||
manifest_path, bundle_dir = _project(
|
||
tmp_path, source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"}
|
||
)
|
||
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
|