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
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
use super::seat::DESK_SEAT_Z_OFF;
use super::*;
use pixtuoid_core::sprite::{Frame, Palette};
use pixtuoid_core::state::GlobalDeskIndex;
use std::path::PathBuf;
use std::sync::Arc;
#[test]
fn stitch_vertical_wall_connects_each_joint() {
let top_margin = 48u16;
let top_wall_h = top_margin - 4; // 44
let h_y = 90u16; // a horizontal divider row
let h_rows = [h_y];
// Top joint: a segment starting at top_margin rises to the window band.
let (yt, _) = stitch_vertical_wall(top_margin, 70, top_margin, top_wall_h, &h_rows);
assert_eq!(
yt, top_wall_h,
"top segment should connect up to the window band"
);
// Corner joint: a segment ending on the horizontal row extends down by
// the horizontal's thickness to fill the inside corner.
let (_, yb) = stitch_vertical_wall(60, h_y, top_margin, top_wall_h, &h_rows);
assert_eq!(
yb,
h_y + (WALL_THICK_H_PX - 1),
"bottom should fill the corner"
);
// Bridge-up joint (the dual-meeting case): a segment starting ~6 px
// below the cross wall is bridged up to meet it. This branch only fires
// on variant-2 floors, so it has no end-to-end render guard.
let (yt2, _) = stitch_vertical_wall(h_y + 6, 120, top_margin, top_wall_h, &h_rows);
assert_eq!(yt2, h_y, "lower segment should bridge up to the cross wall");
// No false bridge: a segment well below the tolerance stays put, and a
// segment with no joints is returned unchanged.
let (yt3, yb3) = stitch_vertical_wall(h_y + 20, 130, top_margin, top_wall_h, &h_rows);
assert_eq!(
(yt3, yb3),
(h_y + 20, 130),
"distant segment must not bridge"
);
let (yt4, yb4) = stitch_vertical_wall(60, 80, top_margin, top_wall_h, &[]);
assert_eq!((yt4, yb4), (60, 80), "no joints → unchanged");
}
// The vertical-wall top raise lives in TWO crates — the renderer
// (stitch_vertical_wall, here) and the mask (build_walkable_mask, core).
// The mask raises a top_margin-rooted segment to
// `top_margin - WALL_BAND_TO_TOP_MARGIN`; the renderer raises it to
// `top_wall_h`, which the binary derives from the SAME const. If they ever
// disagree a walkable slot opens at the wall top (the regression
// `vertical_wall_is_impassable_except_through_the_door` guards). Extracting
// the rule into core would drag tui wall constants across the crate
// boundary (invariant #1); this test pins the agreement instead.
#[test]
fn vertical_wall_top_raise_agrees_between_renderer_and_mask() {
let top_margin = 48u16;
let tbm = pixtuoid_core::layout::WALL_BAND_TO_TOP_MARGIN;
let top_wall_h = top_margin - tbm; // what the binary passes the renderer
let mask_raise = top_margin.saturating_sub(tbm); // what mask.rs computes
let (renderer_raise, _) = stitch_vertical_wall(top_margin, 90, top_margin, top_wall_h, &[]);
assert_eq!(
renderer_raise, mask_raise,
"renderer + mask must raise the vertical wall top to the same row"
);
}
#[test]
fn glass_wall_h_back_cap_composites_over_a_character_behind_it() {
// Occlusion: the horizontal wall's frosted glass rises GLASS_CAP_PX
// north of its footprint, y-sorted at the south base — so a character
// standing just NORTH of the wall (drawn earlier) is composited over
// by the translucent glass. Stand in for that character with a vivid
// warm pixel inside the cap band; the glass must shift it toward the
// cool tone (red drops, blue rises) rather than leave it untouched.
let theme = crate::tui::theme::theme_by_name("normal").expect("theme");
let y_top = 20u16;
// Place the stand-in at the REAL northmost row a routed walker's feet
// can reach: footprint top `y_top` minus (OBSTACLE_PAD_PX=2 + 1) = the
// first walkable row north of the wall. With GLASS_CAP_PX=6 the cap
// (rows y_top-6..y_top-1) covers this row, so a walker's feet/lower legs
// composite behind the glass. (The old test used y_top-2, a row inside
// the blocked footprint+pad band that no walker ever occupies.)
let cap_row = y_top - 3;
let character = Rgb {
r: 220,
g: 40,
b: 40,
};
let mut buf = RgbBuffer::filled(
48,
48,
Rgb {
r: 150,
g: 110,
b: 72,
},
); // carpet
for x in 4..20 {
buf.put(x, cap_row, character);
}
paint_glass_wall_h(&mut buf, theme, 0, 47, y_top);
let after = buf.get(8, cap_row);
assert_ne!(after, character, "glass must composite over the character");
assert!(
after.r < character.r && after.b > character.b,
"frosted glass should cool the occluded pixel (red↓ blue↑): {after:?}"
);
}
#[test]
fn seat_sprite_maps_facing_to_sprite_and_flip() {
use crate::tui::layout::{Facing, WaypointKind};
// Lounge couch always looks at the window (Facing::North) → back view.
assert_eq!(
seat_sprite(WaypointKind::Couch, Facing::North),
("back_couch", false),
"couch's seated facing is North (window) → back_couch, same path as the sofa"
);
// North-side sofa seat faces away → back view, no flip.
assert_eq!(
seat_sprite(WaypointKind::MeetingSofa, Facing::North),
("back_couch", false)
);
// South-side sofa seat faces the viewer → front seated, no flip.
assert_eq!(
seat_sprite(WaypointKind::MeetingSofa, Facing::South),
("seated", false)
);
// West stand (layout marks it Facing::East) mirrors toward the table.
assert_eq!(
seat_sprite(WaypointKind::MeetingStand, Facing::East),
("standing", true)
);
// East stand (Facing::West) is unmirrored.
assert_eq!(
seat_sprite(WaypointKind::MeetingStand, Facing::West),
("standing", false)
);
}
fn make_slot(id: pixtuoid_core::AgentId, state: ActivityState) -> AgentSlot {
let now = SystemTime::UNIX_EPOCH;
AgentSlot {
agent_id: id,
source: Arc::from("claude-code"),
session_id: Arc::from("s"),
cwd: Arc::from(PathBuf::from("/x").as_path()),
label: Arc::from("x"),
state,
state_started_at: now,
created_at: now,
last_event_at: now,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(0),
floor_idx: 0,
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
}
}
fn base_palette() -> Palette {
let mut p = Palette::new();
p.insert(
'B',
Some(Rgb {
r: 10,
g: 20,
b: 30,
}),
); // shirt
p.insert(
'H',
Some(Rgb {
r: 40,
g: 50,
b: 60,
}),
); // hair
p.insert(
'S',
Some(Rgb {
r: 70,
g: 80,
b: 90,
}),
); // skin
p.insert(
'X',
Some(Rgb {
r: 99,
g: 99,
b: 99,
}),
); // unrelated key
p
}
#[test]
fn agent_palette_is_deterministic_per_id() {
let id = pixtuoid_core::AgentId::from_transcript_path("/a.jsonl");
let base = base_palette();
let a = agent_palette(&base, &make_slot(id, ActivityState::Idle), None);
let b = agent_palette(&base, &make_slot(id, ActivityState::Idle), None);
assert_eq!(a.get('B'), b.get('B'));
assert_eq!(a.get('H'), b.get('H'));
assert_eq!(a.get('S'), b.get('S'));
}
#[test]
fn agent_palette_overrides_only_bhs_keys() {
let id = pixtuoid_core::AgentId::from_transcript_path("/a.jsonl");
let base = base_palette();
let p = agent_palette(&base, &make_slot(id, ActivityState::Idle), None);
// X is not a recolor target — must pass through unchanged.
assert_eq!(
p.get('X'),
Some(Some(Rgb {
r: 99,
g: 99,
b: 99
}))
);
// B/H/S must be replaced — the base RGBs (10/20/30 etc.) are
// unlikely to be in any preset, so they should differ.
assert_ne!(
p.get('B'),
Some(Some(Rgb {
r: 10,
g: 20,
b: 30
}))
);
assert_ne!(
p.get('H'),
Some(Some(Rgb {
r: 40,
g: 50,
b: 60
}))
);
assert_ne!(
p.get('S'),
Some(Some(Rgb {
r: 70,
g: 80,
b: 90
}))
);
}
#[test]
fn agent_palette_glow_tint_shifts_skin_toward_given_color() {
let id = pixtuoid_core::AgentId::from_transcript_path("/a.jsonl");
let base = base_palette();
let slot = make_slot(id, ActivityState::Idle);
let unlit = agent_palette(&base, &slot, None);
let green_glow = agent_palette(
&base,
&slot,
Some(Rgb {
r: 140,
g: 240,
b: 170,
}),
);
let blue_glow = agent_palette(
&base,
&slot,
Some(Rgb {
r: 100,
g: 160,
b: 255,
}),
);
// Shirt / hair / pants are unaffected by glow.
assert_eq!(unlit.get('B'), green_glow.get('B'));
assert_eq!(unlit.get('H'), green_glow.get('H'));
assert_eq!(unlit.get('P'), green_glow.get('P'));
// Green glow pushes skin's green channel up.
let (Some(Some(Rgb { r: _, g: ug, b: _ })), Some(Some(Rgb { r: _, g: gg, b: _ }))) =
(unlit.get('S'), green_glow.get('S'))
else {
panic!("S key missing")
};
assert!(
gg > ug,
"green glow should push skin green (lit={gg}, unlit={ug})"
);
// Blue glow pushes skin's blue channel up.
let (Some(Some(Rgb { r: _, g: _, b: ub })), Some(Some(Rgb { r: _, g: _, b: bb }))) =
(unlit.get('S'), blue_glow.get('S'))
else {
panic!("S key missing")
};
assert!(
bb > ub,
"blue glow should push skin blue (lit={bb}, unlit={ub})"
);
}
#[test]
fn tool_glow_tint_maps_known_tools() {
let id = pixtuoid_core::AgentId::from_transcript_path("/t.jsonl");
let edit_slot = make_slot(
id,
ActivityState::Active {
tool_use_id: None,
detail: Some(Arc::from("Edit src/main.rs")),
},
);
let bash_slot = make_slot(
id,
ActivityState::Active {
tool_use_id: None,
detail: Some(Arc::from("Bash: ls")),
},
);
let idle_slot = make_slot(id, ActivityState::Idle);
let glow = &crate::tui::theme::NORMAL.tool_glow;
let edit_tint = palette::tool_glow_tint(&edit_slot, glow);
let bash_tint = palette::tool_glow_tint(&bash_slot, glow);
let idle_tint = palette::tool_glow_tint(&idle_slot, glow);
assert!(edit_tint.is_some(), "Edit should produce glow");
assert!(bash_tint.is_some(), "Bash should produce glow");
assert_eq!(idle_tint, None, "Idle should produce no glow");
// Edit and Bash should be different colors.
assert_ne!(edit_tint, bash_tint, "Edit and Bash should differ");
}
#[test]
fn recolor_frame_substitutes_bhs_pixels() {
let base = base_palette();
// Build an agent palette where B/H/S are clearly distinguishable.
let mut agent_pal = base.clone();
agent_pal.insert('B', Some(Rgb { r: 200, g: 0, b: 0 })); // red shirt
agent_pal.insert('H', Some(Rgb { r: 0, g: 200, b: 0 })); // green hair
agent_pal.insert('S', Some(Rgb { r: 0, g: 0, b: 200 })); // blue skin
// Frame: 1 pixel per palette key + 1 unrelated pixel + 1 transparent.
let frame = Frame {
width: 5,
height: 1,
pixels: vec![
Some(Rgb {
r: 10,
g: 20,
b: 30,
}), // matches base B → should become red
Some(Rgb {
r: 40,
g: 50,
b: 60,
}), // matches base H → should become green
Some(Rgb {
r: 70,
g: 80,
b: 90,
}), // matches base S → should become blue
Some(Rgb {
r: 123,
g: 45,
b: 67,
}), // unrelated → unchanged
None, // transparent → unchanged
],
};
let out = recolor_frame(&frame, &agent_pal, &base);
assert_eq!(out.width, 5);
assert_eq!(out.height, 1);
assert_eq!(out.pixels[0], Some(Rgb { r: 200, g: 0, b: 0 }));
assert_eq!(out.pixels[1], Some(Rgb { r: 0, g: 200, b: 0 }));
assert_eq!(out.pixels[2], Some(Rgb { r: 0, g: 0, b: 200 }));
assert_eq!(
out.pixels[3],
Some(Rgb {
r: 123,
g: 45,
b: 67
})
);
assert_eq!(out.pixels[4], None);
}
#[test]
fn recolor_frame_handles_palette_with_no_overrides() {
// If agent palette equals base, frame must come back identical.
let base = base_palette();
let frame = Frame {
width: 3,
height: 1,
pixels: vec![
Some(Rgb {
r: 10,
g: 20,
b: 30,
}),
Some(Rgb {
r: 40,
g: 50,
b: 60,
}),
Some(Rgb {
r: 70,
g: 80,
b: 90,
}),
],
};
let out = recolor_frame(&frame, &base, &base);
assert_eq!(out.pixels, frame.pixels);
}
/// Helper — build a minimal Drawable for sort-order tests. Uses the
/// MeetingTable variant since it carries no borrowed data.
fn drawable(anchor_y: u16) -> Drawable<'static> {
Drawable {
anchor_y,
kind: DrawableKind::MeetingTable {
pos: Point { x: 0, y: 0 },
},
}
}
#[test]
fn drawables_sort_ascending_by_anchor_y() {
let mut v = [drawable(30), drawable(10), drawable(20)];
v.sort_by_key(|d| d.anchor_y);
let ys: Vec<u16> = v.iter().map(|d| d.anchor_y).collect();
assert_eq!(ys, [10, 20, 30]);
}
#[test]
fn drawables_sort_is_stable_on_ties() {
// Same anchor_y values — TimSort (Rust's stable sort) must
// preserve insertion order. The y-sort relies on this so that
// a character at the same anchor_y as the couch behind them
// still paints first (matches the prior Pass 1 → Pass 1.5
// layering).
let mut v = [
Drawable {
anchor_y: 10,
kind: DrawableKind::MeetingTable {
pos: Point { x: 1, y: 0 },
},
},
Drawable {
anchor_y: 10,
kind: DrawableKind::MeetingTable {
pos: Point { x: 2, y: 0 },
},
},
Drawable {
anchor_y: 10,
kind: DrawableKind::MeetingTable {
pos: Point { x: 3, y: 0 },
},
},
];
v.sort_by_key(|d| d.anchor_y);
let xs: Vec<u16> = v
.iter()
.map(|d| match &d.kind {
DrawableKind::MeetingTable { pos } => pos.x,
_ => unreachable!(),
})
.collect();
assert_eq!(xs, [1, 2, 3]);
}
#[test]
fn back_view_meeting_sofa_sorts_over_its_sitter() {
// A south-of-table meeting sofa renders the `back_couch` sprite
// (Facing::North) — the sitter's body must be occluded BEHIND the
// sofa back, same as the lounge couch. The back-view sitter's
// y-sort key is `sofa.y + 2` (back_couch_anchor = stand.y - 7,
// sprite_h = 9, stand.y = sofa.y); the back sofa must beat that.
let sofa_y: u16 = 40;
let sitter_anchor_y = (sofa_y - 7) + 9; // back_couch_anchor + sprite_h
let back_sofa_anchor_y = sofa_y + 3; // faces_away bump
let front_sofa_anchor_y = sofa_y + 2; // sitter-on-top default
assert!(
back_sofa_anchor_y > sitter_anchor_y,
"back-view sofa must sort AFTER its sitter (paint on top): \
sofa={back_sofa_anchor_y}, sitter={sitter_anchor_y}"
);
// Front sofa ties the sitter; insertion order (decor first) then
// keeps the sitter on top — so it must NOT exceed the sitter.
assert!(
front_sofa_anchor_y <= sitter_anchor_y,
"front-view sofa must not sort after its sitter: \
sofa={front_sofa_anchor_y}, sitter={sitter_anchor_y}"
);
}
#[test]
fn center_pin_south_offset_lands_on_the_sprite_south_row() {
// A center-pinned sprite of height h blits at py = center - h/2, so its
// south (front) ROW is `center + h - 1 - h/2`. The z-key must equal that
// for BOTH parities — the round-1 fix used `h/2 - 1`, which is one short
// for ODD h (the 11px whiteboard sorted in front of its own base).
for h in 1u16..=16 {
let expected_south = h - 1 - h / 2;
assert_eq!(
center_pin_south_offset(h),
expected_south,
"h={h}: z-key must land on the sprite south row, not one past it",
);
}
}
#[test]
fn pet_z_anchor_tracks_the_selected_anim_sprite_height() {
// Regression: the pet south-row z-key derives from the CHOSEN anim's
// sprite height (not a hardcoded +2). The shorter sleep sprite must sort
// one row NORTH of the walk/sit sprites — a literal +2 painted a sleeping
// pet OVER a character whose feet land on pos.y+1. Reads the REAL embedded
// heights so a pet-sprite resize surfaces HERE, not as a z-order bug.
let pack = crate::tui::embedded_pack::load_sprite_pack(None).expect("embedded pack");
let pos = Point { x: 40, y: 30 };
let anim_h = |name: &str| {
pack.animation(name)
.and_then(|a| a.frames.first())
.map(|f| f.height)
.unwrap_or_else(|| panic!("missing pet anim {name}"))
};
for &kind in crate::tui::pet::PetKind::ALL {
let sleep_h = anim_h(kind.sleep_anim());
let sleep = z_sort_row(Anchor::Center, pos, sleep_h);
let walk = z_sort_row(Anchor::Center, pos, anim_h(kind.walk_anim()));
let sit = z_sort_row(Anchor::Center, pos, anim_h(kind.sit_anim()));
assert!(
sleep <= walk && sleep <= sit,
"{kind:?}: shorter sleep sprite must not sort south of walk/sit \
(sleep={sleep}, walk={walk}, sit={sit})",
);
assert_eq!(
sleep,
pos.y + center_pin_south_offset(sleep_h),
"{kind:?}: sleep pet must land on its sprite's south row",
);
}
}
#[test]
fn floor_lamp_south_offset_is_the_base_row() {
// The lamp's halo / shadow / z-anchor all use floor_lamp_south_offset();
// for the 4×10 sprite that's +4 (the base disc). Locks the value so a
// visual-height edit in the table surfaces HERE, not as a floating halo.
assert_eq!(floor_lamp_south_offset(), 4);
}
#[test]
fn waypoint_depth_baseline_is_center_pinned_sprite_south() {
use crate::tui::layout::{furniture_def, WaypointKind};
// These appliances are center-pinned, so the z-sort key is the sprite's
// south ROW = pos.y + footprint.h/2 - 1 (NOT +h/2 — that overshoots by
// one and lets the sprite paint over a character just in front). Lock
// the corrected offsets (vending 6→2, printer 4→1), DERIVED from the
// footprint so a shape edit surfaces here, not as a visual layering bug.
let south_off = |k: WaypointKind| {
furniture_def(k.furniture())
.footprint
.expect("has footprint")
.h
/ 2
- 1
};
assert_eq!(south_off(WaypointKind::VendingMachine), 2);
assert_eq!(south_off(WaypointKind::Printer), 1);
}
#[test]
fn desk_walk_anchor_settles_exactly_on_the_seat() {
// The home desk's walk anchor (desk_furniture_def's geometry, pure
// algebraic) must land so the WALKING sprite anchor equals the SEATED
// sprite anchor — zero pop on arrival. This identity is the contract
// that lets desk_walk_anchor stay a pure fn instead of a side-probe; if
// seated_anchor or walking_anchor ever change, this fails loudly.
use crate::tui::layout::desk_walk_anchor;
for desk in [
Point { x: 40, y: 30 },
Point { x: 100, y: 60 },
Point { x: 7, y: 5 }, // near-origin: saturating_sub edge
] {
// The identity must hold for ANY pack character width — the bundled
// 8-wide AND the robot 10-wide — because desk_walk_anchor's +4 / -8
// cancel against the width-centering for every w.
for w in [CHARACTER_SPRITE_W, 10] {
assert_eq!(
walking_anchor(desk_walk_anchor(desk), w),
seated_anchor(desk, w),
"walking_anchor(desk_walk_anchor({desk:?}), {w}) must equal seated_anchor",
);
}
}
}
#[test]
fn seated_foot_cell_settles_exactly_on_the_render_anchor() {
// The UNIFIED zero-pop identity: for every occupies_pos Furniture (the
// seat kinds AND the home desk), the WALKING sprite anchor at
// seated_foot_cell(S) must equal the SEATED render anchor at pos — so the
// post-A* settle ends with zero pop on every arrival side. back_couch
// render for couch/sofa, waypoint render for stand, seated_anchor for the
// desk: ONE fn, the correctness lock for the whole convergence.
use pixtuoid_core::layout::{seated_foot_cell, Furniture};
for pos in [
Point { x: 40, y: 30 },
Point { x: 100, y: 60 },
Point { x: 6, y: 8 }, // near-origin: saturating_sub edge
] {
for w in [CHARACTER_SPRITE_W, 10] {
for f in [Furniture::Couch, Furniture::MeetingSofa] {
let s = seated_foot_cell(f, pos).expect("occupies_pos seat");
assert_eq!(
walking_anchor(s, w),
back_couch_anchor(pos, w),
"{f:?}: walking_anchor(S={s:?}) must equal back_couch_anchor(pos={pos:?}) w={w}",
);
}
let s = seated_foot_cell(Furniture::MeetingStand, pos).expect("occupies_pos seat");
assert_eq!(
walking_anchor(s, w),
waypoint_anchor(pos, w),
"MeetingStand: walking_anchor(S={s:?}) must equal waypoint_anchor(pos={pos:?}) w={w}",
);
// The home desk flows through the SAME fn — its S is
// desk_walk_anchor, its render seated_anchor. Same identity,
// proving the desk genuinely converged into Furniture.
let sd = seated_foot_cell(Furniture::Desk, pos).expect("desk is occupies_pos");
assert_eq!(
walking_anchor(sd, w),
seated_anchor(pos, w),
"Desk: walking_anchor(seated_foot_cell)={:?} must equal seated_anchor",
walking_anchor(sd, w),
);
}
// Obstacles have no fixed seat — their sprite renders AT the approach
// cell, so seated_foot_cell is None.
assert_eq!(seated_foot_cell(Furniture::Pantry, pos), None);
assert_eq!(seated_foot_cell(Furniture::VendingMachine, pos), None);
}
}
#[test]
fn settle_view_matches_the_seated_view_for_every_seat() {
// The unification guarantee: the sit-down settle and the seated render
// derive from ONE source (`SeatView::of`), so they can never disagree —
// the "sit facing the wrong way then snap" bug cannot recur, for current
// OR future seatable furniture (matched generically by having a settle
// foot-cell, not a hardcoded kind list).
use crate::tui::layout::{Facing, WaypointKind, MAX_VISIBLE_DESKS};
let l = Layout::compute(192, 158, MAX_VISIBLE_DESKS).expect("fits");
let seats: Vec<_> = l
.waypoints
.iter()
.filter(|w| pixtuoid_core::layout::seated_foot_cell(w.kind.furniture(), w.pos).is_some())
.collect();
assert!(
seats.iter().any(
|w| matches!(w.kind, WaypointKind::Couch | WaypointKind::MeetingSofa)
&& w.facing == Facing::North
),
"this layout size must have a window-facing (North) seat to exercise the fix"
);
for w in &seats {
let foot = pixtuoid_core::layout::seated_foot_cell(w.kind.furniture(), w.pos)
.expect("seat occupies_pos → has a settle foot cell");
let view = SeatView::of(w.kind, w.facing);
// The sit-down glide onto this seat renders in the seat's view, at the
// seat's stable z-key.
assert_eq!(
settle_seat_view(foot, &l),
Some((view, view.z_key_for_seat(w.pos))),
"settle onto {:?}@{:?} must use the seat view {view:?}",
w.kind,
w.pos
);
// Totality guard (review finding): a seat detected generically by its
// foot-cell must NOT fall through `SeatView::of`'s upright catch-all —
// every real seat maps to an explicitly-handled view, so a future seat
// added to the Furniture table without a `SeatView::of` arm fails HERE
// rather than silently rendering as an upright stander.
assert!(
matches!(w.kind, WaypointKind::Couch | WaypointKind::MeetingSofa)
|| matches!(w.kind, WaypointKind::MeetingStand),
"seat kind {:?} has a settle foot-cell but is not explicitly handled \
in SeatView::of — add an arm there",
w.kind
);
// Single-source invariant: the seated sprite and the sit-down settle
// agree on orientation (both back-view, or neither) — they cannot
// diverge because both come from `view`.
let seated_is_back = view.seated_sprite().0 == "back_couch";
let (settle_is_back, _) = view.settle_walk();
assert_eq!(
seated_is_back, settle_is_back,
"{:?}: seated render and sit-down settle must share orientation",
w.kind
);
// For seats whose foot-cell is offset from the centre (couch/sofa),
// the centre is an ordinary travel target — keeps travel facing.
if foot != w.pos {
assert_eq!(
settle_seat_view(w.pos, &l),
None,
"seat centre {:?} is not a settle foot cell",
w.pos
);
}
}
}
#[test]
fn settle_seat_view_recognizes_the_home_desk() {
// The home desk joins the unified settle: its chair (seated_foot_cell(Desk)
// = desk_walk_anchor) is a settle target, so the arrival glide onto it goes
// through SeatView::Front (front-facing, stable z-key) — same path as the
// sofas, no front-cross.
use crate::tui::layout::MAX_VISIBLE_DESKS;
use pixtuoid_core::layout::{desk_walk_anchor, Furniture};
let l = Layout::compute(192, 158, MAX_VISIBLE_DESKS).expect("fits");
let desk = *l.home_desks.first().expect("at least one home desk");
let chair = desk_walk_anchor(desk);
assert_eq!(
settle_seat_view(chair, &l),
Some((SeatView::Front, desk.y + DESK_SEAT_Z_OFF)),
"the desk chair {chair:?} must settle as SeatView::Front at the desk z-key"
);
// seated_foot_cell(Desk) is exactly desk_walk_anchor — the hook keys off it.
assert_eq!(
pixtuoid_core::layout::seated_foot_cell(Furniture::Desk, desk),
Some(chair)
);
// A non-chair cell near the desk is ordinary travel.
assert_eq!(
settle_seat_view(desk, &l),
None,
"the desk corner is not the chair"
);
}
#[test]
fn desk_settle_z_key_matches_the_seated_arm() {
// The desk's settle z-key (desk.y + DESK_SEAT_Z_OFF) must equal the z-key
// the seated desk arms use (anchor_no_breath.y + 12 with anchor =
// seated_anchor) so the glide and the settled render sort identically —
// and both stay below the desk furniture z-key (desk.y + 8).
for desk in [Point { x: 40, y: 30 }, Point { x: 100, y: 60 }] {
for w in [CHARACTER_SPRITE_W, 10] {
let seated_arm_z = seated_anchor(desk, w).y + 12;
assert_eq!(
desk.y + DESK_SEAT_Z_OFF,
seated_arm_z,
"desk settle z-key must equal the SeatedIdle/Typing arm z-key"
);
let fp_h = crate::tui::layout::desk_furniture_def()
.footprint
.expect("desk footprint")
.h;
assert!(
desk.y + DESK_SEAT_Z_OFF < desk.y + fp_h + DESK_FRONT_OVERHANG,
"desk sitter must sort behind the desk furniture"
);
}
}
}
#[test]
fn sit_arc_z_key_is_stable_and_on_the_right_side_of_its_furniture() {
// The z-sort flicker fix. The sit-down/stand-up GLIDE and the SEATED state
// must share ONE z-key (`z_key_for_seat`) so the agent never crosses its
// furniture's z-key mid-glide (pop in front of the sofa for a frame, then
// snap behind it). Asserts: (1) the seat z-key equals the historical
// AtWaypoint formula (seated render unchanged); (2) it lands the agent on
// the correct side of the furniture for every seat — behind a back-view
// sofa/couch, on top of (tie with) a front sofa, and in front of the
// meeting table for a stand.
use crate::tui::layout::{
furniture_def, z_sort_row, Anchor, Facing, Furniture, WaypointKind, MAX_VISIBLE_DESKS,
};
let l = Layout::compute(192, 158, MAX_VISIBLE_DESKS).expect("fits");
let mut saw_back = false;
for w in l
.waypoints
.iter()
.filter(|w| pixtuoid_core::layout::seated_foot_cell(w.kind.furniture(), w.pos).is_some())
{
let view = SeatView::of(w.kind, w.facing);
let z = view.z_key_for_seat(w.pos);
// (1) Behavior-preserving: equals the historical seated AtWaypoint key.
let historical = match view {
// back_couch_anchor.y + sprite_h(9) = (pos.y - 7) + 9
SeatView::Front | SeatView::Back => back_couch_anchor(w.pos, CHARACTER_SPRITE_W).y + 9,
// waypoint_anchor.y + sprite_h(12) + 3 = (pos.y - 12) + 12 + 3
SeatView::Side { .. } => waypoint_anchor(w.pos, CHARACTER_SPRITE_W).y + 12 + 3,
};
assert_eq!(
z, historical,
"{:?}@{:?}: seat z-key {z} must equal the historical AtWaypoint key {historical}",
w.kind, w.pos
);
// (2) Correct side of the furniture.
match w.kind {
WaypointKind::Couch => {
// Lounge couch furniture z-key = z_sort_row(Center, center, visual.h).
let couch_z = z_sort_row(
Anchor::Center,
w.pos,
furniture_def(Furniture::Couch).visual.h,
);
assert!(
z < couch_z,
"couch sitter z {z} must be BEHIND the couch back {couch_z}"
);
saw_back = true;
}
WaypointKind::MeetingSofa => {
// Furniture z-key: faces_away (North) → sofa.y+3; else sofa.y+2.
if w.facing == Facing::North {
assert!(z < w.pos.y + 3, "back sofa sitter z {z} must be < sofa.y+3");
saw_back = true;
} else {
// Front sofa: tie at sofa.y+2 (insertion order keeps the
// sitter on top).
assert!(
z <= w.pos.y + 2,
"front sofa sitter z {z} must be <= sofa.y+2"
);
}
}
WaypointKind::MeetingStand => {
// Stand clears the meeting table row it stands beside.
assert!(
z > w.pos.y + 2,
"stand z {z} must clear the table at pos.y+2"
);
}
_ => {}
}
}
assert!(
saw_back,
"layout must contain a back-view seat to exercise the flicker fix"
);
}
#[test]
fn desk_occupant_always_sorts_behind_its_desk() {
// The same "agent on the correct side of its furniture" guarantee the
// wander-seat invariant gives, extended to the home desk so EVERY seatable
// is covered. A seated or standing desk occupant must y-sort BEHIND the
// desk cubicle (which sorts at `desk.y + footprint.h + DESK_FRONT_OVERHANG`
// — pinned by `desk_z_key_is_footprint_front_plus_overhang`). The desk
// keeps its own render arms (different sprite/work-state by design), but
// ties its character z-key to its furniture z-key so a footprint or anchor
// edit can never drift the agent in front of its own desk (no flicker,
// matching the wander seats — the z-order GUARANTEE is unified even though
// the render code is intentionally not).
let fp_h = crate::tui::layout::desk_furniture_def()
.footprint
.expect("desk has a footprint")
.h;
for desk in [Point { x: 40, y: 30 }, Point { x: 100, y: 60 }] {
for w in [CHARACTER_SPRITE_W, 10] {
let desk_furniture_z = desk.y + fp_h + DESK_FRONT_OVERHANG;
// SeatedIdle / SeatedThinking / SeatedTyping z-key.
let seated_z = seated_anchor(desk, w).y + 12;
// StandingAtDesk z-key.
let standing_z = standing_at_desk_anchor(desk, w).y + 12;
assert!(
seated_z < desk_furniture_z,
"seated desk occupant z {seated_z} must be BEHIND the desk {desk_furniture_z}"
);
assert!(
standing_z < desk_furniture_z,
"standing desk occupant z {standing_z} must be BEHIND the desk {desk_furniture_z}"
);
}
}
}
#[test]
fn desk_z_key_is_footprint_front_plus_overhang() {
// The DeskCubicle z-sort baseline is `desk.y + footprint.h +
// DESK_FRONT_OVERHANG` — footprint-front-derived (consistent with the
// waypoint/wall baselines), not a bare sprite-bottom literal. Equals
// the historical `desk.y + 8` (6 + 2). Locks the relationship so a
// footprint or overhang edit surfaces here, not as a layering bug.
let fp_h = crate::tui::layout::desk_furniture_def()
.footprint
.expect("desk has a footprint")
.h;
assert_eq!(fp_h + DESK_FRONT_OVERHANG, 8, "desk z-key offset (was +8)");
}
#[test]
fn every_pod_occludes_via_overhang() {
// Occlusion is emergent now (no `occludes_behind` cap): every aisle pod's
// sprite is TALLER than its shallow south-anchored ground footprint, so a
// walker parks deep behind it and the overhang's own y-sort hides them.
// Exhaustive over PodDecor::ALL so a new pod kind is forced through this.
use crate::tui::layout::{furniture_def, PodDecor, Size};
assert_eq!(
PodDecor::ALL.len(),
5,
"PodDecor variant added/removed — update ALL (and this count)"
);
for &kind in PodDecor::ALL {
let def = furniture_def(kind.furniture());
// z-sort precondition: the pod-decor loop anchors at
// `center_pin_south_offset(visual.1)`, so a 0-height visual would
// sort the sprite at its own center. Every pod must have visible h.
assert!(
def.visual.h > 0,
"{kind:?}: pod decor needs a non-zero visual height for the z-sort"
);
// The overhang IS the occlusion: the sprite must rise above its
// ground base, else a walker behind it wouldn't be hidden.
let Size { h: fh, .. } = def.footprint.expect("aisle pod has a ground footprint");
assert!(
def.visual.h > fh,
"{kind:?}: aisle pod must overhang its footprint to occlude (visual.h {} > footprint.h {fh})",
def.visual.h
);
}
}
#[test]
fn back_view_seats_sort_over_their_sitter() {
// Occlusion for BOTH back-view seat renderers (lounge couch + the
// north meeting sofa): the furniture must y-sort OVER the back-view
// sitter so the sofa back occludes the body. The sitter's z-key is
// `base + 2` (back_couch_anchor stand-7 + sprite_h 9); the back
// furniture is `base + 3`. Lounge couch (`center.y + 3`) and the north
// meeting sofa (`sofa.y + 3`) both satisfy it.
let base: u16 = 40;
let sitter = (base - 7) + 9; // = base + 2
let couch_furniture = base + 3; // WaypointCouch drawable
let back_meeting_sofa = base + 3; // faces_away meeting sofa
assert!(couch_furniture > sitter, "couch must sort over its sitter");
assert!(
back_meeting_sofa > sitter,
"north meeting sofa must sort over its sitter"
);
}
#[test]
fn character_anchor_y_exceeds_desk_when_south_of_it() {
// The bug-fix invariant: a character whose feet (anchor.y + 12)
// land BELOW the desk's bottom row (desk.y + 8) must sort AFTER
// the desk and therefore paint on top.
let desk_y: u16 = 20;
let desk_anchor_y = desk_y + 8;
let char_feet_anchor = (desk_y + 10) + 12; // walker south of desk
assert!(
char_feet_anchor > desk_anchor_y,
"walker south of desk must sort after it: char={char_feet_anchor}, desk={desk_anchor_y}"
);
}
#[test]
fn character_anchor_y_below_desk_when_seated_at_it() {
// Inverse invariant — a SEATED character at this desk has feet
// that land ABOVE the desk's bottom (because they're tucked
// under the desktop). They must sort BEFORE the desk so the
// desk occludes their lower body in top-down view.
let desk_y: u16 = 20;
let seated_anchor = seated_anchor(Point { x: 0, y: desk_y }, CHARACTER_SPRITE_W);
let char_feet_anchor = seated_anchor.y + 12;
let desk_anchor_y = desk_y + 8;
assert!(
char_feet_anchor < desk_anchor_y,
"seated char must sort before desk: char={char_feet_anchor}, desk={desk_anchor_y}"
);
}
// --- compute_door_frame_idx -------------------------------------------
fn entry_slot(created_at_ms_ago: u64, now: SystemTime) -> AgentSlot {
let id = pixtuoid_core::AgentId::from_transcript_path("/door.jsonl");
let mut s = make_slot(id, ActivityState::Idle);
s.created_at = now - std::time::Duration::from_millis(created_at_ms_ago);
s
}
fn exit_slot(exit_ms_ago: u64, now: SystemTime) -> AgentSlot {
let id = pixtuoid_core::AgentId::from_transcript_path("/exit.jsonl");
let mut s = make_slot(id, ActivityState::Idle);
s.created_at = now - std::time::Duration::from_secs(300);
s.exiting_at = Some(now - std::time::Duration::from_millis(exit_ms_ago));
s
}
#[test]
fn door_frame_closed_when_no_agents() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
assert_eq!(compute_door_frame_idx(&[], now, 0), 0);
}
#[test]
fn door_frame_just_spawned_is_half_open() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
// 50 ms into the 200 ms opening ramp — first half = frame 1.
let slot = entry_slot(50, now);
assert_eq!(compute_door_frame_idx(&[slot], now, 0), 1);
}
#[test]
fn door_frame_after_opening_ramp_is_fully_open() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
// 150 ms (still inside opening ramp but past midpoint) → frame 2.
let s1 = entry_slot(150, now);
assert_eq!(compute_door_frame_idx(&[s1], now, 0), 2);
// 2 s into the 4 s window → fully open.
let s2 = entry_slot(2_000, now);
assert_eq!(compute_door_frame_idx(&[s2], now, 0), 2);
}
#[test]
fn door_frame_closing_then_closed_at_end_of_entry() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
// 150 ms left in the entry window → closing ramp first half → frame 1.
let mid_close = entry_slot(pose::ENTRY_ANIMATION_MS - 150, now);
assert_eq!(compute_door_frame_idx(&[mid_close], now, 0), 1);
// 50 ms left → closing ramp final half → frame 0 (closed).
let near_end = entry_slot(pose::ENTRY_ANIMATION_MS - 50, now);
assert_eq!(compute_door_frame_idx(&[near_end], now, 0), 0);
}
#[test]
fn door_frame_expired_entry_contributes_nothing() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
// Older than the 4 s entry window → no contribution.
let old = entry_slot(pose::ENTRY_ANIMATION_MS + 1, now);
assert_eq!(compute_door_frame_idx(&[old], now, 0), 0);
}
#[test]
fn door_frame_exit_window_uses_4500ms_total() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
// 2 s into a 4.5 s exit window → mid-flight → fully open.
let exiting = exit_slot(2_000, now);
assert_eq!(compute_door_frame_idx(&[exiting], now, 0), 2);
}
#[test]
fn door_frame_takes_max_across_agents() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
let opening = entry_slot(50, now); // frame 1
let open = entry_slot(2_000, now); // frame 2
assert_eq!(compute_door_frame_idx(&[opening, open], now, 0), 2);
}
#[test]
fn door_frame_uses_physics_window_when_nonzero() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
// Slot spawned 3 s ago; with old ENTRY_ANIMATION_MS=4000 it would still
// be mid-flight. Supply a short physics window (2500 ms) so it reads as
// near the closing ramp instead.
let short_window_ms: u64 = 2_500;
// elapsed=3000, total=2500 → elapsed > total → door should be in closing
// ramp or closed (remaining = 0 → frame 0).
let slot = entry_slot(3_000, now);
let frame = compute_door_frame_idx(&[slot], now, short_window_ms);
assert_eq!(
frame, 0,
"with short physics window elapsed>total should yield closed door, got frame {frame}"
);
// Slot spawned 500 ms ago; physics window = 2500 ms → still well in the
// middle (fully open frame = 2).
let slot_mid = entry_slot(500, now);
let frame_mid = compute_door_frame_idx(&[slot_mid], now, short_window_ms);
assert_eq!(
frame_mid, 2,
"500ms into 2500ms window should be fully open, got frame {frame_mid}"
);
}
#[test]
fn weather_state_covers_all_variants() {
let mut seen = std::collections::HashSet::new();
let base = SystemTime::UNIX_EPOCH;
for cycle in 0..200u64 {
let now = base + std::time::Duration::from_secs(cycle * 600);
seen.insert(std::mem::discriminant(&background::weather_state(now)));
}
assert!(
seen.len() >= 8,
"expected all 8 weather variants in 200 cycles, got {}",
seen.len()
);
}
#[test]
fn weather_state_deterministic() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000);
let a = background::weather_state(now);
let b = background::weather_state(now);
assert_eq!(a, b);
}
#[test]
fn weather_state_changes_across_cycles() {
let mut states = Vec::new();
let base = SystemTime::UNIX_EPOCH;
for cycle in 0..20u64 {
states.push(background::weather_state(
base + std::time::Duration::from_secs(cycle * 600),
));
}
let unique: std::collections::HashSet<_> = states.iter().map(std::mem::discriminant).collect();
assert!(unique.len() >= 2, "weather should vary across cycles");
}
#[test]
fn sunset_strength_varies_across_day() {
let mut strengths = Vec::new();
let base = SystemTime::UNIX_EPOCH;
for hour in 0..24u64 {
strengths.push(background::sunset_strength(
base + std::time::Duration::from_secs(hour * 3600),
));
}
let has_zero = strengths.iter().any(|s| *s < 0.05);
let has_nonzero = strengths.iter().any(|s| *s > 0.1);
assert!(has_zero, "sunset should be ~0 at some hours");
assert!(has_nonzero, "sunset should be >0 at dawn/dusk hours");
}
// --- waypoint_rank_offset_x decollision table -------------------------
#[test]
fn waypoint_rank_offset_x_decollision_table() {
use super::anchors::waypoint_rank_offset_x;
use crate::tui::layout::WaypointKind;
// rank 0 = first arrival, no offset, for every kind.
assert_eq!(waypoint_rank_offset_x(WaypointKind::Couch, 0), 0);
assert_eq!(waypoint_rank_offset_x(WaypointKind::Pantry, 0), 0);
// Couch decollision is ±6 (3 seats on a 20px sofa).
assert_eq!(waypoint_rank_offset_x(WaypointKind::Couch, 1), 6);
assert_eq!(waypoint_rank_offset_x(WaypointKind::Couch, 2), -6);
assert_eq!(
waypoint_rank_offset_x(WaypointKind::Couch, 3),
0,
"rank >2 collapses to 0"
);
// Generic kinds step aside ±9.
assert_eq!(waypoint_rank_offset_x(WaypointKind::Pantry, 1), 9);
assert_eq!(waypoint_rank_offset_x(WaypointKind::Pantry, 2), -9);
assert_eq!(
waypoint_rank_offset_x(WaypointKind::Pantry, 5),
0,
"rank >2 collapses to 0"
);
}
// --- tool_glow_tint token arms ----------------------------------------
#[test]
fn tool_glow_tint_maps_delegation_search_and_unknown_tokens() {
let id = pixtuoid_core::AgentId::from_transcript_path("/g.jsonl");
let glow = &crate::tui::theme::NORMAL.tool_glow;
let active = |detail: &str| {
make_slot(
id,
ActivityState::Active {
tool_use_id: None,
detail: Some(Arc::from(detail)),
},
)
};
// Agent / Task → glow.agent.
assert_eq!(
palette::tool_glow_tint(&active("Agent code-reviewer"), glow),
Some(glow.agent)
);
assert_eq!(
palette::tool_glow_tint(&active("Task: do X"), glow),
Some(glow.agent)
);
// Grep / Glob → glow.grep.
assert_eq!(
palette::tool_glow_tint(&active("Grep pattern"), glow),
Some(glow.grep)
);
assert_eq!(
palette::tool_glow_tint(&active("Glob **/*.rs"), glow),
Some(glow.grep)
);
// Unknown token → glow.default.
assert_eq!(
palette::tool_glow_tint(&active("WebFetch https://x"), glow),
Some(glow.default)
);
}
// --- SeatView::of obstacle (upright) arm -------------------------------
#[test]
fn seat_view_of_obstacle_kinds_is_upright_unflipped() {
use crate::tui::layout::{Facing, WaypointKind};
// The non-seat obstacle kinds dispatch directly in production and never
// reach a seated render through SeatView, but the explicit arm maps them to
// the upright default (Side { flip: false }) for totality.
for kind in [
WaypointKind::Pantry,
WaypointKind::PhoneBooth,
WaypointKind::StandingDesk,
WaypointKind::VendingMachine,
WaypointKind::Printer,
] {
assert_eq!(
SeatView::of(kind, Facing::South),
SeatView::Side { flip: false },
"{kind:?} must map to the upright default",
);
}
}
// --- paint_character_at defensive missing-anim early return -----------
#[test]
fn paint_character_at_missing_anim_is_a_noop() {
let pack = crate::tui::embedded_pack::load_sprite_pack(None).expect("embedded pack");
let mut cache = FrameCache::new();
let id = pixtuoid_core::AgentId::from_transcript_path("/c.jsonl");
let slot = make_slot(id, ActivityState::Idle);
let bg = Rgb { r: 4, g: 5, b: 6 };
let mut buf = RgbBuffer::filled(40, 40, bg);
paint_character_at(
&mut buf,
"does_not_exist",
0,
Point { x: 20, y: 20 },
&slot,
&pack,
false,
None,
&mut cache,
);
for y in 0..buf.height {
for x in 0..buf.width {
assert_eq!(
buf.get(x, y),
bg,
"missing character anim must paint nothing"
);
}
}
}
// --- glass bounds clamps ----------------------------------------------
#[test]
fn glass_wall_h_clamps_below_buffer_bottom() {
// y_top near the buffer bottom → the cap+face row span exceeds the height,
// so the per-row `y >= bh continue` fires. Must not panic; in-bounds rows
// still paint.
let theme = crate::tui::theme::theme_by_name("normal").expect("theme");
let bh = 16u16;
let mut buf = RgbBuffer::filled(40, bh, Rgb { r: 0, g: 0, b: 0 });
paint_glass_wall_h(&mut buf, theme, 0, 39, bh - 1);
// The cap rows that ARE in-bounds (above bh) must have painted something.
let mut painted = false;
for y in 0..bh {
for x in 0..40u16 {
if buf.get(x, y) != (Rgb { r: 0, g: 0, b: 0 }) {
painted = true;
}
}
}
assert!(painted, "in-bounds glass rows should still paint");
}
#[test]
fn glass_wall_v_clamps_past_right_edge() {
// x_left == bw-1 → x_left+dx for dx>=1 exceeds the width, exercising the
// `x >= bw continue`. Must not panic.
let theme = crate::tui::theme::theme_by_name("normal").expect("theme");
let bw = 12u16;
let mut buf = RgbBuffer::filled(bw, 40, Rgb { r: 0, g: 0, b: 0 });
paint_glass_wall_v(&mut buf, theme, bw - 1, 5, 20);
// The dx==0 column (in-bounds) must have painted.
let mut painted = false;
for y in 5..21u16 {
if buf.get(bw - 1, y) != (Rgb { r: 0, g: 0, b: 0 }) {
painted = true;
}
}
assert!(painted, "the in-bounds glass column should paint");
}
// --- effects: pet hearts edges ------------------
#[test]
fn pet_hearts_skip_dead_and_faded_hearts() {
use super::effects::paint_pet_hearts;
let bg = Rgb { r: 0, g: 0, b: 0 };
let cat_pos = Point { x: 20, y: 20 };
let painted_count = |elapsed_ms: u64| -> usize {
let mut buf = RgbBuffer::filled(40, 40, bg);
paint_pet_hearts(&mut buf, cat_pos, elapsed_ms);
(0..40u16)
.flat_map(|y| (0..40u16).map(move |x| (x, y)))
.filter(|&(x, y)| buf.get(x, y) != bg)
.count()
};
// Past HEART_LIFE_MS (1550) for the first heart but the later staggered
// hearts are also dead (i=1 starts at 150 → dead by 1700; ... i=3 at 450 →
// dead by 2000). At elapsed=2100 all four hearts are past their life → the
// `local_ms >= HEART_LIFE_MS continue` (152) fires for each → nothing paints.
assert_eq!(
painted_count(2_100),
0,
"all hearts past their life → none paint"
);
// A fresh frame DOES paint (proves the count isn't vacuously 0).
assert!(painted_count(0) > 0, "first heart paints at t=0");
// alpha < 0.05 continue (158): for heart i=0, local_ms in [1473,1549] gives
// alpha just under 0.05 → that heart is skipped while still within its life.
// Compare the heart count at elapsed=1500 (i=0 faded) vs a fresh stagger
// where i=0 is bright — fewer hearts at the faded frame proves 158 fired.
// (i=1..3 may still be alive at 1500, so just assert no panic + bounded.)
let faded = painted_count(1_500);
assert!(
faded <= painted_count(300),
"the faded heart drops out (alpha<0.05)"
);
}
// --- furniture decor guards + bodies + corner clip --------------------
#[test]
fn furniture_room_decor_too_small_bounds_are_noops() {
use super::furniture::{
paint_doormat, paint_notice_board, paint_trash_bin, paint_water_cooler,
};
let theme = crate::tui::theme::theme_by_name("normal").expect("theme");
let bg = Rgb { r: 9, g: 9, b: 9 };
let small = crate::tui::layout::Bounds {
x: 2,
y: 2,
width: 8,
height: 8,
};
let assert_noop = |f: &dyn Fn(&mut RgbBuffer)| {
let mut buf = RgbBuffer::filled(60, 60, bg);
f(&mut buf);
for y in 0..buf.height {
for x in 0..buf.width {
assert_eq!(buf.get(x, y), bg, "too-small bounds must paint nothing");
}
}
};
assert_noop(&|b| paint_notice_board(b, small, theme));
assert_noop(&|b| paint_doormat(b, small, theme));
assert_noop(&|b| paint_water_cooler(b, small, theme));
assert_noop(&|b| paint_trash_bin(b, small));
}
#[test]
fn furniture_room_decor_large_bounds_paint() {
use super::furniture::{
paint_doormat, paint_notice_board, paint_trash_bin, paint_water_cooler,
};
let theme = crate::tui::theme::theme_by_name("normal").expect("theme");
let bg = Rgb { r: 9, g: 9, b: 9 };
// A generous room: width 40, height 40, well above every guard threshold.
let big = crate::tui::layout::Bounds {
x: 4,
y: 4,
width: 40,
height: 40,
};
let assert_paints = |f: &dyn Fn(&mut RgbBuffer)| {
let mut buf = RgbBuffer::filled(120, 80, bg);
f(&mut buf);
let painted = (0..80u16)
.flat_map(|y| (0..120u16).map(move |x| (x, y)))
.any(|(x, y)| buf.get(x, y) != bg);
assert!(painted, "large bounds must paint the decor");
};
assert_paints(&|b| paint_notice_board(b, big, theme));
assert_paints(&|b| paint_doormat(b, big, theme));
assert_paints(&|b| paint_water_cooler(b, big, theme));
assert_paints(&|b| paint_trash_bin(b, big));
}
#[test]
fn furniture_corner_clip_does_not_panic() {
use super::furniture::{paint_area_rug, paint_pantry_table, paint_side_table};
let theme = crate::tui::theme::theme_by_name("normal").expect("theme");
// Centre each piece near the (0,0) corner so part of the sprite has a
// negative px/py, exercising the `< 0` / out-of-range `continue` clamps.
let mut buf = RgbBuffer::filled(40, 40, Rgb { r: 0, g: 0, b: 0 });
paint_area_rug(&mut buf, 1, 1, 10, 8, theme);
paint_side_table(&mut buf, 1, 1, theme);
paint_pantry_table(&mut buf, 1, 1, theme);
// No panic reaching here is the assertion (negative coords are clipped).
}
#[test]
fn weather_gallery_manifest_matches_the_weather_enum() {
// site/src/weather.json drives the site's weather gallery AND the gen-media
// render loop; the `Weather` enum drives what actually renders. Site CI never
// runs the binary, so nothing else ties the two together — this test is the
// bridge: manifest ids must equal the canonical names, in order. (A new or
// renamed variant fails here until the manifest + `just gen-media` art
// are updated with it.)
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../site/src/weather.json");
let json = match std::fs::read_to_string(path) {
Ok(s) => s,
// crates.io-packaged test runs don't ship the repo's site/ tree.
Err(_) => {
eprintln!("skipping: {path} not present (packaged build)");
return;
}
};
let manifest: Vec<serde_json::Value> =
serde_json::from_str(&json).expect("weather.json parses");
let ids: Vec<&str> = manifest
.iter()
.map(|w| {
w["id"]
.as_str()
.expect("weather.json entry has a string id")
})
.collect();
assert_eq!(
ids,
weather_names(),
"site/src/weather.json ids must match Weather::ALL names in order — \
update the manifest + run `just gen-media` when the enum changes"
);
}