| 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 |