Files
ARR-2.0-0918/tests/test_arr_pm_scope.py
T

229 lines
13 KiB
Python

"""Offline PM scope checks for both daily inputs and the frozen legacy replay."""
from __future__ import annotations
import argparse
import contextlib
import copy
import io
import json
from pathlib import Path
import tempfile
import unittest
import xml.etree.ElementTree as ET
from tests.test_arr_opera_daily_ingest import (
core, reservation, run_processor, validator, xml_document,
)
from tests.test_arr_data_review import source_document
def pm_xml_row(sequence, *, label="PM", room=None, status=None, incomplete=False,
rate_amount="900"):
node = ET.fromstring(reservation(sequence, room=room, rate_amount=rate_amount))
node.find("ROOM_CATEGORY_LABEL").text = label
if status is not None:
ET.SubElement(node, "SHORT_RESV_STATUS").text = status
if incomplete:
for field, value in (("RATE_CODE", ""), ("DISP_ROOM_NO", ""),
("COMPANY_NAME", ""), ("FULL_NAME", ""),
("ADULTS", "-1"), ("NO_OF_ROOMS", "0"),
("EFFECTIVE_RATE_AMOUNT", "INVALID"),
("TRUNC_END", "2026-07-26")):
node.find(field).text = value
return ET.tostring(node, encoding="unicode")
class PMScopeTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="arr-pm-scope-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
def classify_xml(self, *rows, apply_scope=True):
path = self.root / "source.xml"
path.write_text(xml_document(*rows))
business_date, nodes = core.read_xml(path)
return core.classify_input_records(nodes, business_date, apply_scope=apply_scope)
def run_data(self, document, name="output"):
path = self.root / (name + ".json")
path.write_text(json.dumps(document))
output = self.root / name
args = argparse.Namespace(xml=None, data_json=str(path), output_dir=str(output),
result_json=str(output / "result.json"),
structured_result_json=str(output / "structured-result.json"))
before = path.read_bytes()
with contextlib.redirect_stdout(io.StringIO()):
exit_code = core.process(args)
self.assertEqual(path.read_bytes(), before)
return exit_code, json.loads((output / "structured-result.json").read_text())
def test_xml_pm_precedes_rate_and_required_fields_but_cancelled_takes_precedence(self):
rows, eligible, removed_rate, duplicates, errors = self.classify_xml(
pm_xml_row(1, label=" pm ", incomplete=True),
pm_xml_row(2, status="CXL", incomplete=True), reservation(3))
self.assertEqual(errors, [])
self.assertEqual((removed_rate, duplicates), (0, 0))
self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [3])
self.assertEqual([row["_OUTCOME"] for row in rows],
["excluded_pm", "excluded_cancelled", "pending"])
self.assertEqual(rows[0]["_DECISION_CODES"], ["ROOM_TYPE_PM_EXCLUDED"])
self.assertEqual(rows[1]["_DECISION_CODES"], ["RESERVATION_CANCELLED_EXCLUDED"])
self.assertEqual(rows[0]["ROOM_CATEGORY_LABEL"], "pm")
self.assertEqual(rows[0]["CONFIRMATION_NO"], "SYNTHETIC-CONF-1")
self.assertEqual(rows[0]["_SOURCE_LOCATION"], "reservation[1]")
def test_only_exact_explicit_pm_room_type_is_excluded(self):
for label in ("PM", "pm", " pM ", "\tPm\n"):
with self.subTest(label=label):
rows, _, _, _, errors = self.classify_xml(
pm_xml_row(1, label=label, incomplete=True), reservation(2))
self.assertEqual(errors, [])
self.assertTrue(core.is_pm_record(rows[0]))
for label in (None, "", "PM1", "PM ROOM", "P M", "SU6"):
with self.subTest(label=label):
rows, _, _, _, errors = self.classify_xml(
pm_xml_row(1, label=label, room="PM", rate_amount="0"), reservation(2))
self.assertEqual(errors, [])
self.assertFalse(core.is_pm_record(rows[0]))
self.assertEqual(rows[0]["_OUTCOME"], "pending")
def test_pm_does_not_consume_a_real_room_duplicate_key_or_create_price_issue(self):
rows, eligible, _, duplicates, errors = self.classify_xml(
pm_xml_row(1, room="SAME-ROOM", rate_amount="0"),
reservation(2, room="SAME-ROOM"), reservation(3, room="SAME-ROOM"))
self.assertEqual(errors, [])
self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [2])
self.assertEqual(duplicates, 1)
self.assertEqual([row["_OUTCOME"] for row in rows],
["excluded_pm", "pending", "duplicate"])
self.assertEqual(rows[2]["_DUPLICATE_OF_SOURCE_SEQUENCE"], 2)
prices = core.load_price_map(core.PRICE_REFERENCE)
self.assertEqual(core.apply_prices_classified(eligible, prices), [])
self.assertEqual(core.review_issues(eligible, prices), [])
def test_xml_success_and_independent_replay_count_pm_and_cancelled_separately(self):
code, source, output, _, payload = run_processor(xml_document(
pm_xml_row(1, label=" pm ", incomplete=True),
pm_xml_row(2, status="CXL", incomplete=True), reservation(3)), self.root)
self.assertEqual(code, 0, payload["errors"])
self.assertEqual(payload["processor_version"], "4.4.0")
self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1)
self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1)
self.assertEqual(sum(payload["outcome_counts"].values()), 3)
self.assertEqual((payload["source_rows"], payload["output_rows"]), (3, 1))
self.assertEqual(payload["artifacts"]["source_xml"]["sha256"], core.sha256_file(source))
self.assertEqual(payload["records"][0]["room_category_label"], "pm")
for field in ("real_price", "total_price", "kb_amount", "channel_key", "pricing_method"):
self.assertIsNone(payload["records"][0][field])
# A structurally valid cancellation reclassification cannot replace PM evidence.
forged = copy.deepcopy(payload)
forged["records"][0]["outcome"] = "excluded_cancelled"
forged["records"][0]["decision_codes"] = ["RESERVATION_CANCELLED_EXCLUDED"]
forged["outcome_counts"]["excluded_cancelled"] += 1
forged["outcome_counts"]["excluded_pm"] -= 1
core.validate_structured_completeness(forged)
structured = output / "structured-result.json"
structured.write_text(json.dumps(forged))
errors = validator.validate(argparse.Namespace(xml=str(source),
daily=str(next(output.glob("*.xlsx"))), result_json=str(output / "result.json"),
structured_result_json=str(structured), price_reference=str(core.PRICE_REFERENCE)))
self.assertIn("OUTPUT_STRUCTURED_OUTCOME_MISMATCH", {error.code for error in errors})
self.assertIn("OUTPUT_STRUCTURED_RECORD_MISMATCH", {error.code for error in errors})
def test_pm_self_validation_and_schemas_require_explicit_type_reason_and_null_facts(self):
code, _, _, _, payload = run_processor(xml_document(
pm_xml_row(1, label="pM"), reservation(2)), self.root)
self.assertEqual(code, 0, payload["errors"])
for field, value in (("room_category_label", "SU6"),
("decision_codes", ["RATE_CODE_NOT_WHITELISTED"]),
("real_price", 0), ("total_price", 0), ("kb_amount", 0),
("channel_key", "QBD"), ("pricing_method", "manual_review")):
with self.subTest(field=field):
forged = copy.deepcopy(payload)
forged["records"][0][field] = value
with self.assertRaises(core.ProcessingFailure):
core.validate_structured_completeness(forged)
forged = copy.deepcopy(payload)
del forged["outcome_counts"]["excluded_pm"]
with self.assertRaises(core.ProcessingFailure):
core.validate_structured_completeness(forged)
for name in ("structured-result.schema.json", "data-structured-result.schema.json"):
schema = json.loads((core.SKILL_ROOT / "references" / name).read_text())
self.assertEqual(schema["properties"]["processor_version"]["const"], "4.4.0")
self.assertIn("excluded_pm", schema["properties"]["outcome_counts"]["required"])
rule = next(item for item in schema["$defs"]["record"]["allOf"]
if item["if"]["properties"]["outcome"].get("const") == "excluded_pm")
self.assertEqual(rule["then"]["properties"]["decision_codes"]["const"],
["ROOM_TYPE_PM_EXCLUDED"])
self.assertEqual(rule["then"]["properties"]["real_price"]["type"], "null")
def test_xml_review_and_failure_keep_pm_outside_pricing_and_other_validation(self):
code, _, _, _, payload = run_processor(xml_document(
pm_xml_row(1, incomplete=True), reservation(2, rate_amount="8765")),
self.root / "review")
self.assertEqual(code, 0, payload["errors"])
self.assertEqual(payload["status"], "review_required")
self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1)
self.assertEqual(payload["review_required_rows"], 1)
self.assertEqual(payload["review_issues"][0]["affected_records"], 1)
invalid = ET.fromstring(reservation(2))
invalid.find("DISP_ROOM_NO").text = ""
code, _, _, _, payload = run_processor(xml_document(
pm_xml_row(1, incomplete=True), ET.tostring(invalid, encoding="unicode")),
self.root / "failure")
self.assertNotEqual(code, 0)
self.assertEqual(payload["status"], "failed")
self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1)
self.assertEqual(payload["outcome_counts"]["validation_failed"], 1)
def test_data_success_review_and_failure_keep_pm_outside_business_rules(self):
baseline = source_document(2)
for field in baseline["records"][0]["fields"]:
baseline["records"][0]["fields"][field] = {"state": "missing", "value": None}
baseline["records"][0]["fields"]["ROOM_CATEGORY_LABEL"] = {"state": "available", "value": " pm "}
baseline["input_complete"] = False
baseline["status"] = "collected_with_gaps"
code, payload = self.run_data(baseline)
self.assertEqual(code, 0, payload["errors"])
self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1)
self.assertEqual(payload["output_rows"], 1)
self.assertEqual(payload["removed_by_rate_code"], 0)
self.assertEqual(payload["records"][0]["room_category_label"], "pm")
review = copy.deepcopy(baseline)
review["records"][1]["fields"]["EFFECTIVE_RATE_AMOUNT"]["value"] = "8765"
code, payload = self.run_data(review, "review")
self.assertEqual(code, 0, payload["errors"])
self.assertEqual(payload["status"], "review_required")
self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1)
self.assertEqual(payload["review_required_rows"], 1)
failed = copy.deepcopy(baseline)
failed["records"][1]["fields"]["DISP_ROOM_NO"] = {"state": "missing", "value": None}
code, payload = self.run_data(failed, "failure")
self.assertNotEqual(code, 0)
self.assertEqual(payload["status"], "failed")
self.assertEqual(payload["outcome_counts"]["excluded_pm"], 1)
self.assertEqual(payload["outcome_counts"]["validation_failed"], 1)
def test_frozen_legacy_v3_replay_keeps_pm_and_never_projects_new_outcomes(self):
code, _, _, _, payload = run_processor(xml_document(
pm_xml_row(1), reservation(2)), self.root, legacy_v3_output=True)
self.assertEqual(code, 0, payload["errors"])
self.assertEqual(payload["result_schema_version"], "3.0")
self.assertEqual(payload["output_rows"], 2)
self.assertEqual(set(payload["outcome_counts"]), core.LEGACY_DIRECT_FINAL_OUTCOMES)
self.assertEqual([row["outcome"] for row in payload["records"]], ["retained", "retained"])
rows, _, _, _, _ = self.classify_xml(pm_xml_row(1), reservation(2))
for function in (core.build_legacy_direct_structured_result,
core.build_legacy_direct_failed_structured_result):
with self.subTest(function=function.__name__), self.assertRaises(core.ProcessingFailure):
if function is core.build_legacy_direct_structured_result:
function(None, rows[:1], [], self.root / "source.xml", None, None)
else:
function(None, rows[:1], self.root / "source.xml", None, None, [])
if __name__ == "__main__":
unittest.main()