diff --git a/README.md b/README.md index 6673753c..d62c5f28 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,24 @@ adopt the facts-only surface explicitly. `--year` is inert for `--suite uk` because the UK packages are year-pinned. The US off-year bundle behavior is unchanged and out of scope here. +The New Zealand feed builds the same way from the curated NZ suite: + +```bash +uv run chronicle build-bundle --suite nz --out /tmp/chronicle-nz --replace +uv run chronicle build-consumer-artifact --facts /tmp/chronicle-nz --out /tmp/chronicle-nz-artifact --replace +``` + +`NZ_BUNDLE_SOURCES` in `chronicle/bundle.py` lists every package under +`packages/ird`, `packages/mbie`, `packages/msd`, `packages/stats_nz` and +`packages/treasury`, including the national, regional-council and +area-outside-region (`rc_99`) Census 2023 place-summary packages; a test +fails if an alias under those directories is added or dropped without +updating it. `--year` is inert for +`--suite nz` too: every NZ package pins its `artifact_year` and uses +year-independent declarations, so one build at any `--year` emits its facts with +the publisher's own calendar, tax, fiscal-year or monthly period. `--suite` +cannot be combined with `--source`. + Builds without an Axiom CLI still pass when the source package is otherwise valid, but `agent_acceptance.json` warns with `concept_alignment_validation_skipped`. For strict agent review, require every diff --git a/changelog.d/nz-bundle-suite.added.md b/changelog.d/nz-bundle-suite.added.md new file mode 100644 index 00000000..d38fbd6c --- /dev/null +++ b/changelog.d/nz-bundle-suite.added.md @@ -0,0 +1,8 @@ +Add `chronicle build-bundle --suite nz`, a curated New Zealand bundle suite for +Microcosm's NZ feed. It builds all 48 packages under `packages/ird`, +`packages/mbie`, `packages/msd`, `packages/stats_nz` and `packages/treasury`, +including the 36 national, regional-council and area-outside-region Census +2023 place-summary packages. A drift guard fails when an alias under those +directories is added or dropped without updating the +suite. Like `--suite uk`, it refuses `--source`, and `--year` does not change +what it builds because every NZ package pins its artifact year. diff --git a/changelog.d/nz-treasury-r2-prefix.fixed.md b/changelog.d/nz-treasury-r2-prefix.fixed.md new file mode 100644 index 00000000..10260f7d --- /dev/null +++ b/changelog.d/nz-treasury-r2-prefix.fixed.md @@ -0,0 +1,4 @@ +Route `packages/treasury` and `db/data/treasury` to the `raw/nz` and +`derived/nz` R2 prefixes, so `verify-raw` and `publish-raw` compute the +`raw/nz/treasury/...` keys the two NZ Treasury manifests declare instead of +`raw/treasury/...`. diff --git a/chronicle/artifacts.py b/chronicle/artifacts.py index b195b1d8..ec773471 100644 --- a/chronicle/artifacts.py +++ b/chronicle/artifacts.py @@ -35,7 +35,7 @@ # and ``derived/{source_id}`` shapes. Publisher directories are the stable # routing input because they are shared by packages/ and db/data/. R2_COUNTRY_PUBLISHERS = { - "nz": frozenset({"ird", "mbie", "msd", "stats_nz"}), + "nz": frozenset({"ird", "mbie", "msd", "stats_nz", "treasury"}), "uk": frozenset( { "desnz", diff --git a/chronicle/bundle.py b/chronicle/bundle.py index b79b5914..782c1775 100644 --- a/chronicle/bundle.py +++ b/chronicle/bundle.py @@ -291,6 +291,72 @@ "welshgov-household-projections-2022-based", "welshgov-transport-revenue-outturn-2024-25", ) +# Every package under these publisher directories is a New Zealand package: +# Inland Revenue, MBIE, MSD, Stats NZ and the New Zealand Treasury. "treasury" +# names no country, so a non-NZ treasury package must not be added under +# packages/treasury/; the drift guard would then pull it into the NZ suite. +NZ_BUNDLE_SOURCE_PREFIXES = ( + "ird", + "mbie", + "msd", + "stats_nz", + "treasury", +) +# The curated NZ suite (`build-bundle --suite nz`). Each member pins one +# artifact_year and declares nothing by year, so it reads the same file and +# emits the same facts at any --year; the suite's periods (calendar, tax, +# fiscal and monthly) are the publishers' own, whatever --year is passed. +NZ_BUNDLE_SOURCES = ( + "ird-taxable-income-distribution-2025", + "ird-wage-salary-distribution-2026", + "ird-working-for-families-statistics-sept-2025", + "mbie-tenancy-bond-rents-tla-2026", + "msd-annual-report-benefit-expenses-2025", + "msd-benefit-fact-sheets-national-june-2026", + "msd-benefit-fact-sheets-supplementary-june-2026", + "msd-nzs-vp-fact-sheet-june-2026", + "stats-nz-census-2023-family-type", + "stats-nz-census-2023-family-type-rc-01", + "stats-nz-census-2023-family-type-rc-02", + "stats-nz-census-2023-family-type-rc-03", + "stats-nz-census-2023-family-type-rc-04", + "stats-nz-census-2023-family-type-rc-05", + "stats-nz-census-2023-family-type-rc-06", + "stats-nz-census-2023-family-type-rc-07", + "stats-nz-census-2023-family-type-rc-08", + "stats-nz-census-2023-family-type-rc-09", + "stats-nz-census-2023-family-type-rc-12", + "stats-nz-census-2023-family-type-rc-13", + "stats-nz-census-2023-family-type-rc-14", + "stats-nz-census-2023-family-type-rc-15", + "stats-nz-census-2023-family-type-rc-16", + "stats-nz-census-2023-family-type-rc-17", + "stats-nz-census-2023-family-type-rc-18", + "stats-nz-census-2023-family-type-rc-99", + "stats-nz-census-2023-households-by-region", + "stats-nz-census-2023-households-by-region-rc-01", + "stats-nz-census-2023-households-by-region-rc-02", + "stats-nz-census-2023-households-by-region-rc-03", + "stats-nz-census-2023-households-by-region-rc-04", + "stats-nz-census-2023-households-by-region-rc-05", + "stats-nz-census-2023-households-by-region-rc-06", + "stats-nz-census-2023-households-by-region-rc-07", + "stats-nz-census-2023-households-by-region-rc-08", + "stats-nz-census-2023-households-by-region-rc-09", + "stats-nz-census-2023-households-by-region-rc-12", + "stats-nz-census-2023-households-by-region-rc-13", + "stats-nz-census-2023-households-by-region-rc-14", + "stats-nz-census-2023-households-by-region-rc-15", + "stats-nz-census-2023-households-by-region-rc-16", + "stats-nz-census-2023-households-by-region-rc-17", + "stats-nz-census-2023-households-by-region-rc-18", + "stats-nz-census-2023-households-by-region-rc-99", + "stats-nz-household-net-worth-2024", + "stats-nz-subnational-population-estimates-2025", + "treasury-an24-01-fiscal-totals", + "treasury-estimates-of-appropriations-2026-27", +) +BUNDLE_SUITES = ("nz", "uk") _KEY_DOMAINS = { "aggregate_fact_key": "aggregate_fact", @@ -304,29 +370,76 @@ } -def uk_bundle_sources_from_aliases() -> tuple[str, ...]: - """Return UK-package aliases implied by the source-package directory prefixes.""" +def _prefixed_aliases(prefixes: Sequence[str]) -> tuple[str, ...]: + """Return the sorted aliases whose package directory sits under ``prefixes``.""" return tuple( sorted( alias for alias, path in SOURCE_PACKAGE_ALIASES.items() - if path.parts and path.parts[0] in UK_BUNDLE_SOURCE_PREFIXES + if path.parts and path.parts[0] in prefixes ) ) -def assert_uk_bundle_sources_match_aliases() -> None: - """Fail loudly if the curated UK suite omits a UK-prefixed alias.""" - expected = uk_bundle_sources_from_aliases() - if UK_BUNDLE_SOURCES != expected: - missing = sorted(set(expected) - set(UK_BUNDLE_SOURCES)) - extra = sorted(set(UK_BUNDLE_SOURCES) - set(expected)) +def _assert_suite_matches_aliases( + country: str, + sources: tuple[str, ...], + expected: tuple[str, ...], +) -> None: + if sources != expected: + missing = sorted(set(expected) - set(sources)) + extra = sorted(set(sources) - set(expected)) raise ValueError( - "UK_BUNDLE_SOURCES drifted from UK-prefixed SOURCE_PACKAGE_ALIASES: " - f"missing={missing}, extra={extra}" + f"{country}_BUNDLE_SOURCES drifted from {country}-prefixed " + f"SOURCE_PACKAGE_ALIASES: missing={missing}, extra={extra}" ) +def uk_bundle_sources_from_aliases() -> tuple[str, ...]: + """Return UK-package aliases implied by the source-package directory prefixes.""" + return _prefixed_aliases(UK_BUNDLE_SOURCE_PREFIXES) + + +def assert_uk_bundle_sources_match_aliases() -> None: + """Fail loudly if the curated UK suite omits a UK-prefixed alias.""" + _assert_suite_matches_aliases( + "UK", UK_BUNDLE_SOURCES, uk_bundle_sources_from_aliases() + ) + + +def nz_bundle_sources_from_aliases() -> tuple[str, ...]: + """Return NZ-package aliases implied by the source-package directory prefixes. + + Nested packages count: each Census 2023 place-summary family has a national + root, 16 regional-council ``rc_XX`` children and an area-outside-region + ``rc_99`` child. + + ``--year`` does not select or relabel NZ facts: every member pins its + ``artifact_year`` and uses year-independent declarations. A single build + at any year preserves all members' published calendar, tax, fiscal and + monthly reference periods. + """ + return _prefixed_aliases(NZ_BUNDLE_SOURCE_PREFIXES) + + +def assert_nz_bundle_sources_match_aliases() -> None: + """Fail loudly if the curated NZ suite omits or adds an NZ-prefixed alias.""" + _assert_suite_matches_aliases( + "NZ", NZ_BUNDLE_SOURCES, nz_bundle_sources_from_aliases() + ) + + +def bundle_suite_sources(suite: str) -> tuple[str, ...]: + """Return a curated suite's sources once they match its prefixed aliases.""" + if suite == "uk": + assert_uk_bundle_sources_match_aliases() + return UK_BUNDLE_SOURCES + if suite == "nz": + assert_nz_bundle_sources_match_aliases() + return NZ_BUNDLE_SOURCES + raise ValueError(f"Unknown bundle suite: {suite!r}") + + @dataclass(frozen=True) class BuildBundleIssue: """One bundle-level build issue.""" diff --git a/chronicle/harness.py b/chronicle/harness.py index 3ca4ee74..22832851 100644 --- a/chronicle/harness.py +++ b/chronicle/harness.py @@ -22,10 +22,10 @@ verify_raw_artifacts, ) from chronicle.bundle import ( - UK_BUNDLE_SOURCES, + BUNDLE_SUITES, BuildBundleReport, - assert_uk_bundle_sources_match_aliases, build_bundle, + bundle_suite_sources, ) from chronicle.concepts import ConceptAlignmentReport, validate_concept_alignments from chronicle.consumer_contract import ( @@ -276,11 +276,12 @@ def build_bundle_dir( replace: bool = False, ) -> BuildBundleReport: """Build a merged Chronicle consumer bundle from source-package suites.""" - if suite == "uk": + if suite is not None: + if suite not in BUNDLE_SUITES: + raise ValueError(f"Unknown bundle suite: {suite!r}") if sources: - raise ValueError("--suite uk cannot be combined with --source.") - assert_uk_bundle_sources_match_aliases() - sources = list(UK_BUNDLE_SOURCES) + raise ValueError(f"--suite {suite} cannot be combined with --source.") + sources = list(bundle_suite_sources(suite)) return build_bundle( output_dir, year=year, @@ -744,7 +745,7 @@ def main(argv: list[str] | None = None) -> int: ) bundle_parser.add_argument( "--suite", - choices=["uk"], + choices=list(BUNDLE_SUITES), help="Curated source-package suite to build.", ) bundle_parser.add_argument( diff --git a/docs/agent-source-package-harness.md b/docs/agent-source-package-harness.md index c8cec774..41f540cb 100644 --- a/docs/agent-source-package-harness.md +++ b/docs/agent-source-package-harness.md @@ -672,6 +672,12 @@ uv run chronicle build-consumer-artifact --facts /tmp/chronicle-uk --out /tmp/ch `--year` is inert for `--suite uk` because the UK packages are year-pinned. The US off-year bundle behavior is unchanged and out of scope here. +`--suite nz` builds the curated New Zealand suite (every package under +`packages/ird`, `packages/mbie`, `packages/msd`, `packages/stats_nz` and +`packages/treasury`) the same way, and +`--year` is inert for it as well: each NZ package pins its `artifact_year` and +uses year-independent declarations. + The first agent-facing gate is now `/tmp/chronicle-suite/reports/agent_acceptance.json`; it summarizes whether raw artifacts have R2 pointers, the full source document was parsed, facts have diff --git a/tests/test_chronicle_artifacts.py b/tests/test_chronicle_artifacts.py index 409f0be2..6d26bb98 100644 --- a/tests/test_chronicle_artifacts.py +++ b/tests/test_chronicle_artifacts.py @@ -12,6 +12,7 @@ from chronicle.cli import main as cli_main from chronicle.artifacts import ( + R2_COUNTRY_PUBLISHERS, build_artifact_key, build_artifact_rows, build_derived_r2_key, @@ -24,8 +25,10 @@ publish_derived_artifacts, publish_source_artifacts, ) +from chronicle.bundle import NZ_BUNDLE_SOURCE_PREFIXES, NZ_BUNDLE_SOURCES from chronicle.epoch import Epoch from chronicle.harness import main as harness_main +from chronicle.source_package import load_source_package REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -97,6 +100,12 @@ def test_build_artifact_key_rejects_unknown_build_epoch(): ("source_id", "package_path", "expected_country"), [ ("ird", "db/data/ird/wff", "nz"), + ("treasury", "db/data/treasury/an24_01_fiscal_totals", "nz"), + ( + "treasury", + "/repo/packages/treasury/estimates_of_appropriations_2026_27", + "nz", + ), ("desnz", "db/data/desnz/qep", "uk"), ("ons", "db/data/ons/mye", "uk"), ("irs_soi", "db/data/irs_soi/table_1_1", None), @@ -147,6 +156,43 @@ def test_country_aware_r2_keys_preserve_legacy_us_layout(): assert desnz_key == "raw/uk/desnz/desnz-qep/2026/def456/qep.xlsx" +def test_every_nz_suite_manifest_declares_its_default_canonical_raw_key(): + """Every NZ suite artifact sits under raw/nz, Treasury's included. + + verify-raw and publish-raw route a package to raw/nz by its publisher + directory, so every NZ suite publisher must be an NZ R2 publisher. + """ + assert R2_COUNTRY_PUBLISHERS["nz"] == frozenset(NZ_BUNDLE_SOURCE_PREFIXES) + manifests = set() + for alias in NZ_BUNDLE_SOURCES: + artifact = load_source_package(alias).artifact + manifests.add( + REPOSITORY_ROOT + / artifact.resource_package + / artifact.resource_directory + / artifact.manifest + ) + + checked = [] + for manifest_path in sorted(manifests): + manifest = yaml.safe_load(manifest_path.read_text()) + for year, artifact in manifest["files"].items(): + expected_key = build_r2_key( + source_id=manifest["source_id"], + package_id=manifest["package_id"], + year=year, + sha256=artifact["sha256"], + filename=artifact["filename"], + package_path=manifest_path, + ) + assert expected_key.startswith("raw/nz/") + assert artifact["storage"]["r2"]["key"] == expected_key, manifest_path + checked.append(manifest["source_id"]) + + assert set(checked) == set(NZ_BUNDLE_SOURCE_PREFIXES) + assert checked.count("treasury") == 4 + + def test_all_desnz_manifests_use_canonical_uk_r2_keys_and_pinned_bytes(): manifests = sorted(DESNZ_DATA_ROOT.glob("*/manifest.yaml")) diff --git a/tests/test_chronicle_bundle.py b/tests/test_chronicle_bundle.py index d12cde82..29dee597 100644 --- a/tests/test_chronicle_bundle.py +++ b/tests/test_chronicle_bundle.py @@ -14,6 +14,7 @@ BUNDLE_COVERAGE_SCHEMA_VERSION, BUNDLE_SCHEMA_VERSION, BUNDLE_SOURCES_SCHEMA_VERSION, + NZ_BUNDLE_SOURCES, UK_BUNDLE_SOURCES, build_bundle, build_bundle_coverage, @@ -164,6 +165,135 @@ def fake_build_bundle_dir(output_dir, **kwargs): assert captured["suite"] == "uk" +class _FakeBundleReport: + valid = True + + def to_dict(self): + return {"valid": True} + + +@pytest.fixture +def fake_build_bundle(monkeypatch): + """Record what build_bundle_dir asks build_bundle to build, without building.""" + calls = [] + + def record(output_dir, **kwargs): + calls.append({"output_dir": output_dir, **kwargs}) + return _FakeBundleReport() + + monkeypatch.setattr("chronicle.harness.build_bundle", record) + return calls + + +def test_build_bundle_dir_nz_suite_uses_curated_sources(tmp_path, fake_build_bundle): + report = build_bundle_dir(tmp_path / "bundle", year=2023, suite="nz") + + assert report.valid + [call] = fake_build_bundle + assert tuple(call["sources"]) == NZ_BUNDLE_SOURCES + assert call["output_dir"] == tmp_path / "bundle" + assert call["year"] == 2023 + assert "treasury-an24-01-fiscal-totals" in call["sources"] + assert "treasury-estimates-of-appropriations-2026-27" in call["sources"] + assert "stats-nz-census-2023-households-by-region-rc-99" in call["sources"] + assert not set(call["sources"]) & set(UK_BUNDLE_SOURCES) + + +@pytest.mark.parametrize( + "suite,roster", + [ + pytest.param("nz", NZ_BUNDLE_SOURCES, id="nz"), + pytest.param("uk", UK_BUNDLE_SOURCES, id="uk"), + ], +) +def test_build_bundle_cli_builds_each_curated_suite( + tmp_path, fake_build_bundle, suite, roster +): + status = harness_main( + ["build-bundle", "--suite", suite, "--out", str(tmp_path / "bundle")] + ) + + assert status == 0 + [call] = fake_build_bundle + assert tuple(call["sources"]) == roster + assert call["year"] == 2023 + + +@pytest.mark.parametrize("suite", ["nz", "uk"]) +def test_build_bundle_dir_refuses_a_suite_with_explicit_sources( + tmp_path, fake_build_bundle, suite +): + with pytest.raises(ValueError) as excinfo: + build_bundle_dir( + tmp_path / "bundle", + year=2023, + suite=suite, + sources=["ird-working-for-families-statistics-sept-2025"], + ) + + assert str(excinfo.value) == f"--suite {suite} cannot be combined with --source." + assert fake_build_bundle == [] + + +@pytest.mark.parametrize("suite", ["nz", "uk"]) +def test_build_bundle_cli_refuses_a_suite_with_explicit_sources( + tmp_path, fake_build_bundle, suite +): + argv = [ + "build-bundle", + "--suite", + suite, + "--source", + "treasury-an24-01-fiscal-totals", + "--out", + str(tmp_path / "bundle"), + ] + + with pytest.raises(ValueError, match=f"^--suite {suite} cannot be combined"): + harness_main(argv) + + assert fake_build_bundle == [] + + +def test_build_bundle_dir_nz_suite_refuses_a_drifted_roster( + tmp_path, fake_build_bundle, monkeypatch +): + monkeypatch.setattr( + chronicle.bundle, + "NZ_BUNDLE_SOURCES", + tuple( + alias + for alias in NZ_BUNDLE_SOURCES + if alias != "stats-nz-census-2023-family-type-rc-12" + ), + ) + + with pytest.raises( + ValueError, + match=r"^NZ_BUNDLE_SOURCES drifted .*" + r"missing=\['stats-nz-census-2023-family-type-rc-12'\], extra=\[\]$", + ): + build_bundle_dir(tmp_path / "bundle", year=2023, suite="nz") + + assert fake_build_bundle == [] + + +def test_build_bundle_dir_rejects_an_unknown_suite(tmp_path, fake_build_bundle): + with pytest.raises(ValueError, match="Unknown bundle suite: 'au'"): + build_bundle_dir(tmp_path / "bundle", year=2023, suite="au") + + assert fake_build_bundle == [] + + +def test_build_bundle_cli_rejects_an_unknown_suite(tmp_path, fake_build_bundle, capsys): + with pytest.raises(SystemExit) as excinfo: + harness_main(["build-bundle", "--suite", "au", "--out", str(tmp_path)]) + + assert excinfo.value.code == 2 + assert "invalid choice: 'au'" in capsys.readouterr().err + assert fake_build_bundle == [] + + @pytest.fixture def prune_suite_intermediates(monkeypatch): """Delete each source suite's intermediates once the suite is built. diff --git a/tests/test_source_package_alias_drift.py b/tests/test_source_package_alias_drift.py index 613be10b..0a21705c 100644 --- a/tests/test_source_package_alias_drift.py +++ b/tests/test_source_package_alias_drift.py @@ -12,18 +12,57 @@ import pytest from pathlib import Path +import chronicle.bundle from chronicle.bundle import ( + NZ_BUNDLE_SOURCE_PREFIXES, + NZ_BUNDLE_SOURCES, UK_BUNDLE_SOURCE_PREFIXES, UK_BUNDLE_SOURCES, + assert_nz_bundle_sources_match_aliases, assert_uk_bundle_sources_match_aliases, build_bundle, + nz_bundle_sources_from_aliases, ) from chronicle.source_package import ( SOURCE_PACKAGE_ALIASES, SourcePackageAliasDriftError, + _declarations_depend_on_year, assert_alias_map_covers_packages, discover_source_package_dirs, find_alias_map_drift, + load_source_package, + source_package_root, +) + +# Stats NZ regional council codes (there is no 10 or 11) and 99, Area Outside +# Region: one child package per code under each Census 2023 place-summary root. +NZ_CENSUS_REGION_CODES = ( + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "99", +) +NZ_CENSUS_ROOTS = ( + "stats-nz-census-2023-family-type", + "stats-nz-census-2023-households-by-region", +) +NZ_CENSUS_MEMBERS = tuple( + alias + for root in NZ_CENSUS_ROOTS + for alias in (root, *(f"{root}-rc-{code}" for code in NZ_CENSUS_REGION_CODES)) ) @@ -94,6 +133,156 @@ def test_uk_bundle_sources_match_uk_prefixed_aliases(): assert_uk_bundle_sources_match_aliases() +def test_uk_bundle_drift_message_is_unchanged(monkeypatch): + """Factoring the suites through one check keeps the UK error text.""" + monkeypatch.setattr( + chronicle.bundle, + "UK_BUNDLE_SOURCES", + (*UK_BUNDLE_SOURCES[1:], "zz-planted-uk-package"), + ) + + with pytest.raises(ValueError) as excinfo: + assert_uk_bundle_sources_match_aliases() + + assert str(excinfo.value) == ( + "UK_BUNDLE_SOURCES drifted from UK-prefixed SOURCE_PACKAGE_ALIASES: " + "missing=['desnz-energy-trends-domestic-electricity-2026'], " + "extra=['zz-planted-uk-package']" + ) + + +def test_nz_bundle_sources_match_nz_prefixed_aliases(): + """The NZ bundle suite must include every alias under an NZ source prefix.""" + expected = tuple( + sorted( + alias + for alias, path in SOURCE_PACKAGE_ALIASES.items() + if path.parts and path.parts[0] in NZ_BUNDLE_SOURCE_PREFIXES + ) + ) + + assert NZ_BUNDLE_SOURCE_PREFIXES == ("ird", "mbie", "msd", "stats_nz", "treasury") + assert len(NZ_BUNDLE_SOURCES) == 48 + assert NZ_BUNDLE_SOURCES == expected == nz_bundle_sources_from_aliases() + assert_nz_bundle_sources_match_aliases() + + +def test_nz_bundle_sources_include_every_census_place_summary_package(): + """All 36 Census 2023 place-summary packages, roots and rc_XX children.""" + assert len(NZ_CENSUS_MEMBERS) == len(set(NZ_CENSUS_MEMBERS)) == 36 + assert set(NZ_CENSUS_MEMBERS) <= set(NZ_BUNDLE_SOURCES) + census_in_suite = { + alias for alias in NZ_BUNDLE_SOURCES if alias.startswith("stats-nz-census-") + } + assert census_in_suite == set(NZ_CENSUS_MEMBERS) + for alias in NZ_CENSUS_MEMBERS: + root = next(root for root in NZ_CENSUS_ROOTS if alias.startswith(root)) + directory = Path("stats_nz") / root.removeprefix("stats-nz-").replace("-", "_") + suffix = alias.removeprefix(root) + expected = directory / f"rc_{suffix[-2:]}" if suffix else directory + assert SOURCE_PACKAGE_ALIASES[alias] == expected + + +def test_nz_bundle_sources_cover_every_nz_package_on_disk(): + """Every NZ source_package.yaml, at any depth, is a suite member.""" + root = source_package_root() + on_disk = { + manifest.parent.relative_to(root) + for prefix in NZ_BUNDLE_SOURCE_PREFIXES + for manifest in (root / prefix).rglob("source_package.yaml") + } + + assert len(on_disk) == 48 + assert on_disk == {SOURCE_PACKAGE_ALIASES[alias] for alias in NZ_BUNDLE_SOURCES} + + +def test_nz_and_uk_bundle_suites_are_disjoint(): + assert not set(NZ_BUNDLE_SOURCE_PREFIXES) & set(UK_BUNDLE_SOURCE_PREFIXES) + assert not set(NZ_BUNDLE_SOURCES) & set(UK_BUNDLE_SOURCES) + assert all( + SOURCE_PACKAGE_ALIASES[alias].parts[0] in NZ_BUNDLE_SOURCE_PREFIXES + for alias in NZ_BUNDLE_SOURCES + ) + + +@pytest.mark.parametrize( + "roster,missing,extra", + [ + pytest.param( + tuple( + alias + for alias in NZ_BUNDLE_SOURCES + if alias != "stats-nz-census-2023-households-by-region-rc-07" + ), + ["stats-nz-census-2023-households-by-region-rc-07"], + [], + id="drop-census-rc-member", + ), + pytest.param( + tuple( + alias + for alias in NZ_BUNDLE_SOURCES + if alias != "treasury-an24-01-fiscal-totals" + ), + ["treasury-an24-01-fiscal-totals"], + [], + id="drop-treasury-package", + ), + pytest.param( + tuple(sorted((*NZ_BUNDLE_SOURCES, "ons-mye-2025-uk"))), + [], + ["ons-mye-2025-uk"], + id="add-uk-package", + ), + ], +) +def test_nz_bundle_drift_guard_rejects_a_changed_roster( + monkeypatch, roster, missing, extra +): + monkeypatch.setattr(chronicle.bundle, "NZ_BUNDLE_SOURCES", roster) + + with pytest.raises(ValueError) as excinfo: + assert_nz_bundle_sources_match_aliases() + + assert str(excinfo.value) == ( + "NZ_BUNDLE_SOURCES drifted from NZ-prefixed SOURCE_PACKAGE_ALIASES: " + f"missing={missing}, extra={extra}" + ) + + +def test_nz_bundle_drift_guard_rejects_a_new_nz_alias(monkeypatch): + monkeypatch.setitem( + SOURCE_PACKAGE_ALIASES, "treasury-planted-package", Path("treasury/planted") + ) + + with pytest.raises(ValueError, match=r"missing=\['treasury-planted-package'\]"): + assert_nz_bundle_sources_match_aliases() + + +def test_nz_bundle_drift_guard_rejects_a_dropped_nz_alias(monkeypatch): + monkeypatch.delitem( + SOURCE_PACKAGE_ALIASES, "stats-nz-census-2023-family-type-rc-99" + ) + + with pytest.raises( + ValueError, match=r"extra=\['stats-nz-census-2023-family-type-rc-99'\]" + ): + assert_nz_bundle_sources_match_aliases() + + +def test_nz_bundle_members_use_year_independent_declarations(): + """``--year`` is inert for the NZ suite. + + A package that pins ``artifact_year`` reads that year's manifest file at + every build year, and one whose declarations render no ``{year}`` and use + no ``*_by_year`` mapping selects and labels the same cells at every year. + """ + for alias in NZ_BUNDLE_SOURCES: + package = load_source_package(alias) + assert package.artifact.artifact_year is not None, alias + assert not _declarations_depend_on_year(package), alias + + def test_discover_source_package_dirs_finds_real_packages(): """Discovery must return real package directories (prove it can find some).""" discovered = discover_source_package_dirs()