Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions bridge/tests/test_tutorial_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Tests for tutorial / campaign flow state machine + scenario JSONs.

PRD acceptance #9: Tutorial completes <10 min, transitions to long-running campaign.
PRD acceptance #20: Tutorial→campaign handoff works in same session.
"""

import json
from pathlib import Path

import pytest

from bridge.tutorial import (
CAMPAIGN_ACTIVE,
DEFAULT_TUTORIAL_FILENAME,
NOT_STARTED,
TUTORIAL_ACTIVE,
TUTORIAL_COMPLETE,
TutorialFlow,
TutorialFlowError,
TutorialStore,
scenario_to_load,
)


REPO_ROOT = Path(__file__).resolve().parents[2]
SCENARIO_ROOT = REPO_ROOT / "mod" / "ClaudeKingdoms" / "scenarios"


# ────────────────────────────────────────────────────────────────────────
# State machine
# ────────────────────────────────────────────────────────────────────────

def test_initial_state_is_not_started():
flow = TutorialFlow()
assert flow.state == NOT_STARTED
assert flow.turns_in_tutorial == 0
assert flow.is_ready_for_campaign() is False


def test_full_happy_path_transitions():
flow = TutorialFlow()
flow.transition(TUTORIAL_ACTIVE)
assert flow.started_at is not None
flow.record_tutorial_turn()
assert flow.turns_in_tutorial == 1
flow.transition(TUTORIAL_COMPLETE)
assert flow.completed_at is not None
assert flow.is_ready_for_campaign() is True
flow.transition(CAMPAIGN_ACTIVE)
assert flow.state == CAMPAIGN_ACTIVE


def test_skip_tutorial_path():
flow = TutorialFlow()
flow.transition(CAMPAIGN_ACTIVE)
assert flow.state == CAMPAIGN_ACTIVE
assert flow.completed_at is not None # set even on skip


def test_illegal_transition_raises():
flow = TutorialFlow()
with pytest.raises(TutorialFlowError):
flow.transition(TUTORIAL_COMPLETE) # not_started → complete is illegal


def test_record_turn_only_in_tutorial_active():
flow = TutorialFlow()
flow.record_tutorial_turn() # NOT_STARTED — should be a no-op
assert flow.turns_in_tutorial == 0
flow.transition(TUTORIAL_ACTIVE)
flow.record_tutorial_turn()
flow.record_tutorial_turn()
assert flow.turns_in_tutorial == 2
flow.transition(TUTORIAL_COMPLETE)
flow.record_tutorial_turn() # past the tutorial — no-op
assert flow.turns_in_tutorial == 2


def test_replay_tutorial_after_complete():
flow = TutorialFlow()
flow.transition(TUTORIAL_ACTIVE)
flow.transition(TUTORIAL_COMPLETE)
flow.transition(TUTORIAL_ACTIVE) # replay allowed
assert flow.state == TUTORIAL_ACTIVE


def test_campaign_is_terminal_no_back_to_tutorial():
flow = TutorialFlow()
flow.transition(CAMPAIGN_ACTIVE)
with pytest.raises(TutorialFlowError):
flow.transition(TUTORIAL_ACTIVE)


# ────────────────────────────────────────────────────────────────────────
# Persistence
# ────────────────────────────────────────────────────────────────────────

def test_tutorial_store_round_trip(tmp_path):
store = TutorialStore(tmp_path / "state")
flow = TutorialFlow()
flow.transition(TUTORIAL_ACTIVE)
flow.record_tutorial_turn()
store.save(flow)

reloaded = TutorialStore(tmp_path / "state").load()
assert reloaded.state == TUTORIAL_ACTIVE
assert reloaded.turns_in_tutorial == 1
assert reloaded.started_at is not None


def test_tutorial_store_default_filename_when_dir(tmp_path):
store = TutorialStore(tmp_path / "state")
assert store.path.name == DEFAULT_TUTORIAL_FILENAME


def test_tutorial_store_explicit_path(tmp_path):
custom = tmp_path / "custom.json"
store = TutorialStore(custom)
assert store.path == custom


def test_tutorial_store_load_missing_returns_default(tmp_path):
store = TutorialStore(tmp_path / "state")
flow = store.load()
assert flow.state == NOT_STARTED


def test_tutorial_store_load_corrupt_returns_default(tmp_path):
path = tmp_path / "tutorial_state.json"
path.write_text("not json", encoding="utf-8")
flow = TutorialStore(path).load()
assert flow.state == NOT_STARTED


def test_tutorial_store_reset_removes_file(tmp_path):
store = TutorialStore(tmp_path / "state")
flow = TutorialFlow()
flow.transition(TUTORIAL_ACTIVE)
store.save(flow)
assert store.path.exists()
store.reset()
assert not store.path.exists()


def test_atomic_write_no_temp_files_on_disk(tmp_path):
store = TutorialStore(tmp_path / "state")
flow = TutorialFlow()
flow.transition(TUTORIAL_ACTIVE)
store.save(flow)
leftover = list(store.path.parent.glob(".tutorial_*"))
assert leftover == []


# ────────────────────────────────────────────────────────────────────────
# scenario_to_load
# ────────────────────────────────────────────────────────────────────────

def test_scenario_loader_returns_tutorial_when_not_yet_done():
flow = TutorialFlow()
assert scenario_to_load(flow) == "Tutorial"
flow.transition(TUTORIAL_ACTIVE)
assert scenario_to_load(flow) == "Tutorial"


def test_scenario_loader_returns_campaign_after_completion():
flow = TutorialFlow()
flow.transition(TUTORIAL_ACTIVE)
flow.transition(TUTORIAL_COMPLETE)
assert scenario_to_load(flow) == "LongRunningCampaign"


def test_scenario_loader_returns_campaign_after_skip():
flow = TutorialFlow()
flow.transition(CAMPAIGN_ACTIVE)
assert scenario_to_load(flow) == "LongRunningCampaign"


# ────────────────────────────────────────────────────────────────────────
# Scenario JSON files (mod-side content)
# ────────────────────────────────────────────────────────────────────────

def test_tutorial_scenario_files_parse():
for name in ("scenario.json", "Map.json"):
path = SCENARIO_ROOT / "Tutorial" / name
assert path.exists(), f"missing {path}"
json.loads(path.read_text(encoding="utf-8"))


def test_campaign_scenario_files_parse():
for name in ("scenario.json", "Map.json"):
path = SCENARIO_ROOT / "LongRunningCampaign" / name
assert path.exists(), f"missing {path}"
json.loads(path.read_text(encoding="utf-8"))


def test_tutorial_scenario_specifies_under_ten_minutes():
"""PRD acceptance #9 anchor."""
data = json.loads((SCENARIO_ROOT / "Tutorial" / "scenario.json").read_text())
assert data["tutorialFlags"]["expectedDurationMinutes"] <= 10


def test_tutorial_scenario_handoff_target_is_campaign():
data = json.loads((SCENARIO_ROOT / "Tutorial" / "scenario.json").read_text())
assert data["claudeKingdomsMeta"]["handoffTarget"] == "LongRunningCampaign"


def test_campaign_scenario_has_no_forced_end_condition():
"""PRD acceptance #18: no forced end."""
data = json.loads((SCENARIO_ROOT / "LongRunningCampaign" / "scenario.json").read_text())
assert data["victoryConditions"] == []
assert data["campaignFlags"]["noForcedEnd"] is True


def test_campaign_scenario_supports_no_decay():
"""PRD acceptance #16, BS-6: state preserved on close."""
data = json.loads((SCENARIO_ROOT / "LongRunningCampaign" / "scenario.json").read_text())
assert data["campaignFlags"]["noDecayOnClose"] is True


def test_tutorial_map_has_a_starting_city():
"""The tutorial must place exactly one city — PRD: 'one city, one session, one turn'."""
data = json.loads((SCENARIO_ROOT / "Tutorial" / "Map.json").read_text())
cities = []
for row in data["tiles"]:
for tile in row:
if tile.get("city"):
cities.append(tile["city"])
assert len(cities) == 1
assert cities[0] == "Camelot"
Loading
Loading