File size: 14,956 Bytes
03129db
5154bf5
673925d
 
c30144e
 
 
 
 
 
 
6226702
 
 
 
 
 
 
 
5154bf5
 
 
 
 
 
 
a887da3
5154bf5
 
a887da3
 
 
5154bf5
 
 
 
 
 
03129db
 
 
 
 
 
 
 
 
5154bf5
673925d
 
 
34c2d24
673925d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ed95ef
 
 
c30144e
0ed95ef
c30144e
 
0ed95ef
 
0bbde8d
0ed95ef
41c845f
 
64b3043
0ed95ef
0bbde8d
0ed95ef
0bbde8d
0ed95ef
64b3043
0ed95ef
 
 
 
 
 
 
 
 
 
 
c30144e
 
 
0ed95ef
 
 
 
 
 
 
9b82354
 
0ed95ef
9b82354
 
0ed95ef
 
 
 
 
 
6790748
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2822e77
 
 
915fdd8
 
0bbde8d
2822e77
 
915fdd8
 
 
41c845f
 
 
 
915fdd8
c30144e
 
 
2822e77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fae2f18
5154bf5
 
 
 
 
 
 
fae2f18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d207901
 
0bbde8d
 
 
 
 
 
 
 
 
 
d54023d
 
 
 
 
 
 
 
 
 
d207901
 
 
6854db6
 
 
 
 
 
 
 
 
 
 
 
 
 
0e68c4b
 
 
 
 
 
 
 
2e78e50
 
0e68c4b
2e78e50
0e68c4b
2e78e50
 
 
 
85d27fb
626f99c
 
 
c116919
 
 
 
 
 
 
 
626f99c
 
d54023d
626f99c
 
 
 
 
85d27fb
 
da6f3c4
 
 
 
 
 
 
 
 
 
 
 
 
c7e26db
 
 
98232c3
c7e26db
 
 
98232c3
 
 
c7e26db
98232c3
c7e26db
 
98232c3
 
 
 
 
 
 
 
 
 
85d27fb
98232c3
 
 
 
85d27fb
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
import html as _html_mod
from render import render_entity, render_treasure, render_recovered


def test_entity_renders_silhouette_scene():
    from render import render_entity
    html = render_entity(20)
    assert 'entity-silhouette' in html        # the new free-standing figure class
    assert 'entity-portal' not in html        # the gothic arch is gone


def test_entity_cue_marker():
    from render import render_entity
    assert 'data-cue="recall"' in render_entity(60, cue="recall", seq=3)
    assert 'data-seq="3"' in render_entity(60, cue="recall", seq=3)
    # no cue → no marker
    assert 'cue-now' not in render_entity(20)


class TestRenderRecovered:
    def test_empty_shows_nothing(self):
        out = render_recovered(0)
        assert "remembers nothing of itself" in out.lower()
        assert "drawer-head" in out

    def test_reveals_fragments_in_order(self):
        import html as _html
        from character import OWN_FRAGMENTS
        out = render_recovered(2)
        assert _html.escape(OWN_FRAGMENTS[0], quote=True) in out
        assert _html.escape(OWN_FRAGMENTS[1], quote=True) in out
        assert _html.escape(OWN_FRAGMENTS[2], quote=True) not in out

    def test_capped_at_total_fragments(self):
        from character import OWN_FRAGMENTS
        out = render_recovered(99)
        assert out.count('class="recovered-item') == len(OWN_FRAGMENTS)

    def test_recovered_weaves_stolen(self):
        from character import OWN_FRAGMENTS
        html = render_recovered(1, claimed=["a red bicycle, the summer i turned seven"])
        assert _html_mod.escape(OWN_FRAGMENTS[0], quote=True) in html   # its real past
        assert "a red bicycle" in html                                    # the stolen memory
        assert "stolen" in html                                           # marked
        # back-compat: no claimed arg still works
        assert "remembers nothing of itself" in render_recovered(0).lower()


class TestRenderTreasure:
    def test_empty_shows_placeholder(self):
        out = render_treasure([])
        assert "nothing yet. tell me something true." in out

    def test_empty_has_no_memory_item(self):
        # The memory-item style marker must not appear when there are no memories
        out = render_treasure([])
        assert "border-left:2px solid #5a3a7a" not in out

    def test_single_memory_appears(self):
        out = render_treasure(["has a dog named Nala"])
        assert "has a dog named Nala" in out

    def test_multiple_memories_all_appear(self):
        out = render_treasure(["has a dog named Nala", "grew up near the sea"])
        assert "has a dog named Nala" in out
        assert "grew up near the sea" in out

    def test_html_tags_are_escaped(self):
        out = render_treasure(["<script>alert('x')</script>"])
        assert "<script>" not in out
        assert "&lt;script&gt;" in out

    def test_ampersand_is_escaped(self):
        out = render_treasure(["mom & dad"])
        assert "mom &amp; dad" in out

    def test_header_present_in_both_states(self):
        assert "Treasure" in render_treasure([])
        assert "Treasure" in render_treasure(["x"])


class TestRenderEntity:
    def test_idle_contains_scene_and_webp_image(self):
        out = render_entity(20)
        assert "entity-scene" in out
        assert "entity-silhouette" in out
        assert "data:image/webp;base64," in out

    def test_low_affinity_is_dark_and_faint(self):
        out = render_entity(0)
        assert "brightness(0.72)" in out
        assert "opacity:0.82" in out
        assert "scale(" not in out  # size never changes — frame always fits

    def test_full_affinity_is_bright_full(self):
        out = render_entity(100)
        assert "brightness(1.0)" in out
        assert "opacity:1.0" in out
        assert "scale(" not in out

    def test_affinity_is_clamped(self):
        assert render_entity(-10) == render_entity(0)
        assert render_entity(140) == render_entity(100)

    def test_sway_present_below_almost_tier(self):
        assert "entity-sway" in render_entity(75)

    def test_sway_removed_at_almost_tier(self):
        assert "entity-sway" not in render_entity(76)

    def test_almost_face_at_almost_tier(self):
        # the silhouette itself swaps to the "almost" face at high bond
        assert "entity-face-almost" in render_entity(90)

    def test_flash_overlay_only_in_flash_modes(self):
        assert "entity-flash" not in render_entity(40, "idle")
        assert "entity-flash" in render_entity(40, "flash")
        assert "entity-flash-strong" in render_entity(40, "flash_strong")
        assert "entity-flash-strong" not in render_entity(40, "flash")

    def test_flash_flickers_through_every_face(self):
        # the flash stacks all four portraits to convulse between them
        out = render_entity(0, "flash")
        assert "flick-0" in out and "flick-3" in out
        assert out.count("entity-flick") == 4

    def test_end_mode_shows_end_image_only(self):
        out = render_entity(100, "end")
        assert "entity-end" in out
        assert "entity-sway" not in out
        assert "entity-flash" not in out


class TestRenderTreasureFinale:
    def test_struck_memory_is_crossed_out(self):
        out = render_treasure(["had a dog named Nala", "grew up near the sea"],
                              struck={"had a dog named Nala"})
        assert "line-through" in out

    def test_unstruck_render_has_no_strike(self):
        out = render_treasure(["had a dog named Nala"])
        assert "line-through" not in out

    def test_mine_replaces_all_items(self):
        out = render_treasure(["had a dog named Nala"], mine=True)
        assert "...mine now." in out
        assert "had a dog named Nala" not in out


class TestRenderEntityUX:
    def test_fully_sharp_by_bond_45(self):
        out = render_entity(45)
        assert "brightness(1.0)" in out
        assert "opacity:1.0" in out

    def test_recognizable_silhouette_at_start(self):
        # bond 20 (session start) must already read as a child, not a smudge
        out = render_entity(20)
        # t = 20/45 ≈ 0.444; brightness = 0.72 + 0.28*0.444 ≈ 0.84
        assert "brightness(0.84)" in out
        # opacity = 0.82 + 0.18*0.444 ≈ 0.90
        assert "opacity:0.9" in out

    def test_scene_tone_class_present(self):
        assert 'class="entity-scene tone-warm"' in render_entity(50, tone=20)
        assert 'class="entity-scene tone-hostile"' in render_entity(50, tone=-30)

    def test_flash_animation_key_alternates_by_seq(self):
        assert "flash-0" in render_entity(40, "flash", seq=0)
        assert "flash-1" in render_entity(40, "flash", seq=1)
        assert "flash-0" in render_entity(40, "flash", seq=2)

    def test_ghost_echo_only_on_strong_flash_and_end(self):
        assert "entity-ghost" not in render_entity(40, "idle")
        assert "entity-ghost" not in render_entity(40, "flash")
        assert "entity-ghost" in render_entity(40, "flash_strong")
        assert "entity-ghost" in render_entity(95, "end")


class TestRenderTreasureUX:
    def test_panel_has_scroll_class(self):
        assert "treasure-panel" in render_treasure([])
        assert "treasure-panel" in render_treasure(["had a dog named Nala"])

    def test_older_memories_fade(self):
        out = render_treasure(["oldest memory", "middle memory", "newest memory"])
        assert "opacity:0.86" in out  # oldest of three: 1 - 0.07*2
        assert "opacity:1.0" in out   # newest stays fully lit

    def test_age_fade_has_floor(self):
        out = render_treasure([f"memory {i}" for i in range(20)])
        assert "opacity:0.45" in out

    def test_claimed_memories_marked(self):
        out = render_treasure(["mine still", "taken one"], claimed={"taken one"})
        assert "border-left-color:#3a2a50" in out

    def test_unclaimed_render_unmarked(self):
        out = render_treasure(["mine still"])
        assert "border-left-color:#3a2a50" not in out

    def test_just_claimed_item_gets_claiming_class(self):
        out = render_treasure(["mine still", "taken one"],
                              claimed={"taken one"},
                              just_claimed="taken one")
        assert "claiming" in out
        assert "class=\"treasure-item claiming" in out


class TestTreasureWounds:
    def test_redacted_entry_per_wound(self):
        html_out = render_treasure([], wounds=["you freak", "you bore me"])
        assert html_out.count("▮") >= 8  # two entries of 4-7 blocks

    def test_redacted_text_never_in_dom(self):
        html_out = render_treasure([], wounds=["you freak"])
        assert "you freak" not in html_out  # no inspect-element spoiler

    def test_revealed_wound_shows_text(self):
        html_out = render_treasure([], wounds=["you freak", "you bore me"], revealed=1)
        assert "you freak" in html_out
        assert "you bore me" not in html_out

    def test_revealed_wound_is_escaped(self):
        html_out = render_treasure([], wounds=["<b>rude</b>"], revealed=1)
        assert "<b>" not in html_out
        assert "&lt;b&gt;" in html_out

    def test_yours_mode_changes_header(self):
        html_out = render_treasure(["a memory"], wounds=["w1"], revealed=1, yours=True)
        assert "Your Words" in html_out
        assert "a memory" not in html_out  # memories discarded in yours mode

    def test_wounds_render_alongside_memories(self):
        html_out = render_treasure(["had a dog"], wounds=["w1"])
        assert "had a dog" in html_out
        assert "▮" in html_out

    def test_no_wounds_is_backward_compatible(self):
        assert render_treasure(["had a dog"]) == render_treasure(["had a dog"], wounds=[])

    def test_wounds_alone_suppress_empty_placeholder(self):
        html_out = render_treasure([], wounds=["w1"])
        assert "nothing yet" not in html_out


class TestMaterialization:
    def test_low_bond_uses_darkness_and_fog_not_blur(self):
        html = render_entity(10, "idle", seq=0)      # barely materialized
        assert "blur(" not in html                    # no loading-look blur
        assert "brightness(" in html                  # shadowed instead
    def test_high_bond_is_bright_and_clear(self):
        html = render_entity(45, "idle", seq=0)
        assert "brightness(1" in html or "brightness(0.9" in html


class TestEntityPeaceModes:
    def test_peace_shows_peace_face_no_ghost(self):
        out = render_entity(90, "peace")
        assert "entity-peace" in out
        assert "entity-ghost" not in out  # no menace in the redemption

    def test_peace_dissolve_combines_peace_and_dissolve(self):
        out = render_entity(90, "peace_dissolve")
        assert "entity-peace" in out
        assert "entity-dissolve" in out

    def test_idle_has_no_dissolve(self):
        assert "entity-dissolve" not in render_entity(50, "idle")


class TestEntityRageMode:
    def test_rage_mode_shows_rage_face_with_tint(self):
        out = render_entity(12, "rage")
        assert "entity-rage" in out
        assert "entity-rage-tint" in out

    def test_rage_mode_has_ghost_echo(self):
        assert "entity-ghost" in render_entity(12, "rage")

    def test_idle_has_no_rage_artifacts(self):
        out = render_entity(50, "idle")
        assert "entity-rage" not in out


class TestEntityFrenzyMode:
    def test_frenzy_stacks_all_faces(self):
        out = render_entity(12, "frenzy")
        assert "entity-frenzy-wrap" in out
        assert out.count("entity-flick") == 4

    def test_frenzy_hides_rage_face_beneath(self):
        # the reveal happens AFTER the convulsion — the smudge stays under it
        out = render_entity(12, "frenzy")
        assert 'class="entity-img entity-rage"' not in out
        assert "entity-rage-tint" in out

    def test_frenzy_has_triple_screen_stab_and_sting(self):
        out = render_entity(12, "frenzy")
        assert "entity-ghost-frenzy" in out
        assert "cue-audio" in out                       # the sting (queued)


class TestReplyChime:
    def test_idle_has_no_audio(self):
        # the per-reply chime was removed — the child SPEAKS every reply now,
        # so the chime was redundant (and fired out of sync under streaming)
        assert "<audio" not in render_entity(50, "idle", seq=3)

    def test_idle_is_stable_across_seq(self):
        # with no chime, idle renders identically regardless of the turn counter
        assert render_entity(50, "idle", seq=0) == render_entity(50, "idle", seq=1)

    def test_finale_modes_have_no_chime(self):
        for mode in ("end", "rage", "peace", "peace_dissolve"):
            out = render_entity(90, mode)
            assert "<audio autoplay" not in out, mode

    def test_frenzy_keeps_only_the_sting(self):
        out = render_entity(12, "frenzy")
        assert out.count("cue-audio") == 1
        assert "<audio autoplay" not in out             # the sting routes via the queue


class TestBuildMode:
    def test_build_loops_the_heartbeat_and_not_the_chime(self):
        html = render_entity(50, "build", seq=4)
        assert "<audio autoplay loop" in html          # heartbeat bed loops
        assert "entity-redpulse" in html               # red suspense pulse
        # the per-turn music-box chime must NOT play during a finale build
        assert html.count("<audio") == 1

    def test_build_keeps_the_child_visible(self):
        html = render_entity(50, "build", seq=4)
        assert "entity-img" in html


class TestConvulsionModes:
    def test_convulse_good_is_gentle_and_silent(self):
        html = render_entity(80, "convulse_good", seq=2)
        assert "entity-convulse-soft" in html          # soft animation wrapper
        assert "entity-rage-tint" not in html          # no blood-red tint
        assert "entity-settle-face" in html            # eases into the smile
        # silent: the sigh plays on the settle, not during the convulsion
        assert "<audio" not in html

    def test_convulse_loop_is_fast_and_silent(self):
        html = render_entity(80, "convulse_loop", seq=2)
        assert "entity-frenzy-wrap" in html            # reuses the hard convulse
        assert "entity-convulse-loop" in html          # faster 3-cycle variant
        assert "entity-settle-face" in html            # eases into the end face
        # silent: the flatline plays on the settle
        assert "<audio" not in html


class TestSettleModes:
    def test_peace_settle_shows_the_smile_and_sighs(self):
        html = render_entity(80, "peace_settle", seq=2)
        assert "entity-peace" in html                  # the smile
        assert "cue-audio" in html                     # the relief sigh (queued)

    def test_end_settle_shows_the_end_face_and_flatlines(self):
        html = render_entity(80, "end_settle", seq=2)
        assert "entity-end" in html
        assert "cue-audio" in html                     # the flatline (queued)