test(identity): a document that declares a doc-number names its own directory
Red first (K3-19 a). A NISO-STS delivery built with `okf build` put every concept under a directory named for the delivery path's file name -- a UUID that occurs 0 times in the document -- and every `sources` entry named that file twice, while the document carries exactly one <std-ident> with a <doc-number> and one <title-wrap>, neither of which this package read. 11 of the 14 tests are red: `extract.declared_identity` does not exist, and the build still names the directory and the address title from the file. The 3 that pass today pin the fallback layer that must survive the change: no declaration keeps the file name, a declared title carrying a flow terminator falls to the file name, and a name another document's file already holds is not taken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
56b00e7f2f
commit
be169eeca0
3 changed files with 273 additions and 0 deletions
1
tests/fixtures/README.md
vendored
1
tests/fixtures/README.md
vendored
|
|
@ -33,6 +33,7 @@ round-trip-symmetric defect.
|
|||
|---|---|
|
||||
| `sts-mini.xml` | The known-positive. A `<standard>` root with `<sec>` at three nesting levels carrying `<label>`+`<title>`, two lettered points (`a)`, `b)`) with a **label and no title**, one `<table-wrap>` with a label and two rows, `<p>` bodies, a `<list>`, and **one unnumbered section** (`Forord`, `<title>` with no `<label>`) mirroring the single such section in R761. The lettered points are what the 4 954 label-only `<sec>` in that document look like: promoted to headings they would bury its own 2 761. |
|
||||
| `sts-empty-label.xml` | A `<sec>` carrying a `<label>` and **nothing else**, between a lettered point that has a body and the next titled section. The label is held as a prefix for a body line that never arrives, so it was overwritten and lost: measured on R761 that is exactly one `x)`, two characters of 1 283 395, ratio 0.999998. An exact invariant does not get to be 0.999998. |
|
||||
| `sts-identity.xml` | A document that **states who it is**: exactly one `<std-ident>` with a `<doc-number>` and a `<year>`, one `<title-wrap>` whose `<full>` carries a **comma** (as R761's does, which is why that title cannot be written into a `sources` flow mapping verbatim), and a `<std-ref type="dated">`. Its body carries the `sec-type="spec"` shape the `description` rule reads: a titled `<sec>` whose first spec point has one `<p>`, a second spec point that must never become the description, a titled child with no spec point of its own, and a spec point with **two** `<p>` of which only the first counts. `sts-mini.xml` is the half identity (a `<title-wrap>`, no `<doc-number>`) and `sts-empty-label.xml` the absent one. |
|
||||
| `generic-feed.xml` | Known-negative: XML that is **not** STS. It must produce text and ONE plan — never zero, never a crash, and never element names promoted to headings. |
|
||||
| `xml-doctype-bomb.xml` | Known-negative, security: a `<!DOCTYPE` with a small nested-entity expansion. It must be refused by `code`, and the test asserts the expansion appears in **no** output, including the error text. Small on purpose — the point is that it is never parsed, not that it detonates. |
|
||||
| `xml-malformed.xml` | Known-negative: an unterminated tag must raise a typed `ExtractionError`, not leak `ParseError` and not yield zero concepts in silence. |
|
||||
|
|
|
|||
41
tests/fixtures/sts-identity.xml
vendored
Normal file
41
tests/fixtures/sts-identity.xml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<standard>
|
||||
<front>
|
||||
<std-doc-meta>
|
||||
<title-wrap xml:lang="nb"><intro></intro><main></main><compl></compl><full>R900 Testnormalen Standard for fiksturer, tester og kontroll</full></title-wrap>
|
||||
<std-ident><originator>TST</originator><doc-type>Innledning</doc-type><doc-number>R900 Testnormalen</doc-number><isbn>000-00-0000-000-0</isbn><year>2024</year></std-ident>
|
||||
<std-ref type="dated">R900 Testnormalen:2024</std-ref>
|
||||
</std-doc-meta>
|
||||
</front>
|
||||
<body>
|
||||
<sec id="s-88" sec-type="procedure">
|
||||
<label>88</label>
|
||||
<title>Rekkverk</title>
|
||||
<sec id="s-88-612" sec-type="procedure">
|
||||
<label>88.612</label>
|
||||
<title>Utskifting av enkeltkomponenter</title>
|
||||
<sec id="s-88-612-a" sec-type="spec">
|
||||
<label>a)</label>
|
||||
<p>Omfatter utskifting av skadde enkeltkomponenter i rekkverk.</p>
|
||||
</sec>
|
||||
<sec id="s-88-612-b" sec-type="spec">
|
||||
<label>b)</label>
|
||||
<p>Et andre punkt blir aldri beskrivelsen.</p>
|
||||
</sec>
|
||||
<sec id="s-88-6121" sec-type="procedure">
|
||||
<label>88.6121</label>
|
||||
<title><italic>Utskifting av handlist</italic></title>
|
||||
</sec>
|
||||
</sec>
|
||||
<sec id="s-88-613" sec-type="procedure">
|
||||
<label>88.613</label>
|
||||
<title>Maling av rekkverk</title>
|
||||
<sec id="s-88-613-a" sec-type="spec">
|
||||
<label>a)</label>
|
||||
<p>Omfatter maling av rekkverk.</p>
|
||||
<p>Et andre avsnitt i samme punkt blir ikke med.</p>
|
||||
</sec>
|
||||
</sec>
|
||||
</sec>
|
||||
</body>
|
||||
</standard>
|
||||
231
tests/test_document_identity.py
Normal file
231
tests/test_document_identity.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""A document that states who it is names its own directory (K3-19 a).
|
||||
|
||||
MEASURED OUTSIDE THIS REPOSITORY: a NISO-STS delivery built with `okf build`
|
||||
put all 2 761 concepts under a directory named for the delivery path's file
|
||||
name, a UUID that occurs **0 times** in the document itself. The document
|
||||
carries exactly one `<std-ident>` whose `<doc-number>` says what it is, and one
|
||||
`<title-wrap>` -- and nothing in this package read either. Every concept's
|
||||
`sources` entry then named the file name twice, as the address and as the
|
||||
title.
|
||||
|
||||
THE PRECEDENCE, and each layer is tested here: an explicit per-run value beats
|
||||
what the document declares, and what the document declares beats the file
|
||||
name. The explicit layer is `--frontmatter`, tested in
|
||||
`tests/test_run_frontmatter.py`; this file holds the two layers below it.
|
||||
|
||||
WHAT THESE TESTS PIN:
|
||||
|
||||
- the directory comes from `<doc-number>` through the id grammar, and ONLY the
|
||||
file's own stem is replaced -- the folders above it are the operator's;
|
||||
- `sources[0].title` is `<doc-number>` + `<year>` when the document states
|
||||
them, `<title-wrap>` when it states only a title, the file name otherwise;
|
||||
- a declared value that would end the `sources` flow mapping early falls to
|
||||
the next layer instead of being written or mangled -- the measured document's
|
||||
own `<full>` title carries a comma;
|
||||
- two documents in one run that declare the SAME name both keep their file
|
||||
name. The collision gate that already exists refuses both and tells the
|
||||
operator to rename one, and a name read from the document is not one a
|
||||
rename can change;
|
||||
- more than one `<doc-number>` is no doc number: picking one would be a guess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf import cli, extract
|
||||
from llm_ingestion_okf.materialize import parse_frontmatter
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
IDENTITY = FIXTURES / "sts-identity.xml"
|
||||
TITLE_ONLY = FIXTURES / "sts-mini.xml"
|
||||
NEITHER = FIXTURES / "sts-empty-label.xml"
|
||||
GENERIC = FIXTURES / "generic-feed.xml"
|
||||
|
||||
# A delivery path's name. Like the one measured, it says nothing about the
|
||||
# document inside it.
|
||||
OPAQUE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.xml"
|
||||
|
||||
FULL_TITLE = "R900 Testnormalen Standard for fiksturer, tester og kontroll"
|
||||
|
||||
|
||||
def _build(tmp_path: Path, files: dict[str, bytes]) -> Path:
|
||||
folder = tmp_path / "docs"
|
||||
for name, data in files.items():
|
||||
(folder / name).parent.mkdir(parents=True, exist_ok=True)
|
||||
(folder / name).write_bytes(data)
|
||||
bundle = tmp_path / "bundle"
|
||||
report = cli.build(folder, bundle, bundle_id="identity", okf_version="0.2")
|
||||
assert report.codes == ()
|
||||
assert report.unaccounted == ()
|
||||
return bundle
|
||||
|
||||
|
||||
def _concepts(bundle: Path) -> dict[str, dict[str, str]]:
|
||||
return {
|
||||
path.relative_to(bundle).as_posix(): parse_frontmatter(path)
|
||||
for path in sorted(bundle.rglob("*.md"))
|
||||
if path.name not in ("index.md", "log.md")
|
||||
}
|
||||
|
||||
|
||||
def _sources(resource: str, title: str) -> str:
|
||||
return f"[{{ resource: {resource}, title: {title} }}]"
|
||||
|
||||
|
||||
# --- what the document declares ---------------------------------------------
|
||||
|
||||
|
||||
def test_the_identity_is_read_from_the_documents_own_elements() -> None:
|
||||
identity = extract.declared_identity(IDENTITY.name, IDENTITY.read_bytes())
|
||||
|
||||
assert identity == extract.DeclaredIdentity(
|
||||
doc_number="R900 Testnormalen", year="2024", title=FULL_TITLE
|
||||
)
|
||||
|
||||
|
||||
def test_a_title_wrap_without_a_doc_number_is_half_an_identity() -> None:
|
||||
identity = extract.declared_identity(TITLE_ONLY.name, TITLE_ONLY.read_bytes())
|
||||
|
||||
assert identity == extract.DeclaredIdentity(
|
||||
doc_number=None, year=None, title="Testnormal for fiksturbruk"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", [NEITHER, GENERIC], ids=["sts-no-front", "not-sts"])
|
||||
def test_a_document_declaring_neither_has_no_identity(fixture: Path) -> None:
|
||||
assert extract.declared_identity(fixture.name, fixture.read_bytes()) is None
|
||||
|
||||
|
||||
def test_only_the_row_that_can_read_a_declaration_is_asked() -> None:
|
||||
"""A markdown file whose text LOOKS like an identity declares nothing."""
|
||||
data = b"<std-ident><doc-number>R900 Testnormalen</doc-number></std-ident>\n"
|
||||
assert extract.declared_identity("notat.md", data) is None
|
||||
|
||||
|
||||
def test_two_doc_numbers_are_no_doc_number() -> None:
|
||||
"""An adopted standard carries one `<std-ident>` per body that issued it.
|
||||
|
||||
Choosing one of them would be a guess, so neither is used and the file
|
||||
name stays the directory.
|
||||
"""
|
||||
data = IDENTITY.read_bytes().replace(
|
||||
b"</std-doc-meta>",
|
||||
b"<std-ident><doc-number>NS 9000</doc-number><year>2020</year></std-ident>"
|
||||
b"</std-doc-meta>",
|
||||
)
|
||||
identity = extract.declared_identity(IDENTITY.name, data)
|
||||
|
||||
assert identity is not None
|
||||
assert identity.doc_number is None
|
||||
assert identity.year is None
|
||||
assert identity.title == FULL_TITLE
|
||||
|
||||
|
||||
# --- the directory and the address, through the whole build -----------------
|
||||
|
||||
|
||||
def test_the_doc_number_names_the_document_directory(tmp_path: Path) -> None:
|
||||
concepts = _concepts(_build(tmp_path, {OPAQUE: IDENTITY.read_bytes()}))
|
||||
|
||||
assert concepts, "the fixture must produce concepts for this to mean anything"
|
||||
assert all(name.startswith("r900-testnormalen/") for name in concepts), sorted(concepts)
|
||||
assert not any(Path(OPAQUE).stem in name for name in concepts)
|
||||
|
||||
|
||||
def test_the_address_keeps_the_file_and_the_title_is_the_documents(tmp_path: Path) -> None:
|
||||
"""`resource` is where the bytes are; `title` is what the document calls itself.
|
||||
|
||||
`<doc-number>` + `<year>` rather than `<full>`: the fixture's `<full>`, like
|
||||
the measured document's, carries a comma, which ends a flow mapping.
|
||||
"""
|
||||
concepts = _concepts(_build(tmp_path, {OPAQUE: IDENTITY.read_bytes()}))
|
||||
|
||||
assert {values["sources"] for values in concepts.values()} == {
|
||||
_sources(OPAQUE, "R900 Testnormalen 2024")
|
||||
}
|
||||
|
||||
|
||||
def test_a_nested_document_keeps_the_folders_above_it(tmp_path: Path) -> None:
|
||||
concepts = _concepts(_build(tmp_path, {f"leveranse/{OPAQUE}": IDENTITY.read_bytes()}))
|
||||
|
||||
assert all(name.startswith("leveranse/r900-testnormalen/") for name in concepts)
|
||||
assert {values["sources"] for values in concepts.values()} == {
|
||||
_sources(f"leveranse/{OPAQUE}", "R900 Testnormalen 2024")
|
||||
}
|
||||
|
||||
|
||||
def test_a_title_without_a_doc_number_names_the_address_and_not_the_directory(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
concepts = _concepts(_build(tmp_path, {"sts-mini.xml": TITLE_ONLY.read_bytes()}))
|
||||
|
||||
assert all(name.startswith("sts-mini/") for name in concepts)
|
||||
assert {values["sources"] for values in concepts.values()} == {
|
||||
_sources("sts-mini.xml", "Testnormal for fiksturbruk")
|
||||
}
|
||||
|
||||
|
||||
def test_no_declaration_keeps_the_file_name_in_both_places(tmp_path: Path) -> None:
|
||||
concepts = _concepts(_build(tmp_path, {"sts-empty-label.xml": NEITHER.read_bytes()}))
|
||||
|
||||
assert all(name.startswith("sts-empty-label/") for name in concepts)
|
||||
assert {values["sources"] for values in concepts.values()} == {
|
||||
_sources("sts-empty-label.xml", "sts-empty-label.xml")
|
||||
}
|
||||
|
||||
|
||||
def test_a_declared_title_that_would_close_the_flow_mapping_falls_to_the_file_name(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Refused as a VALUE, never as a document, and never cleaned up.
|
||||
|
||||
Removing the comma would write a title the document does not carry.
|
||||
"""
|
||||
data = TITLE_ONLY.read_bytes().replace(
|
||||
b"Testnormal for fiksturbruk", b"Testnormal, for fiksturbruk"
|
||||
)
|
||||
concepts = _concepts(_build(tmp_path, {"sts-mini.xml": data}))
|
||||
|
||||
assert {values["sources"] for values in concepts.values()} == {
|
||||
_sources("sts-mini.xml", "sts-mini.xml")
|
||||
}
|
||||
|
||||
|
||||
# --- a declared name two documents claim --------------------------------------
|
||||
|
||||
|
||||
def test_two_documents_declaring_one_doc_number_both_keep_their_file_name(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Neither is overwritten, neither wins, and the fall is said out loud."""
|
||||
second = IDENTITY.read_bytes().replace(b"<year>2024</year>", b"<year>2023</year>")
|
||||
bundle = _build(tmp_path, {"a.xml": IDENTITY.read_bytes(), "b.xml": second})
|
||||
concepts = _concepts(bundle)
|
||||
|
||||
assert not any(name.startswith("r900-testnormalen/") for name in concepts)
|
||||
assert {name.split("/", 1)[0] for name in concepts} == {"a", "b"}
|
||||
# Every section of both documents landed: same sections, two directories.
|
||||
assert sum(name.startswith("a/") for name in concepts) == sum(
|
||||
name.startswith("b/") for name in concepts
|
||||
)
|
||||
assert "R900 Testnormalen" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_a_declared_name_another_documents_file_name_holds_is_not_taken(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
markdown = b"# Innledning\n\nTekst her.\n\n# Omfang\n\nMer tekst her.\n"
|
||||
concepts = _concepts(
|
||||
_build(tmp_path, {"r900-testnormalen.md": markdown, OPAQUE: IDENTITY.read_bytes()})
|
||||
)
|
||||
|
||||
stems = {name.split("/", 1)[0] for name in concepts}
|
||||
assert stems == {"r900-testnormalen", Path(OPAQUE).stem}
|
||||
assert all(
|
||||
values["source_file"] == "r900-testnormalen.md"
|
||||
for name, values in concepts.items()
|
||||
if name.startswith("r900-testnormalen/")
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue