from __future__ import annotations import json import unittest from copy import deepcopy from dataclasses import replace from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch from nte_history_exporter.mapping_update import ( MappingUpdateError, apply_update, build_mapping_update, load_assets, load_current_mappings, write_update, ) from tests.support import FIXTURES SAMPLE_ASSETS = FIXTURES / "nte_assets" def sample_current(): return { "arcs.json": { "fork_alpha": {"name": "Stale Alpha Name", "rank": "B"}, "fork_retired": {"name": "Retired Arc", "rank": "A"}, }, "characters.json": { "1003": {"name": "Stale Character Name", "rank": "S"}, "1001": {"name": "Retired Character", "rank": "A"}, }, "items.json": { "DiceNormal": {"type": "item", "name": "Fabricated Dice", "rank": "S"}, "OldTicket": {"type": "item", "name": "Retired Ticket", "rank": "B"}, }, } def build_sample_update(): current = sample_current() return current, build_mapping_update(current, load_assets(assets_root=SAMPLE_ASSETS)) class MappingUpdateTests(unittest.TestCase): def test_mapping_update_is_an_authoritative_snapshot(self): current, result = build_sample_update() self.assertEqual(result.mappings["characters.json"]["1003"], {"name": "Old Character Renamed", "rank": "S"}) self.assertEqual(result.mappings["characters.json"]["1099"], {"name": "New Character", "rank": "A"}) self.assertNotIn("1001", result.mappings["characters.json"]) self.assertNotIn("fork_retired", result.mappings["arcs.json"]) self.assertNotIn("OldTicket", result.mappings["items.json"]) self.assertNotEqual(result.mappings["characters.json"]["1003"], current["characters.json"]["1003"]) self.assertEqual(result.report["changes"], {"additions": 4, "updates": 2, "deletions": 3}) self.assertIs(result.report["safety"]["authoritative_snapshot"], True) self.assertIs(result.report["safety"]["deletions_allowed"], True) self.assertIs(result.report["safety"]["uid_inputs_touched"], False) self.assertIs(result.report["safety"]["pool_mappings_touched"], False) def test_items_use_illustrations_as_filter_and_asset_tables_as_authority(self): _current, result = build_sample_update() self.assertEqual( result.mappings["items.json"]["Fashion_Glide_2000"], {"type": "cosmetic", "name": "Inventory Glider", "rank": "A"}, ) self.assertIn("DiceNormal", result.mappings["items.json"]) self.assertNotIn("DIceNormal", result.mappings["items.json"]) self.assertNotIn("UnusedItem", result.mappings["items.json"]) self.assertFalse(any(key.startswith("Characterawaken_") for key in result.mappings["items.json"])) def test_future_mystery_box_pool_is_discovered_without_hard_coded_event_id(self): assets = load_assets(assets_root=SAMPLE_ASSETS) tables = deepcopy(assets.tables) tables["mystery_box_pools"]["MangHe_wowzers"] = { "GiftList": [ {"ItemID": "WowTicket"}, {"ItemID": "Frame_Wowzers"}, {"ItemID": "VehicleWow"}, ] } tables["inventory"]["WowTicket"] = { "ItemName": {"TableId": "/Game/Text/ST_Item.ST_Item", "Key": "wow_ticket"}, "ItemQuality": "EItemQuality::ITEM_QUALITY_PURPLE", } tables["inventory"]["Frame_Wowzers"] = { "ItemName": {"TableId": "/Game/Text/ST_Item.ST_Item", "Key": "wow_frame"}, "ItemQuality": "EItemQuality::ITEM_QUALITY_BLUE", } tables["vehicle_inventory"]["vehiclewow"] = { "ItemName": { "TableId": "/Game/Text/ST_VehicleData.ST_VehicleData", "Key": "vehicle_wow", }, "ItemQuality": "EItemQuality::ITEM_QUALITY_ORANGE", } tables["localization"]["ST_Item"].update( {"wow_ticket": "Wowzers Ticket", "wow_frame": "Wowzers Frame"} ) tables["localization"]["ST_VehicleData"] = {"vehicle_wow": "Wowmobile"} result = build_mapping_update(sample_current(), replace(assets, tables=tables)) self.assertEqual( result.mappings["items.json"]["WowTicket"], {"type": "item", "name": "Wowzers Ticket", "rank": "A"}, ) self.assertEqual( result.mappings["items.json"]["Frame_Wowzers"], {"type": "cosmetic", "name": "Wowzers Frame", "rank": "B"}, ) self.assertEqual( result.mappings["items.json"]["vehiclewow"], {"type": "item", "name": "Wowmobile", "rank": "S"}, ) def test_embedded_localized_strings_are_ignored(self): _current, result = build_sample_update() self.assertEqual(result.mappings["characters.json"]["1003"]["name"], "Old Character Renamed") self.assertNotEqual(result.mappings["characters.json"]["1003"]["name"], "Wrong Embedded Character") def test_mapping_update_rejects_missing_english_translation(self): assets = load_assets(assets_root=SAMPLE_ASSETS) tables = deepcopy(assets.tables) del tables["localization"]["ST_Player"]["character_1003"] with self.assertRaisesRegex(MappingUpdateError, "English localization is missing"): build_mapping_update(sample_current(), replace(assets, tables=tables)) def test_mapping_update_rejects_ambiguous_translation_without_namespace_match(self): assets = load_assets(assets_root=SAMPLE_ASSETS) tables = deepcopy(assets.tables) tables["inventory"]["Fashion_glide_2000"]["ItemName"]["TableId"] = "/Game/Text/Unknown.Unknown" tables["localization"]["OtherNamespace"] = {"glider_2000": "Other Glider"} with self.assertRaisesRegex(MappingUpdateError, "is ambiguous"): build_mapping_update(sample_current(), replace(assets, tables=tables)) def test_local_asset_source_has_reproducible_provenance(self): first = load_assets(assets_root=SAMPLE_ASSETS) second = load_assets(assets_root=SAMPLE_ASSETS) self.assertEqual(first.sha256, second.sha256) self.assertEqual(first.file_sha256, second.file_sha256) self.assertEqual(first.source, str(SAMPLE_ASSETS.resolve())) self.assertIsNone(first.source_ref) def test_mapping_update_writes_deterministic_review_artifacts(self): _current, result = build_sample_update() with TemporaryDirectory() as first_tmp, TemporaryDirectory() as second_tmp: first = Path(first_tmp) second = Path(second_tmp) write_update(result, first) write_update(result, second) for filename in (*result.mappings, "mapping-update-report.json"): self.assertEqual((first / filename).read_bytes(), (second / filename).read_bytes()) report = json.loads((first / "mapping-update-report.json").read_text(encoding="utf-8")) self.assertEqual(report["changes"]["deletions"], 3) def test_mapping_update_does_not_mutate_inputs(self): current = sample_current() original = deepcopy(current) build_mapping_update(current, load_assets(assets_root=SAMPLE_ASSETS)) self.assertEqual(current, original) def test_mapping_update_apply_touches_only_reward_mapping_files(self): _current, result = build_sample_update() with TemporaryDirectory() as tmp: directory = Path(tmp) for filename, mapping in sample_current().items(): (directory / filename).write_text(json.dumps(mapping), encoding="utf-8") protected = directory / "permanent_board.json" protected.write_text('{"protected": true}\n', encoding="utf-8") apply_update(result, directory) self.assertEqual(load_current_mappings(directory), result.mappings) self.assertEqual(protected.read_text(encoding="utf-8"), '{"protected": true}\n') self.assertFalse((directory / "mapping-update-report.json").exists()) def test_mapping_update_apply_rolls_back_partial_write(self): _current, result = build_sample_update() with TemporaryDirectory() as tmp: directory = Path(tmp) originals = {} for filename, mapping in sample_current().items(): destination = directory / filename destination.write_text(json.dumps(mapping), encoding="utf-8") originals[filename] = destination.read_bytes() from nte_history_exporter import mapping_update real_write = mapping_update._atomic_write calls = 0 def fail_second_write(path, text): nonlocal calls calls += 1 if calls == 2: raise OSError("synthetic write failure") return real_write(path, text) with ( patch("nte_history_exporter.mapping_update._atomic_write", side_effect=fail_second_write), self.assertRaisesRegex(OSError, "synthetic write failure"), ): apply_update(result, directory) for filename, original in originals.items(): self.assertEqual((directory / filename).read_bytes(), original) def test_mapping_update_rejects_unresolved_illustrated_rewards(self): assets = load_assets(assets_root=SAMPLE_ASSETS) tables = deepcopy(assets.tables) tables["illustrations"]["MissingReward"] = {} with self.assertRaisesRegex(MappingUpdateError, "MissingReward"): build_mapping_update(sample_current(), replace(assets, tables=tables)) def test_load_assets_rejects_missing_tables(self): with TemporaryDirectory() as tmp, self.assertRaisesRegex(MappingUpdateError, "cannot read"): load_assets(assets_root=Path(tmp))