Files
coorl-lost-cities/tests/games/classic/test_rust_parity.py
T
coolguy b43bb5917f Rust 크레이트를 루트로 이동
맥락:
- GUI 이식 전에 Rust crate와 proto schema 위치를 Python package 내부에서 분리한다.
- Cargo 작업, IDE 인식, 빌드 산출물 관리를 루트 구조에 맞춘다.

변경:
- rust_core를 rust/lost-cities-core로 이동하고 proto/lost_cities.proto를 루트 proto 디렉터리로 옮겼다.
- Rust build.rs, Python Rust backend, Rust parity 테스트의 경로를 새 위치로 수정했다.
- Python package-data에서 Rust crate와 proto 항목을 제거하고 README/port notes를 갱신했다.

확인:
- uv sync --extra gui --reinstall-package coolrl-lost-cities
- uv run pytest tests/games/classic
- uv run lost-cities-classic
2026-05-06 19:50:32 +09:00

168 lines
5.5 KiB
Python

import json
from pathlib import Path
import random
import subprocess
import coolrl_lost_cities.games.classic as classic
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck
from coolrl_lost_cities.games.classic.backends.rust import RUST_CORE_DIR
LOST_CITIES_DIR = Path(classic.__file__).resolve().parent
FIXTURE_DIR = LOST_CITIES_DIR / "fixtures"
def _run_probe(*args: str) -> dict:
result = subprocess.run(
["cargo", "run", "--quiet", "--bin", "lost_cities_probe", "--", *args],
cwd=RUST_CORE_DIR,
check=True,
text=True,
capture_output=True,
)
return json.loads(result.stdout)
def _python_trace(path: Path) -> dict:
fixture = json.loads(path.read_text())
config = LostCitiesConfig(**fixture["config"])
state = GameState.new_game_from_deck(fixture["initial_deck"], config)
steps = []
for step in fixture["steps"]:
action = step["action"]
if action is not None:
state.apply_unified_action(action)
state.validate_invariants()
snapshot = state.to_snapshot()
steps.append(
{
"action": action,
"phase": state.phase,
"current_player": state.current_player,
"turn_count": state.turn_count,
"terminal": state.terminal,
"pending_discarded_color": state.pending_discarded_color,
"score_diff_player0": state.score_diff(0),
"legal_mask": state.unified_legal_mask(),
"deck": snapshot["deck"],
"hands": snapshot["hands"],
"expeditions": snapshot["expeditions"],
"discards": snapshot["discards"],
}
)
return {"config": config.to_snapshot(), "steps": steps}
def test_rust_default_config_matches_python_default() -> None:
assert _run_probe("defaults") == LostCitiesConfig().to_snapshot()
def test_rust_fixture_trace_matches_python_core() -> None:
fixture_path = FIXTURE_DIR / "canonical_small.json"
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
def test_rust_randomized_fixture_traces_match_python_core(tmp_path: Path) -> None:
config = LostCitiesConfig(
n_colors=3,
n_ranks=5,
min_rank=2,
n_handshakes=1,
hand_size=5,
)
for seed in range(12):
deck = build_deck(config)
rng = random.Random(seed)
rng.shuffle(deck)
state = GameState.new_game_from_deck(deck, config)
steps = [{"action": None}]
for _ in range(64):
if state.terminal:
break
legal = [
index
for index, is_legal in enumerate(state.unified_legal_mask())
if is_legal
]
action = rng.choice(legal)
state.apply_unified_action(action)
steps.append({"action": action})
fixture_path = tmp_path / f"parity_{seed}.json"
fixture_path.write_text(
json.dumps(
{
"config": config.to_snapshot(),
"initial_deck": [card.to_snapshot() for card in deck],
"steps": steps,
}
)
)
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
def test_rust_engine_contract_is_checked_from_python() -> None:
result = _run_probe("engine")
assert result["duplicate_kind"] == "AlreadyExists"
assert result["missing_config_kind"] == "InvalidArgument"
assert result["empty_session_kind"] == "InvalidArgument"
assert result["unknown_session_kind"] == "NotFound"
assert result["invalid_observer_kind"] == "InvalidArgument"
assert result["invalid_observer_state_unchanged"] is True
assert result["invalid_observer_action_still_applies"] is True
assert result["phase_flow"][:2] == [
{
"state_version": 0,
"current_player": 0,
"observer_player": 0,
"phase": "card",
"terminal": False,
},
{
"state_version": 1,
"current_player": 0,
"observer_player": 0,
"phase": "draw",
"terminal": False,
},
]
assert result["phase_flow"][2]["state_version"] == 2
assert result["stale_kind"] == "FailedPrecondition"
assert result["end_session_counts"] == [1, 0]
assert result["off_turn_legal_empty"] is True
assert result["full_session_terminal_reward_matches"] is True
assert result["full_session_final_scores_match"] is True
assert result["terminal_reject_kind"] == "FailedPrecondition"
assert result["deterministic_match"] is True
def test_rust_grpc_contract_is_checked_from_python() -> None:
result = _run_probe("grpc")
assert result == {
"round_trip_phase": "card",
"opponent_legal_empty": True,
"stale_code": "FailedPrecondition",
"invalid_observer_code": "InvalidArgument",
"invalid_observer_state_unchanged": True,
"ended_session_code": "NotFound",
}
def test_rust_core_has_no_native_tests_left() -> None:
rust_files = [
path
for path in (RUST_CORE_DIR / "src").rglob("*.rs")
if "target" not in path.parts
]
for path in rust_files:
text = path.read_text()
assert "#[test]" not in text
assert "#[tokio::test" not in text
tests_dir = RUST_CORE_DIR / "tests"
assert not list(tests_dir.glob("*.rs"))