From 610dc8044c0f03a70c646eb48db51e6bd8ce19c7 Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Wed, 7 Oct 2026 15:31:15 +0300 Subject: [PATCH] feat: publish the reviewed full-work diagnostic through the canonical site Refs #271. Add a digest-bound report adapter, one allow-listed route, reader-first homepage and keyboard/mobile structure. Preserve all existing source artifacts and science gates; add 15 integration regressions and reconcile the existing delivery queue. Depends on #268. --- docs/FULL_WORK_SITE_REPORT.md | 48 ++++ .../CHANGELOG.d/2026-10-07-scrip-site-008.md | 17 ++ project/STATE_AND_QUEUE.md | 45 ++-- scriptorium/site_frozen_diagnostic.py | 73 ++++++ scriptorium/site_renderer.py | 68 ++++-- site/publication-manifest.json | 13 + tests/test_site_contract.py | 12 +- tests/test_site_frozen_diagnostic.py | 229 ++++++++++++++++++ 8 files changed, 471 insertions(+), 34 deletions(-) create mode 100644 docs/FULL_WORK_SITE_REPORT.md create mode 100644 project/CHANGELOG.d/2026-10-07-scrip-site-008.md create mode 100644 scriptorium/site_frozen_diagnostic.py create mode 100644 tests/test_site_frozen_diagnostic.py diff --git a/docs/FULL_WORK_SITE_REPORT.md b/docs/FULL_WORK_SITE_REPORT.md new file mode 100644 index 00000000..e101d22a --- /dev/null +++ b/docs/FULL_WORK_SITE_REPORT.md @@ -0,0 +1,48 @@ +# Full-work reports in the canonical static site + +`scriptorium-static-site-v2` extends the existing publication-manifest-v1 renderer with +one reviewed, source-free historical diagnostic. It also adds a reader-first homepage +with a real report action, explicit page types, shared keyboard focus/skip links and a +responsive card grid. Existing four routes and their scientific labels are retained. + +```bash +python -m scriptorium.site_renderer --repo-root . --output build/site +``` + +The generated homepage links to `works/anna-karenina-full-work-diagnostic/`. That page +reuses `frozen_diagnostic_report` from SCRIP-SITE-007: 28 historical fields, 23 numeric +actuals, five missing actuals, expected/actual/raw deltas, correct mean-length denominators +and replay identities. A backlink returns to the work list. No metric is recalculated. + +## Publication boundary + +The manifest must explicitly include the frozen report. Directory discovery is not +supported. `scriptorium/site_frozen_diagnostic.py` registers the exact existing JSON path, +entry ID, slug, publication/compatibility/source-text flags and SHA-256 of the reviewed +snapshot. The standard schema validator runs even after the byte digest matches; the +parsed object must agree with the verified bytes. This narrow registration intentionally +rejects unreviewed new snapshots, changed observations, altered labels and path aliases. +A new snapshot requires scientific/security review and an explicit registration change. + +The registration does not grant corpus admission or identify FantLab's input. The entry +remains diagnostic-only, inferred, with `corpus_admissibility=not_admissible`; the original +source-free JSON is not rewritten. M2 remains 0/5. The four existing artifacts keep their +own previously reviewed corpus and compatibility flags. + +All entries are validated and rendered before the existing disposable output is replaced. +A broken input, unapproved digest or report-integration mismatch leaves the previous +valid build intact. `build.json` records the exact consumed input hashes and five routes. +The existing workflow performs the full suite and a byte-identical canonical rebuild. + +## Acceptance and delivery + +Tests exercise the canonical five-entry manifest, all internal links/fragments, historical +row counts, source-free output, digest/flag tampering, missing/aliased files, output +preservation, title escaping and deterministic builds. Shared navigation/card/action links +have minimum 44px targets; tables are named, focusable scrolling regions with scoped +headers. The full report retains its own standalone stylesheet and historical labels. + +A successful build is not a live deployment. The existing Pages deployment variable, +repository configuration and later fresh PR acceptance gates are unchanged. No workflow +permissions or external services are added. Visual screenshots require an environment +where browser navigation is allowed; structural tests are not visual verification. diff --git a/project/CHANGELOG.d/2026-10-07-scrip-site-008.md b/project/CHANGELOG.d/2026-10-07-scrip-site-008.md new file mode 100644 index 00000000..b33758af --- /dev/null +++ b/project/CHANGELOG.d/2026-10-07-scrip-site-008.md @@ -0,0 +1,17 @@ +# SCRIP-SITE-008 — canonical full-work report route + +Refs #271; stacked on #268 at 50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae. + +Static-site-v2 adds one digest-approved historical Anna Karenina full-work report to the +existing publication allow-list, with a real homepage action and backlink. Reuses the +standalone validator/renderer; 28 rows, 23 actuals and five missing actuals remain historical +source-unmatched evidence. The original four routes and source JSON bytes are preserved. + +Add 15 integration regressions, extend the canonical manifest safety test for the fifth +entry, and record source hashes in the existing build manifest. Keyboard skip/focus/table +contracts and responsive cards accompany the real reader flow. The compact existing queue +now points to #268, #270 and #271 rather than stale recovery or duplicate local handoffs. + +Local actual five-route build and byte-identical rebuild passed. Full hosted CI and later +fresh acceptance remain required. Browser navigation returned ERR_BLOCKED_BY_ADMINISTRATOR; +VISUAL_NOT_VERIFIED. No corpus, analyzer, M2, deployment activation or access-policy changes. diff --git a/project/STATE_AND_QUEUE.md b/project/STATE_AND_QUEUE.md index 867dd37f..e8b928c2 100644 --- a/project/STATE_AND_QUEUE.md +++ b/project/STATE_AND_QUEUE.md @@ -1,28 +1,29 @@ # STATE_AND_QUEUE — Scriptorium -STATE_REVISION: 290 +STATE_REVISION: 291 PHASE: M1 — Deterministic FantLab surface -LAST_COMMITTED_RUN_AT: 2026-10-02T09:12:28Z -LAST_RESULT: SCRIP-CORPUS-087 / Issue #263 / PR #264 completed. Independent exact-head review re-read the six-file recovery patch at `950404bb86c191b7eacdf21eb34c231f1d906fc4`, confirmed 24/24 pull-request-triggered workflow runs completed successfully with no submitted reviews or unresolved review threads, and found no semantic merge blocker. PR #264 was squash-merged with expected-head protection as `a9df79ec04664011f6dddf3593031424b15c1073`; Issue #263 closed completed. Embedded text-layer status remains unverified; literary body, >=300k admission, FantLab-input identity, diagnostics, M2 and parity remain closed. +LAST_COMMITTED_RUN_AT: 2026-10-07T12:21:02Z +LAST_RESULT: Owner-requested delivery batch: repaired four mean-length unit labels in PR #268 at `50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae` (654 hosted tests and canonical Pages double-build passed); implemented local analysis for #269 in PR #270 at `752f3c491af2f1c563a0c5ae31e9452f93a1c0a3` (22 focused real-engine tests, applicable hosted workflows passed); implemented the SCRIP-SITE-008 route for #271 in this stacked feature. These are branch/PR effects, not merges or live publication. Later fresh acceptance remains required. LAST_VERIFIED_PROGRESS: M1 deterministic metric families remain executable inferred candidates. Public corpus/showcase evidence includes exact frozen/admitted works and source-free derived outputs, while no retained work has an independently established FantLab analyzer-input identity. Perelman now has independently reviewed exact PDF/DjVu identities, pagination, current Wikisource/FantLab surface boundaries, zero-PDF-PageLabels evidence, front-matter anchors, transition/tail anchors, restored-page interior observations, and canonical broad mid-body exact-PDF anchors that expose an unlabelled carrier page while keeping global mapping closed. Darwin/Rachinsky retains canonical reviewed dependency/replay/provider evidence through SCRIP-CORPUS-084. M2 stays 0/5. ## Current unit ```text -UNIT_ID: SCRIP-SITE-007 -ISSUE: #267 +UNIT_ID: SCRIP-SITE-008 +ISSUE: #271 STATUS: REVIEW_PENDING -PR: Resolve the linked product PR from Issue #267; GitHub owns current head/check/merge facts. -BASE: abe7bb889ef98437ba9faef5a79b023499556991 -NEXT_ACTION: Later exact-head acceptance of the readable frozen diagnostic report. - Inspect code/tests and applicable full-suite/Pages CI; do not merge in the authoring run. - Keep the existing historical diagnostic and all source-match/parity gates unchanged. +PR: Resolve the linked route-integration PR from #271; GitHub owns current head/check/merge facts. +BASE: 50fbb9b9c0da59a5eb78c6826381b8acd3dd8aae (PR #268) +NEXT_ACTION: Review/accept #268 separately, then accept this stacked route on a fresh exact head. + PR #270 is independent and can receive its own later acceptance without waiting for this stack. + Re-read applicable full-suite/Pages CI, source-free bytes and publication interlocks. ``` -PR #266 is merged as `abe7bb889ef98437ba9faef5a79b023499556991`; it is not a pending -recovery blocker. The older LAST_RESULT above describes the completed corpus unit. -The report PR adopts useful local work with 23 focused tests; hosted acceptance and -actual merge/publication are separate native GitHub effects, not implied here. +PR #266 is already merged as `abe7bb889ef98437ba9faef5a79b023499556991` and is not a +recovery blocker. GitHub writes succeeded in the 2026-10-07 interactive delivery pass; +this does not establish scheduled-context capability. Do not repeat old denied mutations +without a material capability change. This feature includes the real homepage report +action and shared keyboard/mobile structure; do not reimplement them from old preview ZIPs. ## Current milestone gate @@ -44,11 +45,17 @@ milestone does not declare M1/M2 complete. | Priority | Unit | Mode | Deliverable | Gate / dependency | | --- | --- | --- | --- | --- | -| P1 | SCRIP-SITE-007 / #267 | acceptance / delivery | Accept the offline frozen diagnostic HTML/CLI on its fresh PR head | Full applicable tests/checks; later-run acceptance; no source-match promotion | -| P2 | SCRIP-SITE-008 | public experience | Integrate the reviewed report into the existing Pages allow-list and work route | Reuse report primitives; correct the old local patcher's indentation error; test actual renderer, full build and byte-identical rebuild; deployment rights block live publication only | -| P3 | SCRIP-METRIC-003 | analyzer / CLI | Local UTF-8 text to readable report and JSON using current deterministic metrics | Adopt/revalidate pending local work against actual core modules; private text stays local; dictionary-dependent unknowns stay explicit | -| P4 | SCRIP-SITE-009 | public experience | Reader-first homepage and README with real actions and evidence labels | Revalidate pending local work against accepted Pages items; no invented analyses or capabilities | -| P5 | SCRIP-CORPUS continuation | corpus / provenance | Strengthen a legally usable >=300k diversity candidate with a new executable extraction/provenance result | Preserve translation/edition identity; candidate-specific body/FantLab gates; unchanged scans do not block P1-P4 | +| P1 | SCRIP-SITE-007 / #267 / PR #268 | acceptance | Fresh acceptance of the fixed offline report | Exact head, applicable checks and later-run gate; unit-label fix is already on the PR branch | +| P2 | SCRIP-METRIC-003 / #269 / PR #270 | acceptance | Ship local UTF-8 text to private JSON/offline HTML | Independent of #268; review actual core integration, create-only writer and exact-head CI | +| P3 | SCRIP-SITE-008 / #271 | public experience | Ship five canonical routes including the 28-metric full-work report | Stack depends on #268; digest/source flags, full suite and deterministic build; deployment rights block live publication only | +| P4 | SCRIP-COMPARE-002 pending handoff | analyzer / comparison | Adopt one tested two-diagnostic comparison CLI into the repository | Deduplicate current code/PRs, preserve missing values and schema/profile boundaries; test real canonical Anna/Grin bytes | +| P5 | SCRIP-CORPUS continuation | corpus / provenance | Advance one independent legally usable >=300k diversity candidate | New executable evidence only; preserve translation/edition and FantLab-input gates | + +SCRIP-SITE-009's homepage action and keyboard structure are partly covered by #271; +README local-analysis instructions are in #270. Remaining visual acceptance is scoped: +the interactive Chromium attempt to the actual local build returned +`net::ERR_BLOCKED_BY_ADMINISTRATOR`. Structural/link tests and canonical double builds +passed, but screenshots remain VISUAL_NOT_VERIFIED. No browser restriction was bypassed. ## Retained corpus / provenance orientation diff --git a/scriptorium/site_frozen_diagnostic.py b/scriptorium/site_frozen_diagnostic.py new file mode 100644 index 00000000..2c01eabd --- /dev/null +++ b/scriptorium/site_frozen_diagnostic.py @@ -0,0 +1,73 @@ +"""Publish only the explicitly reviewed frozen full-work diagnostic snapshot. + +Digest approval is a publication boundary, not an assertion of FantLab source identity. +The historical artifact stays immutable; new snapshots require a reviewed registration. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from hashlib import sha256 +import json +from typing import Any + +from . import frozen_diagnostic_report as report + + +FROZEN_PROFILE = report.SCHEMA_VERSION +ARTIFACT_PATH = "benchmarks/fantlab/work270306-wikisource-diagnostic.json" +ARTIFACT_SHA256 = "2a25c3d2df91c2f26b283ec40055ca20248f865cffadc932026cf9c98b5d0e8b" +ENTRY_ID = "tolstoy-anna-karenina-full-work-diagnostic-v1" +SLUG = "anna-karenina-full-work-diagnostic" +_DECLARATION = { + "entry_id": ENTRY_ID, + "kind": "work_showcase", + "slug": SLUG, + "artifact_path": ARTIFACT_PATH, + "artifact_schema": FROZEN_PROFILE, + "publication_status": "diagnostic", + "benchmark_admissibility": "diagnostic_only", + "corpus_admissibility": "not_admissible", + "compatibility_claim": "inferred", + "source_text_included": False, +} + + +def prepare( + entry: Mapping[str, Any], artifact: Mapping[str, Any], raw: bytes | None +) -> dict[str, Any]: + """Validate the publication registration and reuse the standalone report renderer.""" + if set(entry) != {*_DECLARATION, "title"}: + raise ValueError("frozen diagnostic declaration keys drift") + for key, expected in _DECLARATION.items(): + actual = entry.get(key) + if type(actual) is not type(expected) or actual != expected: + raise ValueError(f"frozen diagnostic declaration mismatch: {key}") + if not isinstance(raw, bytes) or sha256(raw).hexdigest() != ARTIFACT_SHA256: + raise ValueError("frozen diagnostic bytes do not match the reviewed SHA-256") + if json.loads(raw) != artifact: + raise ValueError("frozen diagnostic object differs from its verified bytes") + value = report.validate_artifact(artifact) + if value["candidate_id"] != "tolstoy-anna-karenina-ru": + raise ValueError("frozen diagnostic candidate identity drift") + title = entry["title"] + rendered = report.render_html(value, title=title) + # The reused renderer owns all metric tables, values, units and historical labels. + # Add only navigation to the site's work list and a focusable skip-link destination. + if rendered.count("
") != 1 or rendered.count('
') != 1: + raise ValueError("frozen diagnostic navigation integration marker drift") + rendered = rendered.replace( + "
", + '
', + 1, + ).replace('
', '
', 1) + return { + **_DECLARATION, + "render_mode": "frozen_diagnostic", + "title": title, + "author": "Leo Tolstoy", + "work_title": "Anna Karenina", + "metric_count": report.summarize(value)["metric_count"], + "report_html": rendered, + } diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 0f60f894..9d0555ee 100644 --- a/scriptorium/site_renderer.py +++ b/scriptorium/site_renderer.py @@ -19,8 +19,9 @@ from urllib.parse import urlsplit from .publication import validate_compatibility_claim +from . import site_frozen_diagnostic -GENERATOR_PROFILE = "scriptorium-static-site-v1" +GENERATOR_PROFILE = "scriptorium-static-site-v2" MANIFEST_PROFILE = "scriptorium-publication-manifest-v1" PROVENANCE_SHOWCASE_PROFILE = "scriptorium-work-provenance-showcase-v1" _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") @@ -50,6 +51,7 @@ "scriptorium-deterministic-metrics-v1", "scriptorium-deterministic-metrics-v2", PROVENANCE_SHOWCASE_PROFILE, + site_frozen_diagnostic.FROZEN_PROFILE, } ) _PROVENANCE_TOP_LEVEL_KEYS = frozenset( @@ -450,8 +452,15 @@ def _validate_provenance_showcase( } -def _validate_showcase(entry: Mapping[str, Any], artifact: Mapping[str, Any]) -> dict[str, Any]: +def _validate_showcase( + entry: Mapping[str, Any], artifact: Mapping[str, Any], artifact_raw: bytes | None = None +) -> dict[str, Any]: common = _validate_common_entry(entry) + if common[2] == site_frozen_diagnostic.FROZEN_PROFILE: + try: + return site_frozen_diagnostic.prepare(entry, artifact, artifact_raw) + except ValueError as exc: + raise PublicationBuildError(str(exc)) from exc if common[2] == PROVENANCE_SHOWCASE_PROFILE: return _validate_provenance_showcase(entry, artifact, common) return _validate_metric_showcase(entry, artifact, common) @@ -480,6 +489,7 @@ def _page(title: str, stylesheet_href: str, body: str) -> str: f"{_escape(title)}\n" f'\n' "\n\n" + '\n' f"{body}\n" f'
Generated by {_escape(GENERATOR_PROFILE)} from allow-listed derived artifacts. No source prose is published.
\n' "\n\n" @@ -489,7 +499,10 @@ def _page(title: str, stylesheet_href: str, body: str) -> str: def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: cards = [] for item in entries: - mode_note = " · provenance-only" if item.get("render_mode") == "provenance" else "" + mode_note = { + "provenance": " · provenance-only", + "frozen_diagnostic": " · full-work metrics", + }.get(item.get("render_mode"), "") cards.append( '
' f'

{_escape(item["publication_status"])}{_escape(mode_note)}

' @@ -500,11 +513,24 @@ def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: f'Corpus: {_escape(item["corpus_admissibility"])}

' "
" ) + featured = next((item for item in entries if item.get("render_mode") == "frozen_diagnostic"), None) + action = "" + if featured is not None: + action = ( + '

' + f'' + f'Explore the {_escape(featured["metric_count"])}-metric full-work report → ' + 'Browse all evidence pages

' + ) body = ( - '

Scriptorium

Evidence-labelled literary analysis

' - '

Public pages are generated only from repository artifacts explicitly allow-listed by the publication manifest. ' - 'Compatibility labels describe evidence, not visual similarity to FantLab.

' - f'
{"".join(cards)}
' + '

Scriptorium · literary analysis

' + '

Explore the language behind a story.

' + '

Inspect vocabulary, sentence rhythm, dialogue and punctuation in real literary texts. ' + 'Each result shows its measurements, source identity and limits.

' + + action + + '

Full-work metrics, illustrative excerpts and provenance-only pages are labelled separately. ' + 'FantLab source identity remains unknown for the frozen full-work report: diagnostic evidence is not parity.

' + + f'
{"".join(cards)}
' ) return _page("Scriptorium", "assets/site.css", body) @@ -531,7 +557,7 @@ def _render_metric_work(item: Mapping[str, Any]) -> str: ) revision_url = _escape(item["revision_url"]) body = ( - '
' + '
' f'

{_escape(item["publication_status"])}

' f'

{_escape(item["title"])}

' f'

{_escape(item["author"])} · {_escape(item["work_title"])}

' @@ -541,7 +567,9 @@ def _render_metric_work(item: Mapping[str, Any]) -> str: f'Corpus: {_escape(item["corpus_admissibility"])}. ' f'{_escape(item["representativeness_note"])}' '

Derived metrics

' - '
' + '
MetricValueUnitEvidence
' + '' + '' f'{"".join(rows)}
Derived metrics and their evidence labels
MetricValueUnitEvidence
' '

Provenance

' f'
Provider
{_escape(item["provider"])}
' @@ -558,7 +586,7 @@ def _render_provenance_work(item: Mapping[str, Any]) -> str: revision_url = _escape(item["revision_url"]) fantlab_url = _escape(item["fantlab_url"]) body = ( - '
' + '
' f'

{_escape(item["publication_status"])} · provenance-only

' f'

{_escape(item["title"])}

' f'

{_escape(item["author"])} · {_escape(item["work_title"])}

' @@ -600,6 +628,8 @@ def _render_provenance_work(item: Mapping[str, Any]) -> str: def _render_work(item: Mapping[str, Any]) -> str: + if item.get("render_mode") == "frozen_diagnostic": + return item["report_html"] if item.get("render_mode") == "provenance": return _render_provenance_work(item) return _render_metric_work(item) @@ -607,13 +637,22 @@ def _render_work(item: Mapping[str, Any]) -> str: _SITE_CSS = """\ :root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; } -body { margin: 0; } -main, footer { max-width: 72rem; margin: auto; padding: 2rem; } +* { box-sizing: border-box; } +body { margin: 0; background: Canvas; color: CanvasText; } +main, footer { max-width: 72rem; margin: auto; padding: clamp(1rem, 4vw, 2rem); } a { color: inherit; } +.skip-link:not(:focus) { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; } +.skip-link:focus { position: absolute; z-index: 10; margin: .5rem; padding: .75rem; background: Canvas; } +*:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; } +nav a, .card a, .actions a { display: inline-block; min-height: 44px; padding-block: .6rem; } +h1, .card h2 { font-family: Georgia, 'Times New Roman', serif; } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-block: 1.5rem; } +.primary-action { border: 2px solid currentColor; border-radius: .5rem; padding-inline: 1rem; font-weight: 700; } +.notice { margin-block: 1.5rem; } h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1.05; } .eyebrow { font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } .lede { font-size: 1.15rem; max-width: 52rem; } -.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); gap: 1rem; } .card, .notice { border: 1px solid currentColor; border-radius: .75rem; padding: 1rem; } .table-wrap { overflow-x: auto; } table { border-collapse: collapse; width: 100%; } @@ -623,6 +662,7 @@ def _render_work(item: Mapping[str, Any]) -> str: .status { font-weight: 700; } code { overflow-wrap: anywhere; } footer { opacity: .7; font-size: .9rem; } +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } """ @@ -671,7 +711,7 @@ def build_site( artifact_path = _safe_artifact_path(root, entry.get("artifact_path")) artifact, artifact_raw = _read_json(artifact_path) - item = _validate_showcase(entry, artifact) + item = _validate_showcase(entry, artifact, artifact_raw) render_items.append(item) input_rows.append( { diff --git a/site/publication-manifest.json b/site/publication-manifest.json index db133b13..8de8156f 100644 --- a/site/publication-manifest.json +++ b/site/publication-manifest.json @@ -60,6 +60,19 @@ "corpus_admissibility": "admissible", "compatibility_claim": "extension", "source_text_included": false + }, + { + "entry_id": "tolstoy-anna-karenina-full-work-diagnostic-v1", + "kind": "work_showcase", + "slug": "anna-karenina-full-work-diagnostic", + "title": "Anna Karenina — 28-metric full-work diagnostic", + "artifact_path": "benchmarks/fantlab/work270306-wikisource-diagnostic.json", + "artifact_schema": "scriptorium-frozen-diagnostic-v1", + "publication_status": "diagnostic", + "benchmark_admissibility": "diagnostic_only", + "corpus_admissibility": "not_admissible", + "compatibility_claim": "inferred", + "source_text_included": false } ] } diff --git a/tests/test_site_contract.py b/tests/test_site_contract.py index c4e0ce7e..054b77ee 100644 --- a/tests/test_site_contract.py +++ b/tests/test_site_contract.py @@ -4,6 +4,7 @@ from pathlib import Path from scriptorium.publication import derive_compatibility_claim, validate_compatibility_claim +from scriptorium import site_frozen_diagnostic ROOT = Path(__file__).resolve().parents[1] @@ -51,11 +52,20 @@ def test_seed_entries_are_unique_and_repo_relative(self): self.assertFalse(entry["source_text_included"]) def test_seed_manifest_matches_canonical_showcase_safety_labels(self): - self.assertEqual(len(self.manifest["entries"]), 4) + self.assertEqual(len(self.manifest["entries"]), 5) for entry in self.manifest["entries"]: artifact = json.loads( (ROOT / entry["artifact_path"]).read_text(encoding="utf-8") ) + if entry["artifact_schema"] == site_frozen_diagnostic.FROZEN_PROFILE: + raw = (ROOT / entry["artifact_path"]).read_bytes() + item = site_frozen_diagnostic.prepare(entry, artifact, raw) + self.assertEqual(item["metric_count"], 28) + self.assertEqual(entry["compatibility_claim"], "inferred") + self.assertEqual(entry["corpus_admissibility"], "not_admissible") + self.assertIs(artifact["epistemic_status"]["m2_parity_admissible"], False) + self.assertEqual(artifact["epistemic_status"]["fantlab_source_edition_match"], "unknown") + continue self.assertEqual(entry["entry_id"], artifact["showcase_id"]) self.assertEqual(entry["publication_status"], artifact["status"]) self.assertEqual( diff --git a/tests/test_site_frozen_diagnostic.py b/tests/test_site_frozen_diagnostic.py new file mode 100644 index 00000000..269d63a2 --- /dev/null +++ b/tests/test_site_frozen_diagnostic.py @@ -0,0 +1,229 @@ +"""Integration tests for the explicitly registered historical full-work route.""" +import copy +from hashlib import sha256 +from html.parser import HTMLParser +import json +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import patch +from urllib.parse import urlsplit, unquote + +from scriptorium import site_frozen_diagnostic as frozen +from scriptorium import site_renderer as site + +ROOT = Path(__file__).resolve().parents[1] + + +class Links(HTMLParser): + def __init__(self, text): + super().__init__() + self.links = [] + self.ids = [] + self.feed(text) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if "id" in attrs: + self.ids.append(attrs["id"]) + if tag in {"a", "link"} and "href" in attrs: + self.links.append(attrs["href"]) + + +class FrozenSiteIntegrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / "repo" + self.manifest = json.loads((ROOT / "site/publication-manifest.json").read_text()) + (self.root / "site").mkdir(parents=True) + for entry in self.manifest["entries"]: + destination = self.root / entry["artifact_path"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / entry["artifact_path"], destination) + self.entry = next(row for row in self.manifest["entries"] if row["entry_id"] == frozen.ENTRY_ID) + self.raw = (self.root / frozen.ARTIFACT_PATH).read_bytes() + self.artifact = json.loads(self.raw) + self.output = self.root / "build/site" + self.write_manifest() + + def write_manifest(self): + (self.root / "site/publication-manifest.json").write_text(json.dumps(self.manifest, ensure_ascii=False)) + + def snapshot(self): + return {path.relative_to(self.output).as_posix(): path.read_bytes() + for path in sorted(self.output.rglob("*")) if path.is_file()} + + def test_canonical_site_preserves_four_routes_and_adds_one_real_report(self): + record = site.build_site(self.root, self.output) + routes = {row["route"] for row in record["entries"]} + self.assertEqual(routes, { + "works/anna-karenina-part1-ch1-opening/", + "works/anna-karenina-part1-ch2-dialogue/", + "works/tolstoy-anna-karenina-morphology-diagnostic/", + "works/tolstoy-resurrection-provenance/", + f"works/{frozen.SLUG}/", + }) + self.assertEqual(record["generator_profile"], "scriptorium-static-site-v2") + row = next(row for row in record["entries"] if row["entry_id"] == frozen.ENTRY_ID) + self.assertEqual(row["artifact_sha256"], frozen.ARTIFACT_SHA256) + index = (self.output / "index.html").read_text() + self.assertIn("Explore the 28-metric full-work report", index) + self.assertIn("full-work metrics", index) + self.assertIn("provenance-only", index) + self.assertEqual(index.count('class="card"'), 5) + rendered = (self.output / "works" / frozen.SLUG / "index.html").read_text() + self.assertEqual(rendered.count('scope="row"'), 28) + self.assertEqual(rendered.count("characters / word"), 1) + self.assertEqual(rendered.count("characters / sentence"), 3) + self.assertIn("Historical snapshot", rendered) + self.assertIn("0/5", rendered) + self.assertIn("unknown", rendered) + self.assertIn('href="../../"', rendered) + self.assertIn('id="metrics" tabindex="-1"', rendered) + self.assertEqual(frozen.report.summarize(self.artifact)["missing_actual_count"], 5) + self.assertEqual((self.root / frozen.ARTIFACT_PATH).read_bytes(), self.raw) + + def test_double_build_is_byte_identical(self): + site.build_site(self.root, self.output) + before = self.snapshot() + site.build_site(self.root, self.output) + self.assertEqual(self.snapshot(), before) + + def test_all_internal_links_and_fragments_resolve(self): + site.build_site(self.root, self.output) + for path in self.output.rglob("*.html"): + parsed = Links(path.read_text()) + self.assertEqual(len(parsed.ids), len(set(parsed.ids)), path) + for href in parsed.links: + link = urlsplit(href) + if link.scheme or link.netloc: + continue + target = (path.parent / unquote(link.path)).resolve() if link.path else path + if target.is_dir(): + target = target / "index.html" + self.assertTrue(target.is_file(), (path, href)) + self.assertTrue(target.is_relative_to(self.output), (path, href)) + if link.fragment: + self.assertIn(link.fragment, Links(target.read_text()).ids) + + def test_every_page_has_targetable_main_and_no_source_selection_prose(self): + site.build_site(self.root, self.output) + for path in self.output.rglob("*.html"): + rendered = path.read_text() + self.assertIn('id="main-content" tabindex="-1"', rendered) + self.assertNotIn("", text) + self.assertIn("<script>", text) + + def test_report_adapter_marker_drift_preserves_output(self): + site.build_site(self.root, self.output) + before = self.snapshot() + with patch.object(frozen.report, "render_html", return_value="changed contract"): + with self.assertRaisesRegex(site.PublicationBuildError, "integration marker"): + site.build_site(self.root, self.output) + self.assertEqual(self.snapshot(), before) + + +if __name__ == "__main__": + unittest.main()