182 lines
8.1 KiB
Python
182 lines
8.1 KiB
Python
"""Deterministic and mutation tests for the Rate/Room directory foundation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
GENERATOR_PATH = REPOSITORY_ROOT / "scripts" / "generate_rate_room_directory.py"
|
|
DIRECTORY_PATH = (
|
|
REPOSITORY_ROOT
|
|
/ "server"
|
|
/ "src"
|
|
/ "main"
|
|
/ "resources"
|
|
/ "reservation-booking"
|
|
/ "catalog"
|
|
/ "rate-room-directory-v20260810.json"
|
|
)
|
|
MANIFEST_PATH = DIRECTORY_PATH.with_name("rate-room-directory-v20260810.manifest.json")
|
|
GOLDEN_PATH = (
|
|
REPOSITORY_ROOT
|
|
/ "server"
|
|
/ "src"
|
|
/ "test"
|
|
/ "resources"
|
|
/ "reservation-booking"
|
|
/ "catalog"
|
|
/ "rate-room-directory-v20260810-golden.json"
|
|
)
|
|
|
|
|
|
def load_generator():
|
|
spec = importlib.util.spec_from_file_location("rate_room_directory_generator", GENERATOR_PATH)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError("Rate/Room generator module cannot be loaded")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class RateRoomDirectoryGeneratorTest(unittest.TestCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.generator = load_generator()
|
|
cls.directory_bytes = DIRECTORY_PATH.read_bytes()
|
|
cls.directory = json.loads(cls.directory_bytes)
|
|
cls.manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
cls.golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
|
|
|
def test_directory_manifest_and_independent_golden_match(self) -> None:
|
|
self.generator.validate_directory(self.directory)
|
|
self.generator.validate_manifest(self.directory_bytes, self.manifest)
|
|
|
|
self.assertEqual(self.golden["directory_version"], self.directory["directory_version"])
|
|
self.assertEqual(self.golden["source_excel_sha256"], self.directory["source"]["sha256"])
|
|
self.assertEqual(self.golden["source_word_sha256"], self.directory["source"]["word"]["sha256"])
|
|
self.assertEqual(self.golden["semantic_fingerprints"], self.generator.semantic_fingerprints(self.directory))
|
|
self.assertEqual(self.golden["semantic_fingerprints"], self.manifest["semantic_fingerprints"])
|
|
self.assertEqual(self.golden["room_types"], [item["code"] for item in self.directory["room_types"]])
|
|
self.assertEqual(
|
|
self.golden["rate_codes"],
|
|
[
|
|
{
|
|
"code": item["code"],
|
|
"breakfast_included": item["breakfast_included"],
|
|
"breakfast_restaurant_code": item["breakfast_restaurant_code"],
|
|
}
|
|
for item in self.directory["rate_codes"]
|
|
],
|
|
)
|
|
|
|
def test_golden_word_corrections_are_bound_to_exact_source_rows(self) -> None:
|
|
rows = {item["source_row"]: item for item in self.directory["mapping_rows"]}
|
|
projected = []
|
|
for expected in self.golden["word_corrections"]:
|
|
row = rows[expected["source_row"]]
|
|
projected.append({key: row[key] for key in expected})
|
|
self.assertEqual(self.golden["word_corrections"], projected)
|
|
|
|
def test_room_type_and_rate_code_projections_are_independently_fingerprinted(self) -> None:
|
|
fingerprints = self.generator.semantic_fingerprints(self.directory)
|
|
self.assertEqual(
|
|
"f1bfce959809ac47b65d7a0291f036e43ef9f99b80a774bbd6af9603e9d06eac",
|
|
fingerprints["room_type_mapping_projection"],
|
|
)
|
|
self.assertEqual(
|
|
"2810b5c812858da4be694dfa7259b8cdd1c98c1e8133f368e4a4ad9fe5e02885",
|
|
fingerprints["rate_code_mapping_projection"],
|
|
)
|
|
self.assertEqual(50, len(self.generator.room_type_mapping_projection(self.directory)))
|
|
self.assertEqual(165, len(self.generator.rate_code_mapping_projection(self.directory)))
|
|
|
|
roomtype_only = copy.deepcopy(self.directory)
|
|
roomtype_only["mapping_rows"][0]["room_type"] = "RM4"
|
|
roomtype_only_fingerprints = self.generator.semantic_fingerprints(roomtype_only)
|
|
self.assertNotEqual(
|
|
fingerprints["room_type_mapping_projection"],
|
|
roomtype_only_fingerprints["room_type_mapping_projection"],
|
|
)
|
|
self.assertEqual(
|
|
fingerprints["rate_code_mapping_projection"],
|
|
roomtype_only_fingerprints["rate_code_mapping_projection"],
|
|
)
|
|
|
|
def test_semantic_duplicate_ignores_source_provenance(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
duplicate = copy.deepcopy(mutated["mapping_rows"][0])
|
|
duplicate["source_row"] = mutated["mapping_rows"][1]["source_row"]
|
|
mutated["mapping_rows"][1] = duplicate
|
|
with self.assertRaisesRegex(ValueError, "Duplicate semantic source mappings"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_duplicate_source_row_is_rejected(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["mapping_rows"][1]["source_row"] = mutated["mapping_rows"][0]["source_row"]
|
|
with self.assertRaisesRegex(ValueError, "Duplicate source_row"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_unknown_room_type_reference_is_rejected(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["mapping_rows"][0]["room_type"] = "UNKNOWN"
|
|
with self.assertRaisesRegex(ValueError, "references unknown Roomtype"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_breakfast_cross_reference_is_rejected(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["mapping_rows"][0]["breakfast_restaurant_code"] = "LEELA"
|
|
with self.assertRaisesRegex(ValueError, "breakfast state differs"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_bad_alias_target_is_rejected(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["legacy_rate_code_aliases"][0]["base_rate_code"] = "UNKNOWN"
|
|
with self.assertRaisesRegex(ValueError, "points outside"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_candidate_scenario_drift_is_rejected(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["candidate_scenarios"][0]["candidate_room_types"] = ["RM2"]
|
|
with self.assertRaisesRegex(ValueError, "Candidate scenarios are stale"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_missing_price_default_must_be_canonical_grpa1(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["policies"]["rate_code"]["missing_price_default_base_rate_code"] = "GRPA1-900"
|
|
with self.assertRaisesRegex(ValueError, "canonical base Rate Code"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_company_specific_segment_override_is_rejected(self) -> None:
|
|
mutated = copy.deepcopy(self.directory)
|
|
mutated["policies"]["segment"]["fixed_by_company"]["Q.B.D"] = "Group"
|
|
with self.assertRaisesRegex(ValueError, "Unexpected segment policy"):
|
|
self.generator.validate_directory(mutated)
|
|
|
|
def test_manifest_hash_mismatch_is_rejected(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "Manifest directory SHA-256 mismatch"):
|
|
self.generator.validate_manifest(self.directory_bytes + b"\n", self.manifest)
|
|
|
|
def test_check_mode_never_creates_or_rewrites_files(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="rate-room-generator-test-") as temporary_directory:
|
|
target = Path(temporary_directory) / "artifact.txt"
|
|
target.write_text("stable", encoding="utf-8")
|
|
before = target.stat().st_mtime_ns
|
|
self.generator.write_or_check(target, "stable", True)
|
|
self.assertEqual(before, target.stat().st_mtime_ns)
|
|
|
|
missing = Path(temporary_directory) / "missing.txt"
|
|
with self.assertRaisesRegex(ValueError, "stale or missing"):
|
|
self.generator.write_or_check(missing, "expected", True)
|
|
self.assertFalse(missing.exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|