File size: 4,288 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""casefile_to_public: a generated CaseFile projects to the PUBLIC view without leaking
the sealed solution, and conforms to the same wire contract the client expects.
"""

from __future__ import annotations

import json

from case_zero.api.case_adapter import casefile_to_public
from case_zero.api.public_view import PublicCase
from case_zero.generator.assemble import assemble_case
from case_zero.generator.stages import GenSuspect, MysteryOut, WorldCastOut
from case_zero.schemas.case import GenerationKnobs
from case_zero.schemas.enums import MotiveCategory
from case_zero.schemas.timeline import TimeWindow


def _casefile():
    world = WorldCastOut(
        title="THE LANTERN ROOM",
        briefing="A bookbinder is found dead in a locked study during a winter salon.",
        setting_name="Ashgrove House",
        locations=["The Study", "The Parlor", "The Conservatory", "The Cellar"],
        victim_name="Edmund Crane",
        victim_role="Master Bookbinder",
        cause_of_death="Struck from behind with a heavy press",
        found_at_index=0,
        weapon_name="bookbinding press",
        weapon_kind="blunt",
        suspects=[
            GenSuspect(name="Cora Vale", role="Apprentice", persona_summary="A quiet apprentice with ink-stained hands.", secret="She forged a signature once.", cover_story="I was cataloguing in the parlor.", evidence_name="ink bottle", evidence_reveal="A spilled bottle of rare ink.", gender="female"),
            GenSuspect(name="Miles Ardent", role="Patron", persona_summary="A wealthy patron with debts.", secret="He owes the victim money.", cover_story="I never left the salon.", evidence_name="promissory note", evidence_reveal="A note for a large sum, unpaid.", gender="male"),
            GenSuspect(name="Greta Holt", role="Housekeeper", persona_summary="The long-serving housekeeper.", secret="She skims the household accounts.", cover_story="I was in the cellar fetching wine.", evidence_name="ledger", evidence_reveal="A ledger with altered figures.", gender="female"),
            GenSuspect(name="Felix Crane", role="Nephew", persona_summary="The restless nephew and heir.", secret="He gambles in secret.", cover_story="I stepped out to the conservatory for air.", evidence_name="card", evidence_reveal="A gambling marker from a city den.", gender="male"),
        ],
    )
    mystery = MysteryOut(
        motive_category=MotiveCategory.GREED,
        motive_summary="To erase a debt that would have ruined him.",
        method_narrative="He waited until the study emptied, then struck.",
        alibi_claim="I was in the parlor with the others all evening.",
        alibi_claimed_loc_index=1,
        breaker_one_name="thread",
        breaker_one_reveal="A torn thread of dark cloth on the study latch.",
        breaker_two_name="footprint",
        breaker_two_reveal="A damp footprint by the press, fresh at the hour.",
        deduction_chain=["The study was locked from inside.", "Only one route stayed open.", "The thread matches the patron's coat."],
    )
    return assemble_case(
        case_id="gen-000042", seed=42, knobs=GenerationKnobs(), world=world, mystery=mystery,
        window=TimeWindow(start_min=21 * 60, end_min=22 * 60),
        tod=TimeWindow(start_min=21 * 60 + 20, end_min=21 * 60 + 50),
        culprit_idx=1, crime_idx=0, claimed_idx=1,
    )


def test_adapter_conforms_to_public_contract():
    pub = casefile_to_public(_casefile())
    assert isinstance(pub, PublicCase)
    assert len(pub.suspects) == 4
    assert len(pub.evidence) >= 1
    assert len(pub.motives) == 4
    assert any(m.id == "M1" for m in pub.motives)  # the true motive is option M1
    assert pub.timeline and pub.flashback.title
    for s in pub.suspects:
        assert len(s.suggested_questions) == 5


def test_adapter_does_not_leak_culprit():
    case = _casefile()
    pub = casefile_to_public(case)
    blob = json.dumps(pub.model_dump(by_alias=True))
    # the sealed culprit is S2 (culprit_idx=1); nothing must mark who it is
    for token in ('"isCulprit"', '"is_culprit"', '"secret"', '"breaksOn"', '"correctMotive"', '"killer"'):
        assert token not in blob
    # the true motive text appears (as one option) but is not flagged correct
    assert case.culprit.true_motive.summary in blob