nte-exporter/tests/test_export_contract.py
goober 3c8834e98f feat: capture and export gear, cartridges and loadouts
Adds a third export alongside pull history and achievements: the cartridges,
modules, character levels and equipped console boards the game sends at login.

Shaped as added files plus a handful of one-line calls, so this repo can keep
merging upstream:

- decoder/gear.py: self-contained bit-level decoder over a list of datagrams.
- export/gear_export.py: the nte-gear-export envelope.
- live_capture/gear_collector.py: UDP flow accumulation, the mid-capture
  decode, the fallback scan, the console line and the export file.

Gear is reported during the capture rather than only at the stop. The burst
arrives in a rush and then stops, so "this flow went quiet" is the only
mid-stream marker available; a flow is retried only once it has grown, since
decoding per packet would be quadratic in flow size.

Flows are tried largest first and the first one that decodes wins - picking
the single biggest was wrong, history traffic can outweigh a short login
burst.

Verified against a live capture: 817 items (310 cartridges, 507 modules),
20 characters, 13 loadouts, every bucket matching the in-game counts exactly.
26 synthetic tests, no real capture used as a fixture; 149 total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:38:27 +03:00

262 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from tests.support import * # noqa: F401,F403
from nte_history_exporter import __version__
from nte_history_exporter.constants import EXPORTER_VERSION
from nte_history_exporter.decoder.achievement import AchievementRecord
from nte_history_exporter.export.json_export import build_achievement_export_json
from nte_history_exporter.live_capture.runner import _achievement_path
class ExportContractTests(unittest.TestCase):
def test_public_version_references_match(self):
self.assertEqual(__version__, "0.4.0")
self.assertEqual(EXPORTER_VERSION, __version__)
def test_sanitized_export_omits_raw_packet_fields(self):
annotated = annotate_groups(fixture_session().build_rows("permanent"))
export = build_export_json(annotated, [])
self.assertEqual(export["format"], "nte-history-export")
self.assertIn("exporter", export)
self.assertNotIn("user_uid", export)
self.assertNotIn("server_id", export)
self.assertNotIn("account_region", export)
self.assertNotIn("record_hex", export["records"][0])
self.assertNotIn("request_msg", export["records"][0])
self.assertNotIn("response_msg", export["records"][0])
def test_export_includes_user_uid_when_provided(self):
annotated = annotate_groups(fixture_session().build_rows("permanent"))
export = build_export_json(
annotated,
[],
capture_source="npcap",
user_uid="123456789",
)
self.assertEqual(list(export).index("user_uid"), list(export).index("records") - 1)
self.assertEqual(export["capture_source"], "npcap")
self.assertEqual(export["user_uid"], "123456789")
def test_export_includes_server_id_and_mapped_account_region(self):
annotated = annotate_groups(fixture_session().build_rows("permanent"))
export = build_export_json(annotated, [], server_id="23003")
self.assertEqual(export["server_id"], "23003")
self.assertEqual(export["account_region"], "EU")
def test_export_preserves_unknown_server_without_guessing_region(self):
annotated = annotate_groups(fixture_session().build_rows("permanent"))
export = build_export_json(annotated, [], server_id="23999")
self.assertEqual(export["server_id"], "23999")
self.assertNotIn("account_region", export)
def test_extracts_server_id_from_valid_initial_tcp_response(self):
payload = bytearray(204)
payload[0:4] = (200).to_bytes(4, "little")
payload[4:8] = (20).to_bytes(4, "little")
payload[96:100] = (23003).to_bytes(4, "little")
address = b"198.51.100.20"
payload[132:136] = len(address).to_bytes(4, "little")
payload[136 : 136 + len(address)] = address
self.assertEqual(extract_server_id(bytes(payload)), "23003")
def test_rejects_server_id_at_offset_without_valid_message_structure(self):
payload = bytearray(204)
payload[96:100] = (23003).to_bytes(4, "little")
self.assertIsNone(extract_server_id(bytes(payload)))
def test_live_session_detects_server_only_on_inbound_tcp(self):
payload = bytearray(204)
payload[0:4] = (200).to_bytes(4, "little")
payload[4:8] = (20).to_bytes(4, "little")
payload[96:100] = (23004).to_bytes(4, "little")
address = b"198.51.100.20"
payload[132:136] = len(address).to_bytes(4, "little")
payload[136 : 136 + len(address)] = address
session = LiveHistorySession("192.0.2.10")
session.process_packet(
UdpPacket(
1.0,
"198.51.100.20",
"192.0.2.10",
30000,
40000,
bytes(payload),
protocol="tcp",
)
)
self.assertEqual(session.server_id, "23004")
@patch("builtins.input", side_effect=["9", "3"])
def test_server_prompt_retries_then_returns_selected_server_id(self, _input):
self.assertEqual(console.prompt_server_id(), "23003")
@patch("builtins.input", return_value="")
def test_server_prompt_can_be_skipped(self, _input):
self.assertIsNone(console.prompt_server_id())
@patch("nte_history_exporter.console.wait_for_keypress")
def test_wait_for_close_waits_for_a_second_keypress(self, wait_for_keypress):
console.wait_for_close()
wait_for_keypress.assert_called_once_with()
def test_debug_csv_includes_exporter_version(self):
with TemporaryDirectory() as tmp:
path = Path(tmp) / "debug.csv"
write_csv(path, [{"uid": "abc123"}])
with path.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
self.assertEqual(rows[0]["exporter_version"], __version__)
self.assertEqual(rows[0]["uid"], "abc123")
def test_export_paths_include_user_uid_banner_and_timestamp(self):
_csv_path, json_path = export_paths("limited_character", "218216016349")
self.assertRegex(
json_path.name,
r"^218216016349_Limited_\d{8}_\d{6}(?:_\d+)?\.json$",
)
def test_achievement_path_includes_user_uid_and_timestamp(self):
path = _achievement_path("218216016349")
self.assertRegex(
path.name,
r"^218216016349_Achievements_\d{8}_\d{6}(?:_\d+)?\.json$",
)
def test_achievement_export_has_versioned_metadata_and_records(self):
export = build_achievement_export_json(
[
AchievementRecord("Battle_30", 10, 639_136_215_846_370_000),
AchievementRecord("Battle_25", 20, 0),
AchievementRecord("Playstation_017", 0, 0),
],
source="live_capture",
capture_source="npcap",
user_uid="218216016349",
server_id="23003",
)
self.assertEqual(export["format"], "nte-achievement-export")
self.assertEqual(export["format_version"], 1)
self.assertEqual(export["capture_source"], "npcap")
self.assertEqual(export["user_uid"], "218216016349")
self.assertEqual(export["account_region"], "EU")
self.assertEqual(
export["scan"],
{
"in_game": {
"total_achievements": 2,
"completed_achievements": 1,
"in_progress_achievements": 1,
},
"playstation": {
"total_achievements": 1,
"completed_achievements": 0,
"in_progress_achievements": 1,
},
},
)
battle_30, battle_25 = export["categories"]["battle"]
self.assertEqual(battle_30["name"], "Death Nova I")
self.assertEqual(battle_30["completed_at"], "2026-05-05 23:46:24")
self.assertEqual(battle_25["name"], "Devil Within II")
self.assertEqual(battle_25["description"], "Trigger Hexed ×50.")
self.assertEqual(battle_25["progress"], 20)
self.assertEqual(battle_25["target"], 50)
self.assertEqual(battle_25["quality"], "high")
self.assertEqual(
battle_25["rewards"], [{"item_id": "Annulith", "amount": 10}]
)
self.assertEqual(
export["categories"]["playstation"][0]["name"], "Speed Above All"
)
def test_unmapped_achievement_still_exports_capture_data(self):
export = build_achievement_export_json(
[AchievementRecord("FutureCategory_999", 7, 0)]
)
self.assertEqual(
export["categories"]["futurecategory"],
[
{
"id": "FutureCategory_999",
"platform": "in_game",
"status": "in_progress",
"progress": 7,
"completed": False,
"completed_at": None,
}
],
)
def test_extracts_user_uid_from_record_context(self):
payload = (
b"\x00" * 24
+ (218216016349).to_bytes(8, "little")
+ b"\x00\x00\x00\x00\x09\x00\x00\x00TagOthers\x00"
)
self.assertEqual(extract_user_uid(payload), "218216016349")
def test_extracts_user_uid_from_private_spawn_record_context(self):
payload = (
b"\x88\x00\x00\x00\x10\x00\x00\x00"
+ (218216016349).to_bytes(8, "little")
+ b"\x08\x00\x0c\x00\x07\x00\x08\x00\x08\x00\x00\x00"
+ b"\x00\x00\x00\x01\x08\x00\x00\x00\x04\x00\x04\x00"
+ b"\x04\x00\x00\x00\x16\x00\x00\x00PrivateSpawnInfoRecord\x00"
)
self.assertEqual(extract_user_uid(payload), "218216016349")
def test_does_not_extract_user_uid_from_wrong_record_offset(self):
payload = (
b"\x00" * 28
+ (218216016349).to_bytes(8, "little")
+ b"\x00\x00\x00\x00TagOthers\x00"
)
self.assertIsNone(extract_user_uid(payload))
def test_does_not_extract_old_eight_digit_false_positive_as_user_uid(self):
payload = (
b"WholeVehicleData\x00\x00\x00\x00\x00o<\x00\x00\x05\x00\x00\x00"
b"\x0b\x00\x00\x00Vehicle015\x00\x0b\x00\x00\x00buyvehicle\x00"
b"\x09\x00\x00\x0015363624\x00\x06\x00\x00\x00"
)
self.assertIsNone(extract_user_uid(payload))
def test_ipv4_parser_extracts_tcp_payload_for_user_uid_detection(self):
payload = (
(218216016349).to_bytes(8, "little")
+ b"\x00\x00\x00\x00\x09\x00\x00\x00TagOthers\x00"
)
tcp_header = bytearray(20)
tcp_header[0:2] = (40000).to_bytes(2, "big")
tcp_header[2:4] = (30000).to_bytes(2, "big")
tcp_header[12] = 5 << 4
total_len = 20 + len(tcp_header) + len(payload)
ip_header = bytearray(20)
ip_header[0] = 0x45
ip_header[2:4] = total_len.to_bytes(2, "big")
ip_header[9] = 6
ip_header[12:16] = bytes([192, 0, 2, 1])
ip_header[16:20] = bytes([198, 51, 100, 2])
packet = parse_ipv4_packet(bytes(ip_header) + bytes(tcp_header) + payload)
self.assertIsNotNone(packet)
self.assertEqual(packet.protocol, "tcp")
self.assertEqual(packet.payload, payload)