返回 CodeWhale
tests.rs
1 //! Conformance against the vendored JavaScript oracle.
2 //!
3 //! The receipts in `vendor/whale-character-v2/handoff` were produced by the
4 //! reference with seed 11, ambient motion off and `step(1/30)` exactly. The
5 //! Rust Director replays the same inputs and must reproduce every pose
6 //! within floating-point tolerance, the packed 32×16 terminal bytes, and the
7 //! SHA-256 geometry receipt over the visible named paths.
8
9 use super::acting::{Activity, Context, Director, Event, Options, Presence, Span, acting_for};
10 use super::data::{Act, N, PARAMS, Pose, p, tables};
11 use super::rig::{
12 CRUISE_DIRECTION, Direction, MARK_DIRECTION, OPEN_DIRECTION, build, path_string, rig_pose,
13 signed_area,
14 };
15 use super::scene::{Grid, View, braille, colored_braille, rasterize, scene};
16 use super::stage::{self, Inputs, Stage, Tier};
17 use super::{Role, Shape, props};
18 use serde_json::{Value, json};
19 use sha2::{Digest, Sha256};
20 use std::time::{Duration, Instant};
21
22 const MOTION: &str = include_str!("../../tests/fixtures/whale-motion/motion-conformance.json");
23 const SCENARIOS: &str = include_str!("../../tests/fixtures/whale-motion/scenarios.json");
24 const SCENARIO_RECEIPTS: &str =
25 include_str!("../../tests/fixtures/whale-motion/scenario-conformance.json");
26
27 /// `catalogue.js` CASES: action, label, presence, owner kind.
28 const CASES: [(&str, &str, &str, Option<&str>); 17] = [
29 ("rest", "Resting", "Idle", None),
30 ("listen", "Listening", "Listening", None),
31 ("think", "Thinking", "Thinking", None),
32 ("busy", "Plain work", "Working", Some("unknown")),
33 ("read", "Read", "Working", Some("reading")),
34 ("search", "Search", "Working", Some("searching")),
35 ("write", "Edit", "Working", Some("editing")),
36 ("run", "Run", "Working", Some("executing")),
37 ("browse", "Browse", "Working", Some("browsing")),
38 ("talk", "Reply", "Working", Some("responding")),
39 ("pod", "Delegate · 3 agents", "Working", Some("delegating")),
40 ("needs", "Needs you", "NeedsYou", None),
41 ("done", "Done", "Done", None),
42 ("hmm", "Stuck · proposed", "Stuck", None),
43 ("sleep", "Asleep", "Offline", None),
44 ("computer", "Computer use", "Working", Some("computer")),
45 ("connect", "Connected app", "Working", Some("network")),
46 ];
47
48 fn presence_named(name: &str) -> Presence {
49 match name {
50 "Offline" => Presence::Offline,
51 "Idle" => Presence::Idle,
52 "Listening" => Presence::Listening,
53 "Thinking" => Presence::Thinking,
54 "Working" => Presence::Working,
55 "NeedsYou" => Presence::NeedsYou,
56 "Done" => Presence::Done,
57 "Stuck" => Presence::Stuck,
58 other => panic!("presence {other}"),
59 }
60 }
61
62 /// `scenarios.js` context: Live, turn `fixture-turn`, completed.
63 fn fixture_context() -> Context {
64 Context {
65 live: true,
66 turn_id: Some("fixture-turn".into()),
67 status: Some("completed".into()),
68 now_ms: Some(1000.),
69 failed_at_ms: Some(0.),
70 }
71 }
72
73 /// Apply a JSON context patch the way `{...context, ...step.context}` does.
74 fn patched(patch: &Value) -> Context {
75 let mut context = fixture_context();
76 if let Some(object) = patch.as_object() {
77 for (key, value) in object {
78 match key.as_str() {
79 "freshness" => context.live = value.as_str() == Some("Live"),
80 "turnId" => context.turn_id = value.as_str().map(str::to_string),
81 "status" => context.status = value.as_str().map(str::to_string),
82 "nowMs" => context.now_ms = value.as_f64(),
83 "failedAtMs" => context.failed_at_ms = value.as_f64(),
84 _ => {}
85 }
86 }
87 }
88 context
89 }
90
91 fn activity_json(value: &Value) -> Option<Activity> {
92 value.as_object().map(|_| Activity {
93 kind: value["kind"].as_str().map(str::to_string),
94 observed: value["observed"].as_bool() == Some(true),
95 parallel: value["parallel"].as_f64(),
96 active: value["active"]
97 .as_array()
98 .map(|list| {
99 list.iter()
100 .map(|s| Span {
101 kind: s["kind"].as_str().unwrap().into(),
102 since_ms: s["sinceMs"].as_f64().unwrap(),
103 })
104 .collect()
105 })
106 .unwrap_or_default(),
107 })
108 }
109
110 fn fixture(seed: u32) -> Director {
111 Director::new(Options {
112 seed,
113 reduced: false,
114 ambient: false,
115 })
116 }
117
118 fn set_case(d: &mut Director, case: &(&str, &str, &str, Option<&str>)) {
119 let (_, _, presence, kind) = *case;
120 let activity = kind.map(|kind| Activity {
121 kind: Some(kind.into()),
122 observed: kind != "unknown",
123 parallel: Some(3.),
124 active: Vec::new(),
125 });
126 let context = Context {
127 status: Some(
128 if presence == "Stuck" {
129 "failed"
130 } else {
131 "completed"
132 }
133 .into(),
134 ),
135 ..fixture_context()
136 };
137 d.set(presence_named(presence), activity, context);
138 }
139
140 fn assert_pose(actual: &Pose, expected: &Value, what: &str) {
141 for (i, name) in PARAMS.iter().enumerate() {
142 let want = expected[*name]
143 .as_f64()
144 .unwrap_or_else(|| panic!("{what}: {name}"));
145 let got = actual[i];
146 assert!(
147 (got - want).abs() <= 1e-9 * (1. + want.abs()),
148 "{what}: {name} = {got}, oracle {want}"
149 );
150 }
151 }
152
153 fn geometry_hash(d: &Director) -> String {
154 let parts = scene(d, View::hero(512., 1.));
155 let text = parts
156 .shapes
157 .iter()
158 .filter(|s| s.opacity >= 0.5)
159 .map(|s| format!("{}:{}", s.id, path_string(&s.path)))
160 .collect::<Vec<_>>()
161 .join("\n");
162 Sha256::digest(text.as_bytes())
163 .iter()
164 .map(|b| format!("{b:02x}"))
165 .collect()
166 }
167
168 fn advance(d: &mut Director, frames: usize) {
169 for _ in 0..frames {
170 d.step(1. / 30.);
171 }
172 }
173
174 #[test]
175 fn the_153_motion_samples_match_pose_terminal_bytes_and_geometry() {
176 let frames: Vec<Value> = serde_json::from_str(MOTION).unwrap();
177 assert_eq!(frames.len(), CASES.len() * 9);
178 let (mut poses, mut bytes, mut hashes) = (0, 0, 0);
179 let mut misses = Vec::new();
180 for case in &CASES {
181 let mut d = fixture(11);
182 set_case(&mut d, case);
183 let mut at = 0;
184 for sample in frames.iter().filter(|f| f["state"] == case.0) {
185 let frame = sample["frame"].as_u64().unwrap() as usize;
186 while at < frame {
187 d.step(1. / 30.);
188 at += 1;
189 }
190 assert_eq!(d.acting.id(), case.0);
191 assert_pose(&d.pose(), &sample["pose"], &format!("{}@{frame}", case.0));
192 poses += 1;
193 let calves: Vec<f64> = d.calves.iter().map(|c| c.value).collect();
194 for (i, want) in sample["visibleCalves"]
195 .as_array()
196 .unwrap()
197 .iter()
198 .enumerate()
199 {
200 assert!((calves[i] - want.as_f64().unwrap()).abs() < 1e-9);
201 }
202 if braille(&d, 32, 16).hex() == sample["brailleHex"].as_str().unwrap() {
203 bytes += 1;
204 } else {
205 misses.push(format!("braille {}@{frame}", case.0));
206 }
207 assert_eq!(
208 colored_braille(&d, 32, 16, true).grid.hex(),
209 sample["brailleHex"].as_str().unwrap(),
210 "colored geometry {}@{frame}",
211 case.0
212 );
213 if geometry_hash(&d) == sample["geometryHash"].as_str().unwrap() {
214 hashes += 1;
215 } else {
216 misses.push(format!("geometry {}@{frame}", case.0));
217 }
218 }
219 }
220 assert_eq!(poses, 153);
221 assert_eq!((bytes, hashes), (153, 153), "mismatches: {misses:?}");
222 }
223
224 #[test]
225 fn the_32_owner_scenarios_reproduce_actions_calves_poses_and_spouts() {
226 let fixtures: Value = serde_json::from_str(SCENARIOS).unwrap();
227 let receipts: Vec<Value> = serde_json::from_str(SCENARIO_RECEIPTS).unwrap();
228 let scenarios = fixtures["scenarios"].as_array().unwrap();
229 assert_eq!(scenarios.len(), 32);
230 assert_eq!(receipts.len(), 32);
231 let mut samples = 0;
232 for scenario in scenarios {
233 let id = scenario["id"].as_str().unwrap();
234 let receipt = receipts.iter().find(|r| r["id"] == id).expect(id);
235 let mut d = fixture(11);
236 let mut frame = 0;
237 for (index, step) in scenario["steps"].as_array().unwrap().iter().enumerate() {
238 let after = step["after"].as_u64().unwrap_or(0) as usize;
239 advance(&mut d, after);
240 frame += after;
241 if let Some(event) = step.get("event") {
242 let kind = event["kind"].as_str().unwrap().to_string();
243 d.event(match event["type"].as_str().unwrap() {
244 "tool" => Event::Tool { kind },
245 _ => Event::Failed {
246 kind,
247 status: event["status"].as_str().map(str::to_string),
248 },
249 });
250 } else {
251 d.set(
252 presence_named(step["presence"].as_str().unwrap()),
253 activity_json(&step["activity"]),
254 patched(&step["context"]),
255 );
256 }
257 assert_eq!(d.acting.id(), step["expected"].as_str().unwrap(), "{id}");
258 if let Some(calves) = step["calves"].as_u64() {
259 let n = d.calves.iter().filter(|c| c.target > 0.).count();
260 assert_eq!(n as u64, calves, "{id}");
261 }
262 let expected = &receipt["frames"][index];
263 assert_eq!(expected["frame"].as_u64().unwrap() as usize, frame, "{id}");
264 assert_eq!(d.acting.id(), expected["acting"].as_str().unwrap(), "{id}");
265 assert_pose(&d.pose(), &expected["pose"], &format!("{id}#{index}"));
266 let targets = d.calves.iter().filter(|c| c.target > 0.).count();
267 assert_eq!(
268 targets as u64,
269 expected["targetCalves"].as_u64().unwrap(),
270 "{id}"
271 );
272 let spouts = d
273 .emissions
274 .iter()
275 .filter(|e| matches!(e.kind, super::data::Emit::Spout { .. }))
276 .count();
277 assert_eq!(spouts as u64, expected["spouts"].as_u64().unwrap(), "{id}");
278 samples += 1;
279 }
280 if let Some(spouts) = scenario["spouts"].as_u64() {
281 advance(&mut d, 100);
282 let count = d
283 .emissions
284 .iter()
285 .filter(|e| matches!(e.kind, super::data::Emit::Spout { .. }))
286 .count();
287 assert_eq!(count as u64, spouts, "{id}");
288 }
289 }
290 assert!(samples >= 32);
291 }
292
293 #[test]
294 fn all_51_terminal_stills_match_the_reference_text() {
295 for (cols, rows) in [(20, 10), (32, 16), (48, 24)] {
296 for case in &CASES {
297 let mut d = Director::new(Options {
298 seed: 7,
299 reduced: true,
300 ambient: false,
301 });
302 set_case(&mut d, case);
303 let grid = braille(&d, cols, rows);
304 assert!(grid.cells.iter().any(|b| *b != 0));
305 let path = format!(
306 "{}/tests/fixtures/whale-motion/braille/{}-{cols}x{rows}.txt",
307 env!("CARGO_MANIFEST_DIR"),
308 case.0
309 );
310 let expected = std::fs::read_to_string(path).unwrap();
311 assert_eq!(
312 expected,
313 format!("{}\n{}\n", grid.text(), case.1),
314 "{}",
315 case.0
316 );
317 }
318 }
319 }
320
321 #[test]
322 fn presence_mapping_is_strict_about_observation_and_freshness() {
323 let live = fixture_context();
324 for case in &CASES {
325 let context = Context {
326 status: Some(
327 if case.2 == "Stuck" {
328 "failed"
329 } else {
330 "completed"
331 }
332 .into(),
333 ),
334 ..live.clone()
335 };
336 let activity = case.3.map(|kind| Activity {
337 kind: Some(kind.into()),
338 observed: kind != "unknown",
339 ..Activity::default()
340 });
341 assert_eq!(
342 acting_for(presence_named(case.2), activity.as_ref(), &context).id(),
343 case.0
344 );
345 }
346 let reading = Activity {
347 kind: Some("reading".into()),
348 observed: true,
349 ..Activity::default()
350 };
351 // Missing or stale freshness is plain work.
352 let stale = Context {
353 live: false,
354 ..live.clone()
355 };
356 assert_eq!(
357 acting_for(Presence::Working, Some(&reading), &stale),
358 Act::Busy
359 );
360 // Unobserved activity is plain work.
361 let unobserved = Activity {
362 observed: false,
363 ..reading.clone()
364 };
365 assert_eq!(
366 acting_for(Presence::Working, Some(&unobserved), &live),
367 Act::Busy
368 );
369 for kind in [
370 "unknown",
371 "waiting",
372 "error",
373 "unsupported",
374 "new-plugin-kind",
375 ] {
376 let activity = Activity {
377 kind: Some(kind.into()),
378 ..reading.clone()
379 };
380 assert_eq!(
381 acting_for(Presence::Working, Some(&activity), &live),
382 Act::Busy,
383 "{kind}"
384 );
385 }
386 // Error or waiting activity alone is never Stuck or Needs you.
387 assert_eq!(acting_for(Presence::Working, None, &live), Act::Busy);
388 }
389
390 #[test]
391 fn no_departed_state_emits_particles_or_continues_its_wave() {
392 let read = |d: &mut Director| {
393 d.set(
394 Presence::Working,
395 Some(Activity {
396 kind: Some("reading".into()),
397 observed: true,
398 ..Activity::default()
399 }),
400 fixture_context(),
401 )
402 };
403 for state in ["think", "needs", "hmm", "done"] {
404 let case = CASES.iter().find(|c| c.0 == state).unwrap();
405 for interrupt in [1, 6, 9, 18, 24, 40] {
406 let mut d = fixture(7);
407 set_case(&mut d, case);
408 advance(&mut d, interrupt);
409 let before = d.emissions.len();
410 read(&mut d);
411 assert!(d.particles.is_empty());
412 advance(&mut d, 90);
413 let owner = Act::from_id(state).unwrap();
414 assert!(
415 d.emissions.iter().skip(before).all(|e| e.owner != owner),
416 "{state}@{interrupt}"
417 );
418 assert_eq!(d.pose()[p::spout], 0.);
419 assert_eq!(d.pose()[p::splash], 0.);
420 assert!(
421 d.shots
422 .iter()
423 .all(|s| s.owner == Act::Read || s.exit || s.wake)
424 );
425 }
426 }
427 }
428
429 #[test]
430 fn calves_show_exact_reported_counts_and_fade_independently() {
431 for parallel in [
432 None,
433 Some(0.),
434 Some(-1.),
435 Some(f64::NAN),
436 Some(1.5),
437 Some(1.),
438 Some(2.),
439 Some(3.),
440 Some(8.),
441 ] {
442 let mut d = Director::new(Options {
443 reduced: true,
444 ambient: false,
445 ..Options::default()
446 });
447 d.set(
448 Presence::Working,
449 Some(Activity {
450 kind: Some("delegating".into()),
451 observed: true,
452 parallel,
453 active: Vec::new(),
454 }),
455 fixture_context(),
456 );
457 let n = match parallel {
458 Some(v) if v.fract() == 0. && v > 0. => v.min(3.) as usize,
459 _ => 0,
460 };
461 assert_eq!(d.calves.iter().filter(|c| c.value > 0.).count(), n);
462 let bodies = scene(&d, View::hero(512., 1.))
463 .shapes
464 .iter()
465 .filter(|s| s.id.starts_with("calf-") && s.id.ends_with("-body"))
466 .count();
467 assert_eq!(bodies, n);
468 }
469 let mut d = fixture(7);
470 set_case(&mut d, &CASES[10]);
471 advance(&mut d, 60);
472 let values: Vec<f64> = d.calves.iter().map(|c| c.value).collect();
473 set_case(&mut d, &CASES[4]);
474 d.step(1. / 30.);
475 for (i, calf) in d.calves.iter().enumerate() {
476 assert!(calf.value > 0. && calf.value < values[i]);
477 }
478 advance(&mut d, 90);
479 assert!(d.calves.iter().all(|c| c.value < 0.002));
480 }
481
482 #[test]
483 fn authored_offsets_begin_at_zero_and_beats_end_within_twelve_frames() {
484 for act in Act::ALL {
485 let data = tables().act(act);
486 for clip in [Some(&data.enter), data.exit.as_ref(), data.beat.as_ref()]
487 .into_iter()
488 .flatten()
489 {
490 for (param, keys) in &clip.tracks {
491 assert_eq!(keys[0].value, 0., "{}.{}", act.id(), PARAMS[*param]);
492 }
493 }
494 if let Some(beat) = &data.beat {
495 assert!(beat.dur <= 12., "{}", act.id());
496 }
497 }
498 }
499
500 #[test]
501 fn wake_opens_the_eye_at_once_and_reduced_motion_settles_on_the_poster() {
502 let mut d = fixture(7);
503 set_case(&mut d, &CASES[14]);
504 advance(&mut d, 100);
505 assert!(d.pose()[p::lid] > 0.98);
506 set_case(&mut d, &CASES[0]);
507 assert_eq!(d.acting, Act::Rest);
508 advance(&mut d, 4);
509 assert!(d.pose()[p::lid] < 0.8);
510 d.set_reduced(true);
511 assert_eq!(d.pose()[p::lid], d.target_for(Act::Rest)[p::lid]);
512 assert!(d.shots.is_empty());
513 set_case(&mut d, &CASES[14]);
514 set_case(&mut d, &CASES[0]);
515 d.set_reduced(false);
516 assert_eq!(d.acting, Act::Rest);
517 assert!(d.pose()[p::lid] < 0.1);
518 }
519
520 #[test]
521 fn beats_need_matching_fresh_onsets_and_never_replay() {
522 let mut d = fixture(7);
523 set_case(&mut d, &CASES[4]);
524 advance(&mut d, 60);
525 set_case(&mut d, &CASES[7]);
526 assert!(!d.event(Event::Tool {
527 kind: "reading".into()
528 }));
529 assert!(d.event(Event::Tool {
530 kind: "executing".into()
531 }));
532 assert!(d.shots.iter().any(|s| s.beat && s.owner == Act::Run));
533 let activity = Activity {
534 kind: Some("executing".into()),
535 observed: true,
536 parallel: None,
537 active: vec![
538 Span {
539 kind: "executing".into(),
540 since_ms: 10.,
541 },
542 Span {
543 kind: "reading".into(),
544 since_ms: 8.,
545 },
546 ],
547 };
548 let mut next = fixture(7);
549 next.set(Presence::Working, Some(activity.clone()), fixture_context());
550 let count = next.events.len();
551 next.set(Presence::Working, Some(activity.clone()), fixture_context());
552 assert_eq!(
553 next.events.len(),
554 count,
555 "a repeated onset is not a new beat"
556 );
557 advance(&mut next, 15);
558 assert!(!next.shots.iter().any(|s| s.beat));
559 // A stale report observes nothing.
560 let mut stale = fixture(7);
561 stale.set(
562 Presence::Working,
563 Some(activity),
564 Context {
565 live: false,
566 ..fixture_context()
567 },
568 );
569 assert!(stale.events.is_empty());
570 }
571
572 #[test]
573 fn done_flourishes_once_per_completed_turn() {
574 let spouts = |d: &Director| {
575 d.emissions
576 .iter()
577 .filter(|e| matches!(e.kind, super::data::Emit::Spout { .. }))
578 .count()
579 };
580 let mut d = fixture(7);
581 set_case(&mut d, &CASES[12]);
582 advance(&mut d, 30);
583 assert_eq!(spouts(&d), 1);
584 assert!(d.pose()[p::spout] > 0.9 && d.pose()[p::splash] > 0.7);
585 set_case(&mut d, &CASES[1]);
586 advance(&mut d, 20);
587 set_case(&mut d, &CASES[12]);
588 advance(&mut d, 100);
589 assert_eq!(spouts(&d), 1, "Listening then Done again cannot repeat it");
590 assert_eq!(d.pose()[p::spout], 0.);
591 assert_eq!(d.pose()[p::splash], 0.);
592 d.set(
593 Presence::Done,
594 None,
595 Context {
596 turn_id: Some("t2".into()),
597 ..fixture_context()
598 },
599 );
600 advance(&mut d, 30);
601 assert_eq!(spouts(&d), 2);
602 let mut anonymous = fixture(7);
603 anonymous.set(
604 Presence::Done,
605 None,
606 Context {
607 turn_id: None,
608 ..fixture_context()
609 },
610 );
611 advance(&mut anonymous, 100);
612 assert!(anonymous.emissions.is_empty());
613 // A canceled or failed turn is not a completion.
614 let mut canceled = fixture(7);
615 canceled.set(
616 Presence::Done,
617 None,
618 Context {
619 status: Some("canceled".into()),
620 ..fixture_context()
621 },
622 );
623 advance(&mut canceled, 100);
624 assert!(canceled.emissions.is_empty());
625 }
626
627 #[test]
628 fn failure_is_bounded_and_never_inferred_from_cancellation() {
629 let live = fixture_context();
630 for status in [
631 Some("canceled"),
632 Some("cancelled"),
633 Some("interrupted"),
634 Some("completed"),
635 None,
636 ] {
637 let context = Context {
638 status: status.map(str::to_string),
639 ..live.clone()
640 };
641 assert_eq!(acting_for(Presence::Stuck, None, &context), Act::Rest);
642 }
643 let expired = Context {
644 status: Some("failed".into()),
645 now_ms: Some(3000.),
646 ..live.clone()
647 };
648 assert_eq!(acting_for(Presence::Stuck, None, &expired), Act::Rest);
649 for index in [0, 14, 11, 12, 1] {
650 let mut d = fixture(7);
651 set_case(&mut d, &CASES[index]);
652 assert!(!d.event(Event::Failed {
653 kind: "executing".into(),
654 status: Some("failed".into())
655 }));
656 }
657 let mut d = fixture(7);
658 set_case(&mut d, &CASES[7]);
659 for status in [Some("canceled"), Some("interrupted"), None] {
660 assert!(!d.event(Event::Failed {
661 kind: "executing".into(),
662 status: status.map(str::to_string)
663 }));
664 }
665 assert!(d.event(Event::Failed {
666 kind: "executing".into(),
667 status: Some("failed".into())
668 }));
669 advance(&mut d, 13);
670 assert!(!d.shots.iter().any(|s| s.beat));
671 }
672
673 #[test]
674 fn the_pad_saturates_at_four_lines() {
675 let mut d = fixture(7);
676 set_case(&mut d, &CASES[6]);
677 for _ in 0..8 {
678 d.event(Event::Tool {
679 kind: "editing".into(),
680 });
681 advance(&mut d, 15);
682 }
683 assert_eq!(d.lines, 4.);
684 assert!(d.pose()[p::padLines] > 3.8);
685 }
686
687 #[test]
688 fn every_parameter_stays_within_its_rate_limit_across_state_pairs() {
689 // The oracle runs all 17×17 pairs at 30 and 60 fps; one frame rate and
690 // every pair keeps the debug test fast while exercising each envelope.
691 for from in &CASES {
692 for to in &CASES {
693 let mut d = fixture(7);
694 set_case(&mut d, from);
695 advance(&mut d, 110);
696 d.set(
697 presence_named(to.2),
698 to.3.map(|kind| Activity {
699 kind: Some(kind.into()),
700 observed: kind != "unknown",
701 parallel: Some(3.),
702 active: Vec::new(),
703 }),
704 Context {
705 turn_id: Some(format!("transition-{}{}", from.0, to.0)),
706 status: Some(
707 if to.2 == "Stuck" {
708 "failed"
709 } else {
710 "completed"
711 }
712 .into(),
713 ),
714 ..fixture_context()
715 },
716 );
717 let mut prev = d.pose();
718 for frame in 0..60 {
719 d.step(1. / 30.);
720 let pose = d.pose();
721 for i in 0..N {
722 assert!(pose[i].is_finite());
723 let delta = if i == p::wrenchSpin {
724 super::acting::angle_delta(pose[i], prev[i])
725 } else {
726 pose[i] - prev[i]
727 };
728 assert!(
729 delta.abs() <= tables().rates[i] + 1e-7,
730 "{}->{} {} @{frame}",
731 from.0,
732 to.0,
733 PARAMS[i]
734 );
735 }
736 prev = pose;
737 }
738 }
739 }
740 }
741
742 #[test]
743 fn named_parts_keep_topology_and_finite_cubic_coordinates() {
744 let mut topology = std::collections::HashMap::new();
745 for dir in [MARK_DIRECTION, CRUISE_DIRECTION, OPEN_DIRECTION] {
746 for case in &CASES {
747 for size in [16., 24., 32., 512.] {
748 let mut d = Director::new(Options {
749 reduced: true,
750 ..Options::default()
751 });
752 set_case(&mut d, case);
753 let parts = scene(
754 &d,
755 View {
756 dir,
757 ..View::fitted(size, 1.)
758 },
759 );
760 for part in &parts.shapes {
761 if ["calf-", "bubble-", "spout-"]
762 .iter()
763 .any(|x| part.id.starts_with(x))
764 {
765 continue;
766 }
767 let signature: Vec<usize> =
768 part.path.iter().map(|c| c.coords().len()).collect();
769 let known = topology.entry(part.id.clone()).or_insert(signature.clone());
770 assert_eq!(*known, signature, "{}", part.id);
771 assert!(
772 part.path
773 .iter()
774 .flat_map(|c| c.coords())
775 .all(|v| v.is_finite())
776 );
777 }
778 for id in [
779 "body", "pouch", "pleat-1", "pleat-2", "jaw-band", "eye", "eyelid", "flipper",
780 "flukes",
781 ] {
782 assert!(parts.shapes.iter().any(|s| s.id == id), "{id}");
783 }
784 }
785 }
786 }
787 }
788
789 #[test]
790 fn attentive_turns_keep_winding_and_area() {
791 for id in ["pleat-1", "pleat-2", "flipper"] {
792 for frame in 0..=30 {
793 let mut pose = rig_pose();
794 pose[p::yaw] = frame as f64 / 30.;
795 let parts = build(MARK_DIRECTION, &pose, 0, 24., 1.);
796 let shape = parts.shapes.iter().find(|s| s.id == id).unwrap();
797 assert!(
798 signed_area(&shape.path) < -0.8,
799 "{id} at turn frame {frame}"
800 );
801 }
802 }
803 let states = [
804 "rest", "think", "read", "search", "write", "browse", "needs", "hmm",
805 ];
806 for from in states {
807 for to in states {
808 let mut d = fixture(7);
809 set_case(&mut d, CASES.iter().find(|c| c.0 == from).unwrap());
810 advance(&mut d, 110);
811 set_case(&mut d, CASES.iter().find(|c| c.0 == to).unwrap());
812 for frame in 0..90 {
813 d.step(1. / 30.);
814 let parts = scene(&d, View::hero(512., 1.));
815 let fin = parts.shapes.iter().find(|s| s.id == "flipper").unwrap();
816 if fin.opacity > 0.5 {
817 assert!(signed_area(&fin.path) < -20., "{from}->{to}@{frame}");
818 }
819 }
820 }
821 }
822 }
823
824 #[test]
825 fn held_props_follow_the_whale_under_translation_rotation_and_scale() {
826 let theta = 17f64.to_radians();
827 let scale = 0.83;
828 for name in [
829 "read", "search", "write", "run", "browse", "computer", "connect",
830 ] {
831 let d = Director::new(Options::default());
832 let mut base = d.target_for(Act::from_id(name).unwrap());
833 base[p::x] = 0.;
834 base[p::y] = 0.;
835 base[p::rot] = 0.;
836 base[p::scale] = 1.;
837 let mut moved = base;
838 moved[p::x] = 9.;
839 moved[p::y] = -6.;
840 moved[p::rot] = 17.;
841 moved[p::scale] = scale;
842 let shapes =
843 |pose: &Pose| props::shapes(pose, &build(MARK_DIRECTION, pose, 0, 24., 1.), false);
844 let (before, after) = (shapes(&base), shapes(&moved));
845 for (a, b) in before.iter().zip(&after) {
846 if a.opacity <= 0. {
847 continue;
848 }
849 for (ca, cb) in a.path.iter().zip(&b.path) {
850 let (ca, cb) = (ca.coords(), cb.coords());
851 for j in (0..ca.len()).step_by(2) {
852 let x = 9. + scale * (ca[j] * theta.cos() - ca[j + 1] * theta.sin());
853 let y = -6. + scale * (ca[j] * theta.sin() + ca[j + 1] * theta.cos());
854 assert!(
855 (cb[j] - x).hypot(cb[j + 1] - y) < 1e-6,
856 "{name}/{} detached",
857 a.id
858 );
859 }
860 }
861 }
862 }
863 }
864
865 #[test]
866 fn attention_stays_in_frame_and_keeps_its_profile() {
867 let mut d = fixture(7);
868 set_case(&mut d, &CASES[0]);
869 advance(&mut d, 100);
870 set_case(&mut d, &CASES[11]);
871 for frame in 0..400 {
872 d.step(1. / 30.);
873 let dirs: [Direction; 3] = [MARK_DIRECTION, CRUISE_DIRECTION, OPEN_DIRECTION];
874 for dir in dirs {
875 let parts = scene(
876 &d,
877 View {
878 dir,
879 ..View::hero(512., 1.)
880 },
881 );
882 let find = |id: &str| parts.shapes.iter().find(|s| s.id == id).unwrap().opacity;
883 assert_eq!(find("flipper"), 0., "attention grows a second limb");
884 assert_eq!(find("eye-far"), 0., "attention becomes a frontal face");
885 for shape in parts.shapes.iter().filter(|s| s.opacity > 0.) {
886 for v in shape.path.iter().flat_map(|c| c.coords()) {
887 assert!(v.abs() < 62., "{} clips at frame {frame}", shape.id);
888 }
889 }
890 }
891 }
892 }
893
894 #[test]
895 fn braille_bits_holes_threshold_and_cell_aspect() {
896 use super::rig::{Cmd, Parts};
897 let rect = |x: f64, y: f64, w: f64, h: f64| {
898 vec![
899 Cmd::M([x, y]),
900 Cmd::C([x, y, x + w, y, x + w, y]),
901 Cmd::C([x + w, y, x + w, y + h, x + w, y + h]),
902 Cmd::C([x + w, y + h, x, y + h, x, y + h]),
903 Cmd::C([x, y + h, x, y, x, y]),
904 Cmd::Z,
905 ]
906 };
907 let shape = |id: &str, role, opacity, path| Shape {
908 id: id.into(),
909 path,
910 role,
911 opacity,
912 };
913 let parts = |shapes| Parts {
914 shapes,
915 anchors: Default::default(),
916 small: false,
917 unit_scale: 1.,
918 };
919 let body = shape("body", Role::Body, 1., rect(-62., -62., 124., 124.));
920 let hole = shape("eye", Role::Hole, 1., rect(-31., -31., 62., 62.));
921 let grid = rasterize(&parts(vec![body.clone(), hole]), 8, 4, 0.5);
922 assert_eq!(grid.cells[8 + 3], 0, "the eye is a real hole");
923 assert_eq!(grid.cells[0], 255);
924 let faint = Shape {
925 opacity: 0.49,
926 ..body.clone()
927 };
928 assert!(
929 rasterize(&parts(vec![faint]), 32, 16, 0.5)
930 .cells
931 .iter()
932 .all(|b| *b == 0)
933 );
934 let half = Shape {
935 opacity: 0.5,
936 ..body.clone()
937 };
938 assert!(
939 rasterize(&parts(vec![half]), 32, 16, 0.5)
940 .cells
941 .iter()
942 .any(|b| *b != 0)
943 );
944 let square = parts(vec![shape(
945 "body",
946 Role::Body,
947 1.,
948 rect(-15., -15., 30., 30.),
949 )]);
950 let dots = |g: Grid| g.cells.iter().map(|b| b.count_ones()).sum::<u32>();
951 assert!(dots(rasterize(&square, 32, 16, 1.)) < dots(rasterize(&square, 32, 16, 0.5)));
952 let one = Grid {
953 cols: 1,
954 rows: 1,
955 cells: vec![0x80],
956 };
957 assert_eq!(one.text(), "\u{2880}");
958 }
959
960 #[test]
961 fn reduced_motion_and_a_new_session_carry_no_residual_activity() {
962 let mut d = Director::new(Options {
963 seed: 11,
964 ..Options::default()
965 });
966 set_case(&mut d, &CASES[15]);
967 advance(&mut d, 80);
968 d.set_reduced(true);
969 let first = braille(&d, 32, 16);
970 advance(&mut d, 60);
971 assert_eq!(braille(&d, 32, 16), first, "reduced motion does not move");
972 let fresh = Director::new(Options {
973 reduced: true,
974 ..Options::default()
975 });
976 assert_eq!(fresh.acting, Act::Rest);
977 assert!(fresh.calves.iter().all(|c| c.target == 0.));
978 assert!(fresh.particles.is_empty());
979 }
980
981 // ---------------------------------------------------------------------------
982 // The app-facing Stage: one Director per foreground session, one clock.
983 // ---------------------------------------------------------------------------
984
985 fn inputs(presence: Presence, activity: Option<Activity>, context: Context) -> Inputs {
986 Inputs {
987 presence,
988 activity,
989 context,
990 }
991 }
992
993 #[test]
994 fn a_new_foreground_session_replaces_the_director() {
995 let mut stage = Stage::new();
996 let pod = Activity {
997 kind: Some("delegating".into()),
998 observed: true,
999 parallel: Some(3.),
1000 active: Vec::new(),
1001 };
1002 let now = Instant::now();
1003 stage.observe(
1004 Some("session-a"),
1005 inputs(Presence::Working, Some(pod.clone()), fixture_context()),
1006 false,
1007 );
1008 stage.advance(now);
1009 stage.advance(now + Duration::from_millis(500));
1010 assert_eq!(stage.acting(), Act::Pod);
1011 assert!(stage.director().calves.iter().all(|c| c.value > 0.1));
1012 let done = || inputs(Presence::Done, None, fixture_context());
1013 stage.observe(Some("session-a"), done(), false);
1014 let spouts = |stage: &Stage| stage.director().emissions.len();
1015 stage.advance(now + Duration::from_millis(1500));
1016 let celebrated = spouts(&stage);
1017 // Another session starts clean: no calves, no remembered turn.
1018 stage.observe(
1019 Some("session-b"),
1020 inputs(Presence::Idle, None, Context::default()),
1021 false,
1022 );
1023 assert_eq!(stage.acting(), Act::Rest);
1024 assert!(
1025 stage
1026 .director()
1027 .calves
1028 .iter()
1029 .all(|c| c.value == 0. && c.target == 0.)
1030 );
1031 assert!(stage.director().shots.is_empty());
1032 assert_eq!(spouts(&stage), 0);
1033 assert!(celebrated >= 1);
1034 }
1035
1036 #[test]
1037 fn the_shared_clock_is_idempotent_per_frame_and_caps_catch_up() {
1038 let mut stage = Stage::new();
1039 stage.observe(
1040 None,
1041 inputs(Presence::Idle, None, Context::default()),
1042 false,
1043 );
1044 let start = Instant::now();
1045 stage.advance(start);
1046 stage.advance(start + Duration::from_millis(100));
1047 let f = stage.director().f;
1048 assert!((f - 3.).abs() < 1e-9, "100 ms is three frames, got {f}");
1049 // A second surface painting in the same frame does not step again.
1050 stage.advance(start + Duration::from_millis(101));
1051 assert_eq!(stage.director().f, f);
1052 // A long hidden interval resumes without replaying it.
1053 stage.advance(start + Duration::from_secs(600));
1054 assert!((stage.director().f - f - 30.).abs() < 1e-6);
1055 }
1056
1057 #[test]
1058 fn surfaces_schedule_within_their_caps_and_not_at_all_when_reduced() {
1059 let mut stage = Stage::new();
1060 stage.observe(
1061 None,
1062 inputs(Presence::Idle, None, Context::default()),
1063 false,
1064 );
1065 let now = Instant::now();
1066 stage.advance(now);
1067 stage.advance(now + Duration::from_secs(1));
1068 assert_eq!(
1069 stage.cadence(Tier::Hero),
1070 Some(Duration::from_secs_f64(1. / 8.))
1071 );
1072 assert_eq!(
1073 stage.cadence(Tier::Small),
1074 Some(Duration::from_secs_f64(1. / 2.))
1075 );
1076 stage.observe(
1077 None,
1078 inputs(Presence::Thinking, None, Context::default()),
1079 false,
1080 );
1081 assert_eq!(
1082 stage.cadence(Tier::Hero),
1083 Some(Duration::from_secs_f64(1. / 30.))
1084 );
1085 assert_eq!(
1086 stage.cadence(Tier::Small),
1087 Some(Duration::from_secs_f64(1. / 15.))
1088 );
1089 stage.observe(
1090 None,
1091 inputs(Presence::Thinking, None, Context::default()),
1092 true,
1093 );
1094 assert_eq!(stage.cadence(Tier::Hero), None);
1095 assert_eq!(stage.cadence(Tier::Small), None);
1096 // Reduced motion paints the poster; stepping changes nothing.
1097 let before = stage.director().pose();
1098 stage.advance(now + Duration::from_secs(3));
1099 assert_eq!(stage.director().pose(), before);
1100 }
1101
1102 #[test]
1103 fn owner_activity_is_read_as_served_and_never_inferred() {
1104 // The owner's canonical kind drives the action; a tool name or caption
1105 // beside it is never consulted.
1106 let served = json!({
1107 "observed": true, "kind": "editing", "tool": "exec_command",
1108 "label": "Running a command", "parallel": 2,
1109 "active": [{"kind": "editing", "sinceMs": 1200}, {"kind": "reading"}, "junk"]
1110 });
1111 let activity = stage::activity(&served, true);
1112 assert_eq!(activity.kind.as_deref(), Some("editing"));
1113 assert_eq!(activity.parallel, Some(2.));
1114 assert_eq!(
1115 activity.active,
1116 vec![Span {
1117 kind: "editing".into(),
1118 since_ms: 1200.
1119 }]
1120 );
1121 let live = fixture_context();
1122 assert_eq!(
1123 acting_for(Presence::Working, Some(&activity), &live),
1124 Act::Write
1125 );
1126 // No kind: plain work, even with a descriptive tool name.
1127 let nameless = stage::activity(
1128 &json!({"observed": true, "tool": "read_file", "label": "Reading files"}),
1129 true,
1130 );
1131 assert_eq!(
1132 acting_for(Presence::Working, Some(&nameless), &live),
1133 Act::Busy
1134 );
1135 // The owner said unobserved: plain work.
1136 let unobserved = stage::activity(&served, false);
1137 assert_eq!(
1138 acting_for(Presence::Working, Some(&unobserved), &live),
1139 Act::Busy
1140 );
1141 // Stale freshness: plain work.
1142 let stale = Context {
1143 live: false,
1144 ..live
1145 };
1146 assert_eq!(
1147 acting_for(Presence::Working, Some(&activity), &stale),
1148 Act::Busy
1149 );
1150 }
1151
1152 fn every_coordinate(path: &super::rig::Path) -> Vec<f64> {
1153 path.iter().flat_map(|c| c.coords().to_vec()).collect()
1154 }
1155
1156 #[test]
1157 fn optical_sizes_land_on_the_half_pixel_grid() {
1158 // 16/24/32 px icons snap every visible contour coordinate to half a
1159 // device pixel, at 1× and 2×; the hero rig is never snapped.
1160 for (size, dpr) in [
1161 (16., 1.),
1162 (24., 1.),
1163 (32., 1.),
1164 (16., 2.),
1165 (24., 2.),
1166 (32., 2.),
1167 ] {
1168 let parts = build(MARK_DIRECTION, &rig_pose(), 2, size, dpr);
1169 assert!(parts.small);
1170 let half = 124. / (size * dpr) / 2.;
1171 for shape in parts.shapes.iter().filter(|s| s.opacity > 0.) {
1172 for v in every_coordinate(&shape.path) {
1173 let cells = v / half;
1174 assert!(
1175 (cells - cells.round()).abs() < 1e-6,
1176 "{} at {size}px@{dpr}x: {v} is off the grid",
1177 shape.id
1178 );
1179 }
1180 }
1181 }
1182 assert!(!build(MARK_DIRECTION, &rig_pose(), 0, 512., 2.).small);
1183 assert_eq!(View::fitted(28., 2.).lod, 2);
1184 assert_eq!(View::fitted(40., 2.).lod, 0);
1185 }
1186
1187 #[test]
1188 fn one_ink_subtracts_apertures_while_color_paints_them() {
1189 let parts = build(MARK_DIRECTION, &rig_pose(), 0, 512., 1.);
1190 let mono = super::holes_for("body", &parts.shapes, true);
1191 let color = super::holes_for("body", &parts.shapes, false);
1192 // One ink reveals the real background through the eye and throat.
1193 assert!(!mono.is_empty(), "one ink must cut the apertures");
1194 assert!(
1195 color.is_empty(),
1196 "the color body keeps its painted apertures"
1197 );
1198 assert!(
1199 parts
1200 .shapes
1201 .iter()
1202 .any(|s| s.role == Role::Hole && s.id == "eye"),
1203 "color paints the light eye aperture as its own shape"
1204 );
1205 // Prop details are compound holes in every theme.
1206 let mut pose = rig_pose();
1207 pose[p::page] = 1.;
1208 let with_page = build(MARK_DIRECTION, &pose, 0, 512., 1.);
1209 let mut shapes = with_page.shapes.clone();
1210 shapes.extend(props::shapes(&pose, &with_page, false));
1211 for mono in [true, false] {
1212 assert!(!super::holes_for("prop-page", &shapes, mono).is_empty());
1213 }
1214 }
1215
1216 fn cove_stage(presence: Presence) -> Stage {
1217 let mut stage = Stage::new();
1218 stage.observe(
1219 Some("session"),
1220 inputs(presence, None, Context::default()),
1221 false,
1222 );
1223 stage
1224 }
1225
1226 fn step_stage(stage: &mut Stage, start: Instant, frames: u64) -> Instant {
1227 let mut now = start;
1228 for _ in 0..frames {
1229 now += Duration::from_micros(33_334);
1230 stage.advance(now);
1231 }
1232 now
1233 }
1234
1235 fn max_drift(a: &super::Parts, b: &super::Parts) -> f64 {
1236 a.shapes
1237 .iter()
1238 .zip(&b.shapes)
1239 .flat_map(|(a, b)| {
1240 every_coordinate(&a.path)
1241 .into_iter()
1242 .zip(every_coordinate(&b.path))
1243 .map(|(x, y)| (x - y).abs())
1244 })
1245 .fold(0., f64::max)
1246 }
1247
1248 #[test]
1249 fn the_cove_rides_the_director_clock_and_only_turns_the_resting_gaze() {
1250 let mut stage = cove_stage(Presence::Idle);
1251 let view = View::hero(320., 2.);
1252 let start = Instant::now();
1253 stage.advance(start);
1254 let a = stage.cove_scene(view, false);
1255 // Scenery is only move/cubic/close with finite coordinates, and the
1256 // cove sits behind and in front of the character.
1257 assert!(a.behind.len() >= 10 && a.front.len() >= 2);
1258 for layer in a.behind.iter().chain(&a.front) {
1259 for path in &layer.paths {
1260 assert!(matches!(path.first(), Some(super::Cmd::M(_))));
1261 assert!(every_coordinate(path).iter().all(|v| v.is_finite()));
1262 }
1263 }
1264 // The water moves on the Director's own clock.
1265 let now = step_stage(&mut stage, start, 20);
1266 let b = stage.cove_scene(view, false);
1267 assert_ne!(a.behind, b.behind, "the water should move with the clock");
1268 // Without a pointer the cove never alters the performance.
1269 assert!(max_drift(&b.parts, &scene(stage.director(), view)) < 1e-9);
1270 // At rest the gaze follows the pointer, easing in over the frames.
1271 stage.cove_observe(62., 62.);
1272 let mut now = now;
1273 for _ in 0..30 {
1274 now = step_stage(&mut stage, now, 1);
1275 let _ = stage.cove_scene(view, false);
1276 }
1277 let gazing = stage.cove_scene(view, false);
1278 assert!(
1279 max_drift(&gazing.parts, &scene(stage.director(), view)) > 0.1,
1280 "the resting whale should turn toward the pointer"
1281 );
1282 // A tap ripples the water but never reaches the Director's inputs.
1283 let acting = stage.acting();
1284 let presence = stage.director().presence;
1285 for x in [0., 10., 20., 30.] {
1286 stage.cove_tap(x, 40.);
1287 }
1288 assert_eq!(stage.cove().ripples(), 3, "at most three ripples");
1289 assert_eq!(stage.acting(), acting);
1290 assert_eq!(stage.director().presence, presence);
1291 let now = step_stage(&mut stage, now, 1);
1292 let rippling = stage.cove_scene(view, false);
1293 assert!(rippling.front.len() > gazing.front.len());
1294 // A ripple keeps the hero at its active cap until it has spread.
1295 assert_eq!(
1296 stage.cadence(Tier::Hero),
1297 Some(Duration::from_secs_f64(1. / 30.))
1298 );
1299 let now = step_stage(&mut stage, now, 90);
1300 assert_eq!(stage.cove_scene(view, false).front.len(), 2);
1301 // Working: the gaze eases back to the Director's own pose.
1302 stage.cove_leave();
1303 stage.observe(
1304 Some("session"),
1305 inputs(Presence::Thinking, None, Context::default()),
1306 false,
1307 );
1308 let mut now = now;
1309 for _ in 0..90 {
1310 now = step_stage(&mut stage, now, 1);
1311 let _ = stage.cove_scene(view, false);
1312 }
1313 let working = stage.cove_scene(view, false);
1314 let drift = max_drift(&working.parts, &scene(stage.director(), view));
1315 assert!(drift < 0.05, "working gaze stays the Director's: {drift}");
1316 }
1317
1318 #[test]
1319 fn the_cove_is_still_under_reduced_motion_and_resets_with_the_session() {
1320 let mut stage = cove_stage(Presence::Idle);
1321 let view = View::hero(320., 1.);
1322 stage.observe(
1323 Some("session"),
1324 inputs(Presence::Idle, None, Context::default()),
1325 true,
1326 );
1327 stage.cove_observe(-62., 20.);
1328 stage.cove_tap(0., 40.);
1329 let a = stage.cove_scene(view, false);
1330 let start = Instant::now();
1331 stage.advance(start);
1332 step_stage(&mut stage, start, 45);
1333 let b = stage.cove_scene(view, false);
1334 assert_eq!(a.behind, b.behind, "reduced motion paints a still cove");
1335 assert_eq!(a.front, b.front);
1336 // No gaze under reduced motion: the poster is exactly the Director's.
1337 assert!(max_drift(&b.parts, &scene(stage.director(), view)) < 1e-9);
1338 assert_eq!(stage.cadence(Tier::Hero), None);
1339 // Dark and light palettes differ; geometry does not.
1340 let dark = stage.cove_scene(view, true);
1341 let light = stage.cove_scene(view, false);
1342 assert_ne!(dark.behind[0].color, light.behind[0].color);
1343 assert_eq!(dark.behind[0].paths, light.behind[0].paths);
1344 // A new foreground session starts in a still cove.
1345 stage.observe(
1346 Some("other"),
1347 inputs(Presence::Idle, None, Context::default()),
1348 true,
1349 );
1350 assert_eq!(stage.cove().ripples(), 0);
1351 }
1352
1352 lines RUST