File size: 5,390 Bytes
1c9c13f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""μ„Έμ…˜ μ˜μ†ν™” νšŒκ·€ β€” μŠ€λƒ…μƒ·/볡원 + SQLite μ €μž₯μ†Œ + λΌμš°ν„° μž¬μ‹œμž‘ 볡원.

핡심 νšŒκ·€: 볡원은 _enter()λ₯Ό κ±°μΉ˜μ§€ μ•ŠλŠ”λ‹€ β€” 쇠약도/신뒰좕이 재적용되면 결함.
"""

import pytest
from fastapi import HTTPException

from engine import router
from engine.repositories.content import ContentRepository
from engine.repositories.play_session import PlaySessionRepository
from engine.services.story import StoryEngine


@pytest.fixture(scope="module")
def repo():
    return ContentRepository("content")


def play_until(repo, stop_id: str | None, policy: str = "trust") -> StoryEngine:
    """stop_id μΉ΄λ“œ 도달(λ˜λŠ” None이면 μ—”λ”©)κΉŒμ§€ μ§„ν–‰."""
    engine = StoryEngine(repo)
    step = engine.start()
    while step.ending is None and engine.current_id != stop_id:
        if step.choices:
            idx = next((i for i, c in enumerate(step.choices) if c.axis == policy),
                       next((i for i, c in enumerate(step.choices)
                             if c.axis in (None, "neutral")), 0))
            _, step = engine.choose(idx)
        else:
            step = engine.advance()
    return engine


# ── μŠ€λƒ…μƒ·/볡원 (μ—”μ§„) ──────────────────────────────────────
def test_snapshot_restore_roundtrip(repo):
    engine = play_until(repo, "E04-01")
    snap = engine.snapshot()
    restored = StoryEngine.restore(repo, snap)

    assert restored.snapshot() == snap  # 볡원이 μƒνƒœλ₯Ό μ‘°κΈˆλ„ λ°”κΎΈμ§€ μ•ŠλŠ”λ‹€
    assert restored.current_id == engine.current_id
    assert restored.visited == engine.visited
    step = restored.current_step()
    assert step.card.id == engine.current_id
    assert step.ending is None


def test_restore_does_not_reapply_enter_side_effects(repo):
    """핡심 νšŒκ·€ β€” 볡원 μ‹œ 쇠약도·신뒰좕이 재적용되면 μ•ˆ λœλ‹€ (_enter 우회 검증)."""
    engine = play_until(repo, None)  # μ—”λ”©κΉŒμ§€ = 쇠약도·좕 이벀트 λ‹€μˆ˜ λˆ„μ 
    assert engine.state.frailty > 0 and engine.state.trust_score > 0

    snap = engine.snapshot()
    restored = StoryEngine.restore(repo, snap)
    assert restored.state.frailty == engine.state.frailty
    assert restored.state.trust_score == engine.state.trust_score
    assert restored.state.doubt_score == engine.state.doubt_score
    assert restored.state.axis_log == engine.state.axis_log

    # μ—¬λŸ¬ 번 볡원해도 동일 (재적용이 있으면 μ—¬κΈ°μ„œ μ–΄κΈ‹λ‚œλ‹€)
    again = StoryEngine.restore(repo, restored.snapshot())
    assert again.snapshot() == snap


def test_ending_session_restores_to_ending(repo):
    engine = play_until(repo, None, policy="trust")
    assert engine.ending is not None
    restored = StoryEngine.restore(repo, engine.snapshot())
    step = restored.current_step()
    assert step.ending is not None and step.ending.id == engine.ending.id


# ── SQLite μ €μž₯μ†Œ ───────────────────────────────────────────
def test_play_session_repo_save_load(tmp_path, repo):
    db = PlaySessionRepository(tmp_path / "sessions.db")
    engine = play_until(repo, "E02-01")
    db.save("sid1", engine.snapshot(), repo.content_version)

    loaded = db.load("sid1", repo.content_version)
    assert loaded == engine.snapshot()
    assert db.load("μ—†λŠ”sid", repo.content_version) is None


def test_content_version_mismatch_invalidates(tmp_path, repo):
    db = PlaySessionRepository(tmp_path / "sessions.db")
    db.save("sid1", {"story": {}, "hidden": {}}, "κ΅¬λ²„μ „ν•΄μ‹œ")
    assert db.load("sid1", repo.content_version) is None  # μ½˜ν…μΈ  κ°±μ‹  β†’ λ¬΄νš¨ν™”


def test_purge_removes_stale_sessions(tmp_path):
    db = PlaySessionRepository(tmp_path / "sessions.db")
    db.save("sid1", {"story": {}, "hidden": {}}, "v")
    assert db.purge(days=30) == 0  # 방금 μ €μž₯ β€” λ‚¨λŠ”λ‹€
    assert db.purge(days=0) == 1   # μ¦‰μ‹œ 만료 μ·¨κΈ‰ β€” μ§€μ›Œμ§„λ‹€


# ── λΌμš°ν„° (μž¬μ‹œμž‘ λͺ¨μ‚¬) ────────────────────────────────────
@pytest.mark.asyncio
async def test_router_restores_after_restart(tmp_path, monkeypatch):
    monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
    monkeypatch.setattr(router, "_sessions", {})

    data = await router.create_session()
    sid = data["session_id"]
    step = data["step"]
    while not step["choices"] and not step["ending"]:  # 첫 μ„ νƒμ§€κΉŒμ§€ 읽기
        step = (await router.advance(sid))["step"]
    step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
    current_card = step["card"]["id"]

    router._sessions.clear()  # μ„œλ²„ μž¬μ‹œμž‘ λͺ¨μ‚¬ (인메λͺ¨λ¦¬ μΊμ‹œ μ†Œμ‹€)
    got = await router.get_session(sid)
    assert got["step"]["card"]["id"] == current_card
    assert router._sessions[sid].pocket_sid is None  # 포켓은 λΉ„μ˜μ† β€” λ‹«νžŒ 채 볡원


@pytest.mark.asyncio
async def test_router_unknown_sid_404(tmp_path, monkeypatch):
    monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
    monkeypatch.setattr(router, "_sessions", {})
    with pytest.raises(HTTPException) as exc:
        await router.get_session("μ—†λŠ”μ„Έμ…˜")
    assert exc.value.status_code == 404