返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / ocean / tests.rs
1 use super::*;
2
3 fn distance(a: Color, b: Color) -> u16 {
4 let (ar, ag, ab) = rgb(a).expect("RGB color");
5 let (br, bg, bb) = rgb(b).expect("RGB color");
6 ar.abs_diff(br) as u16 + ag.abs_diff(bg) as u16 + ab.abs_diff(bb) as u16
7 }
8
9 fn relative_luminance(value: Color) -> f64 {
10 let (r, g, b) = rgb(value).expect("contrast colors must be RGB");
11 let linearize = |component: u8| {
12 let srgb = f64::from(component) / 255.0;
13 if srgb <= 0.04045 {
14 srgb / 12.92
15 } else {
16 ((srgb + 0.055) / 1.055).powf(2.4)
17 }
18 };
19 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
20 }
21
22 fn contrast_ratio(foreground: Color, background: Color) -> f64 {
23 let foreground = relative_luminance(foreground);
24 let background = relative_luminance(background);
25 let (lighter, darker) = if foreground >= background {
26 (foreground, background)
27 } else {
28 (background, foreground)
29 };
30 (lighter + 0.05) / (darker + 0.05)
31 }
32
33 #[test]
34 fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
35 let ramp =
36 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
37 assert_eq!(ramp.surface, Color::Rgb(0x10, 0x2a, 0x45));
38 assert_eq!(ramp.middle, Color::Rgb(0x0a, 0x1e, 0x33));
39 assert_eq!(ramp.deep, Color::Rgb(0x06, 0x13, 0x20));
40 assert!(
41 distance(ramp.surface, ramp.deep) >= 32,
42 "the selected underwater treatment must read at a glance"
43 );
44 assert_ne!(
45 ramp.color_at_context(0, 20, 0),
46 ramp.color_at_context(19, 20, 0)
47 );
48 }
49
50 #[test]
51 fn whale_column_stays_blue_and_gently_banded_at_full_screen_depth() {
52 let theme = codewhale_palette::UNDERWATER_UI_THEME;
53 let ramp = OceanRamp::for_theme(&theme).expect("underwater ramp");
54 let mut previous = ramp.color_at_context(0, 80, 0);
55
56 for row in 0..80 {
57 let current = ramp.color_at_context(row, 80, 0);
58 let (red, green, blue) = rgb(current).expect("RGB ocean color");
59 assert!(
60 blue > green && green > red,
61 "row {row} lost the authored blue-ocean ordering: {current:?}"
62 );
63 assert!(
64 relative_luminance(current) > relative_luminance(codewhale_palette::WHALE_BG),
65 "row {row} fell back into the near-black shell field"
66 );
67 assert!(
68 distance(previous, current) <= 4,
69 "row {row} introduced a hard depth seam"
70 );
71 previous = current;
72 }
73
74 assert_eq!(ramp.color_at_context(0, 80, 0), ramp.surface);
75 assert_eq!(ramp.color_at_context(79, 80, 0), ramp.deep);
76 }
77
78 #[test]
79 fn whale_ocean_keeps_text_and_semantic_roles_readable() {
80 let theme = codewhale_palette::UNDERWATER_UI_THEME;
81 let ramp = OceanRamp::for_theme(&theme).expect("underwater ramp");
82 let foregrounds = [
83 ("body", theme.text_body),
84 ("soft", theme.text_soft),
85 ("muted", theme.text_muted),
86 ("hint", theme.text_hint),
87 ("action", theme.accent_primary),
88 ("live", theme.status_working),
89 ("human", theme.accent_action),
90 ("warning", theme.warning),
91 ("danger", theme.error_fg),
92 ("act mode", theme.mode_agent),
93 ("plan mode", theme.mode_plan),
94 ("operate", theme.mode_operate),
95 ("full-access mode", theme.mode_yolo),
96 ("success", theme.success),
97 ];
98
99 for (background_name, background) in [
100 ("ocean surface", ramp.surface),
101 ("ocean middle", ramp.middle),
102 ("ocean deep", ramp.deep),
103 ] {
104 for (foreground_name, foreground) in foregrounds {
105 let ratio = contrast_ratio(foreground, background);
106 assert!(
107 ratio >= 4.5,
108 "Whale {foreground_name} on {background_name} contrast {ratio:.2} is below 4.50"
109 );
110 }
111 }
112 }
113
114 #[test]
115 fn underwater_custom_background_keeps_the_field() {
116 let custom = Color::Rgb(0x12, 0x1a, 0x2d);
117 let theme = codewhale_palette::UNDERWATER_UI_THEME.with_background_color(custom);
118 let ramp = OceanRamp::for_theme(&theme).expect("the field survives a background override");
119
120 assert_ne!(ramp.surface, ramp.deep);
121 }
122
123 #[test]
124 fn themes_other_than_underwater_own_no_field() {
125 use codewhale_palette::{SELECTABLE_THEMES, ThemeId};
126
127 for id in SELECTABLE_THEMES {
128 let ramp = OceanRamp::for_theme(&id.ui_theme());
129 if matches!(id, ThemeId::Underwater) {
130 assert!(ramp.is_some(), "{} owns the painted field", id.name());
131 } else {
132 assert_eq!(
133 ramp,
134 None,
135 "{} must leave the terminal's ground alone",
136 id.name()
137 );
138 }
139 }
140
141 // A custom background repaints surfaces; it never grants a field to a
142 // theme that does not own one.
143 let custom = Color::Rgb(0x12, 0x1a, 0x2d);
144 assert_eq!(
145 OceanRamp::for_theme(&codewhale_palette::UI_THEME.with_background_color(custom)),
146 None
147 );
148 }
149
150 #[test]
151 fn inherited_terminal_background_reports_no_ramp() {
152 assert_eq!(
153 OceanRamp::for_theme(&codewhale_palette::TERMINAL_UI_THEME),
154 None
155 );
156 }
157
158 #[test]
159 fn solarized_light_preserves_its_canonical_base3_background() {
160 let theme = codewhale_palette::SOLARIZED_LIGHT_UI_THEME;
161
162 assert_eq!(theme.surface_bg, Color::Rgb(0xfd, 0xf6, 0xe3));
163 assert_eq!(OceanRamp::for_theme(&theme), None);
164 }
165
166 #[test]
167 fn solarized_light_custom_background_stays_field_free() {
168 let custom = Color::Rgb(0x1a, 0x1b, 0x26);
169 let theme = codewhale_palette::SOLARIZED_LIGHT_UI_THEME.with_background_color(custom);
170 assert_eq!(OceanRamp::for_theme(&theme), None);
171 assert_eq!(theme.surface_bg, custom);
172 }
173
174 #[test]
175 fn terminal_native_themes_keep_reset_shells_while_underwater_paints_the_column() {
176 for theme in [
177 codewhale_palette::UI_THEME,
178 codewhale_palette::LIGHT_UI_THEME,
179 ] {
180 assert_eq!(
181 OceanRamp::for_theme(&theme),
182 None,
183 "{} must not grow a field",
184 theme.name
185 );
186 for shell_surface in [
187 theme.surface_bg,
188 theme.panel_bg,
189 theme.composer_bg,
190 theme.header_bg,
191 theme.footer_bg,
192 ] {
193 assert_eq!(shell_surface, Color::Reset, "{} shell", theme.name);
194 }
195 }
196
197 let theme = codewhale_palette::UNDERWATER_UI_THEME;
198 let ramp = OceanRamp::for_theme(&theme).expect("underwater ramp");
199 for painted in [ramp.surface, ramp.middle, ramp.deep, ramp.ambient] {
200 assert_ne!(painted, Color::Reset, "underwater paint");
201 }
202
203 let area = Rect::new(0, 0, 4, 4);
204 let mut buf = Buffer::empty(area);
205 // The shell has already painted its surface; paint_matching only re-inks
206 // cells wearing that exact background.
207 for y in 0..area.height {
208 for x in 0..area.width {
209 buf[(x, y)].set_bg(theme.surface_bg);
210 }
211 }
212 let column = OceanColumn::new(ramp, area, 0, None, ShellPhase::Idle, false, 0, 0);
213 column.paint_matching(area, &mut buf, theme.surface_bg);
214 assert_ne!(buf[(0, 0)].bg, Color::Reset);
215 assert_ne!(buf[(0, area.height - 1)].bg, Color::Reset);
216 assert_ne!(buf[(0, 0)].bg, buf[(0, area.height - 1)].bg);
217 }
218
219 #[test]
220 fn ambient_ink_matches_sunk_sky_shades_and_survives_reset_surfaces() {
221 // The underwater theme's authored RGB ramp gives its shell two sunk sky
222 // shades; seafoam remains live-work ink.
223 let theme = codewhale_palette::UNDERWATER_UI_THEME;
224 let ramp = OceanRamp::for_theme(&theme).expect("underwater ramp");
225 let baseline = crate::tui::ambient_life::AmbientActivity::Baseline;
226 let (primary, secondary) = ambient_inks_for_activity(&theme, baseline);
227 assert_ne!(primary, ramp.ambient);
228 assert_ne!(primary, secondary);
229 assert_ne!(primary, theme.accent_secondary);
230
231 // Terminal-owned surfaces have no RGB base; the raw secondary accent
232 // lets the terminal's own palette color the life.
233 let terminal = codewhale_palette::TERMINAL_UI_THEME;
234 assert_eq!(
235 ambient_inks_for_activity(&terminal, baseline),
236 (terminal.info, terminal.info)
237 );
238 }
239
240 #[test]
241 fn ambient_ink_reads_the_activity_at_a_glance() {
242 use crate::tui::ambient_life::AmbientActivity;
243 let theme = codewhale_palette::UNDERWATER_UI_THEME;
244 let baseline = ambient_inks_for_activity(&theme, AmbientActivity::Baseline);
245 let reasoning = ambient_inks_for_activity(&theme, AmbientActivity::Reasoning);
246 let tools = ambient_inks_for_activity(&theme, AmbientActivity::Tools);
247 let subagents = ambient_inks_for_activity(&theme, AmbientActivity::Subagents);
248
249 // Each activity wears its own water: dim deep for reasoning, bright
250 // current for tools, seafoam for orchestration.
251 assert_ne!(reasoning, baseline);
252 assert_ne!(tools, baseline);
253 assert_ne!(subagents, baseline);
254 assert_ne!(subagents, tools);
255 // Verifying keeps the metered baseline treatment.
256 assert_eq!(
257 ambient_inks_for_activity(&theme, AmbientActivity::Verifying),
258 baseline
259 );
260 }
261
262 #[test]
263 fn attention_phases_tint_the_water_even_when_life_has_settled() {
264 let viewport = Rect::new(0, 0, 80, 24);
265 let ramp =
266 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
267 // presence 0 + animated false is the fully settled, reduced-motion case —
268 // exactly where the old treatment went neutral and a blocked session was
269 // indistinguishable from an idle one across the room.
270 let waiting = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Approval, false, 0, 0);
271 let failed = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Failed, false, 0, 0);
272 let idle = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Idle, false, 0, 0);
273
274 assert_ne!(waiting.color_at_y(0), idle.color_at_y(0));
275 assert_ne!(failed.color_at_y(0), idle.color_at_y(0));
276 assert_ne!(waiting.color_at_y(0), failed.color_at_y(0));
277
278 // The tint is steady across time and motion settings alike.
279 let later = OceanColumn::new(ramp, viewport, 700, None, ShellPhase::Approval, true, 0, 0);
280 assert_eq!(waiting.color_at_y(0), later.color_at_y(0));
281 }
282
283 /// A full context window reads as the trench: row 0 at 100% matches the
284 /// bottom row at 0%, so the abyss visibly rises as context fills.
285 #[test]
286 fn context_fill_drags_the_water_column_toward_the_deep() {
287 let ramp =
288 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
289 let surface_row = ramp.color_at_context(0, 24, 0);
290 let abyss_row = ramp.color_at_context(0, 24, 100);
291 assert_ne!(surface_row, abyss_row);
292 assert_eq!(abyss_row, ramp.color_at_context(23, 24, 0));
293 }
294
295 #[test]
296 fn shimmer_is_subtle_and_concentrated_near_the_surface() {
297 let ramp =
298 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
299 let surface_a = ramp.color_at_phase_context(0, 20, 0, ShellPhase::Idle, 0);
300 let surface_b = ramp.color_at_phase_context(0, 20, 22_500, ShellPhase::Idle, 0);
301 let deep_a = ramp.color_at_phase_context(19, 20, 0, ShellPhase::Idle, 0);
302 let deep_b = ramp.color_at_phase_context(19, 20, 22_500, ShellPhase::Idle, 0);
303
304 let surface_shift = distance(surface_a, surface_b);
305 assert!(
306 (1..=8).contains(&surface_shift),
307 "surface shift was {surface_shift}"
308 );
309 assert_eq!(
310 deep_a, deep_b,
311 "the floor should stay perceptually anchored"
312 );
313 }
314
315 #[test]
316 fn attention_phases_carry_their_own_water_and_work_phases_have_distinct_depth_bias() {
317 let ramp =
318 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
319 // Attention tints are steady — the color itself is the signal, and a
320 // slow breath read as flicker rather than intent — but never neutral:
321 // each attention phase differs from the plain water.
322 for phase in [
323 ShellPhase::Waiting,
324 ShellPhase::Approval,
325 ShellPhase::Failed,
326 ] {
327 assert_eq!(
328 ramp.color_at_phase_context(4, 20, 0, phase, 0),
329 ramp.color_at_phase_context(4, 20, 45_000, phase, 0)
330 );
331 assert_ne!(
332 ramp.color_at_phase_context(4, 20, 0, phase, 0),
333 ramp.color_at_context(4, 20, 0)
334 );
335 }
336 assert_ne!(
337 ramp.color_at_phase_context(10, 20, 22_500, ShellPhase::Working, 0),
338 ramp.color_at_phase_context(10, 20, 22_500, ShellPhase::Verifying, 0)
339 );
340 }
341
342 #[test]
343 fn tall_columns_darken_continuously_without_an_anchor_shelf() {
344 // The old two-segment ramp met at 0.42 with zero color velocity on both
345 // sides: on a tall window that shelf read as a horizontal seam. The
346 // Bézier column must keep moving through the former anchor zone.
347 let ramp =
348 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
349 let height = 120;
350 let anchor = 50; // ~0.42 of 120
351 let above = ramp.color_at_context(anchor - 6, height, 0);
352 let at = ramp.color_at_context(anchor, height, 0);
353 let below = ramp.color_at_context(anchor + 6, height, 0);
354 assert_ne!(above, at, "water must still darken entering the old anchor");
355 assert_ne!(at, below, "water must still darken leaving the old anchor");
356 assert_eq!(ramp.color_at_context(0, height, 0), ramp.surface);
357 assert_eq!(ramp.color_at_context(height - 1, height, 0), ramp.deep);
358 }
359
360 #[test]
361 fn completion_breath_peaks_once_then_settles() {
362 let ramp =
363 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
364 let start = ramp.color_at_completion_context(0, 20, 0, 0);
365 let peak = ramp.color_at_completion_context(0, 20, 320, 0);
366 let settled = ramp.color_at_completion_context(0, 20, 800, 0);
367 assert_ne!(start, peak);
368 assert_ne!(peak, settled);
369 assert_eq!(settled, ramp.color_at_context(0, 20, 0));
370 }
371
372 #[test]
373 fn cache_fingerprint_changes_when_only_ramp_colors_change() {
374 let viewport = Rect::new(3, 5, 80, 24);
375 let first_ramp = OceanRamp {
376 surface: Color::Rgb(1, 2, 3),
377 middle: Color::Rgb(4, 5, 6),
378 deep: Color::Rgb(7, 8, 9),
379 ambient: Color::Rgb(10, 11, 12),
380 attention: Color::Rgb(240, 180, 60),
381 failure: Color::Rgb(220, 80, 80),
382 };
383 let second_ramp = OceanRamp {
384 surface: Color::Rgb(21, 22, 23),
385 middle: Color::Rgb(24, 25, 26),
386 deep: Color::Rgb(27, 28, 29),
387 ambient: Color::Rgb(30, 31, 32),
388 attention: Color::Rgb(240, 180, 60),
389 failure: Color::Rgb(220, 80, 80),
390 };
391 let first = OceanColumn::new(
392 first_ramp,
393 viewport,
394 22_500,
395 None,
396 ShellPhase::Working,
397 true,
398 1000,
399 0,
400 );
401 let second = OceanColumn::new(
402 second_ramp,
403 viewport,
404 22_500,
405 None,
406 ShellPhase::Working,
407 true,
408 1000,
409 0,
410 );
411
412 assert_ne!(first.color_at_y(viewport.y), second.color_at_y(viewport.y));
413 assert_ne!(
414 first.ramp_fingerprint(),
415 second.ramp_fingerprint(),
416 "visibly different palettes must not reuse the same row-color cache"
417 );
418 }
419
420 #[test]
421 fn each_ramp_color_participates_in_the_typed_cache_identity() {
422 let viewport = Rect::new(3, 5, 80, 24);
423 let ramp = OceanRamp {
424 surface: Color::Rgb(1, 2, 3),
425 middle: Color::Rgb(4, 5, 6),
426 deep: Color::Rgb(7, 8, 9),
427 ambient: Color::Rgb(10, 11, 12),
428 attention: Color::Rgb(240, 180, 60),
429 failure: Color::Rgb(220, 80, 80),
430 };
431 let baseline = OceanColumn::new(
432 ramp,
433 viewport,
434 22_500,
435 None,
436 ShellPhase::Working,
437 true,
438 1000,
439 0,
440 );
441 let alternatives = [
442 OceanRamp {
443 surface: Color::Rgb(101, 2, 3),
444 ..ramp
445 },
446 OceanRamp {
447 middle: Color::Rgb(104, 5, 6),
448 ..ramp
449 },
450 OceanRamp {
451 deep: Color::Rgb(107, 8, 9),
452 ..ramp
453 },
454 OceanRamp {
455 ambient: Color::Rgb(110, 11, 12),
456 ..ramp
457 },
458 OceanRamp {
459 attention: Color::Rgb(255, 200, 90),
460 ..ramp
461 },
462 OceanRamp {
463 failure: Color::Rgb(255, 90, 90),
464 ..ramp
465 },
466 ];
467
468 for alternative in alternatives {
469 let changed = OceanColumn::new(
470 alternative,
471 viewport,
472 22_500,
473 None,
474 ShellPhase::Working,
475 true,
476 1000,
477 0,
478 );
479 assert_ne!(
480 baseline.ramp_cache_identity(),
481 changed.ramp_cache_identity()
482 );
483 assert_ne!(baseline.ramp_fingerprint(), changed.ramp_fingerprint());
484 }
485 }
486
487 #[test]
488 fn identical_semantic_cache_inputs_have_identical_identity() {
489 let ramp =
490 OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
491 let viewport = Rect::new(3, 5, 80, 24);
492 let first = OceanColumn::new(
493 ramp,
494 viewport,
495 22_500,
496 None,
497 ShellPhase::Working,
498 true,
499 1000,
500 0,
501 );
502 let second = OceanColumn::new(
503 ramp,
504 viewport,
505 22_500,
506 None,
507 ShellPhase::Working,
508 true,
509 1000,
510 0,
511 );
512
513 assert_eq!(first.ramp_cache_identity(), second.ramp_cache_identity());
514 assert_eq!(first.ramp_fingerprint(), second.ramp_fingerprint());
515 }
516
517 #[test]
518 fn split_shell_surfaces_share_one_absolute_row_column() {
519 let theme = codewhale_palette::UNDERWATER_UI_THEME;
520 let ramp = OceanRamp::for_theme(&theme).expect("underwater ramp");
521 let viewport = Rect::new(0, 0, 12, 12);
522 let header = Rect::new(0, 0, 12, 2);
523 let composer = Rect::new(0, 10, 12, 2);
524 let mut buf = Buffer::empty(viewport);
525 for y in header.top()..header.bottom() {
526 for x in header.left()..header.right() {
527 buf[(x, y)].set_bg(theme.header_bg);
528 }
529 }
530 for y in composer.top()..composer.bottom() {
531 for x in composer.left()..composer.right() {
532 buf[(x, y)].set_bg(theme.composer_bg);
533 }
534 }
535 buf[(4, 10)].set_bg(theme.selection_bg);
536
537 let column = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Idle, false, 0, 0);
538 column.paint_matching(header, &mut buf, theme.header_bg);
539 column.paint_matching(composer, &mut buf, theme.composer_bg);
540
541 assert_eq!(buf[(0, 0)].bg, ramp.color_at_context(0, 12, 0));
542 assert_eq!(buf[(0, 11)].bg, ramp.color_at_context(11, 12, 0));
543 assert_ne!(buf[(0, 1)].bg, buf[(0, 10)].bg);
544 assert_eq!(
545 buf[(4, 10)].bg,
546 theme.selection_bg,
547 "semantic surfaces must survive the shell Deepsea pass"
548 );
549 }
550
551 #[test]
552 fn full_viewport_water_column_reaches_both_terminal_edges() {
553 let theme = codewhale_palette::UNDERWATER_UI_THEME;
554 let ramp = OceanRamp::for_theme(&theme).expect("underwater ramp");
555 let viewport = Rect::new(0, 0, 120, 32);
556 let mut buf = Buffer::empty(viewport);
557 for y in viewport.top()..viewport.bottom() {
558 for x in viewport.left()..viewport.right() {
559 buf[(x, y)].set_bg(theme.surface_bg);
560 }
561 }
562 buf[(60, 16)].set_bg(theme.selection_bg);
563
564 let column = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Idle, false, 0, 0);
565 column.paint_matching(viewport, &mut buf, theme.surface_bg);
566
567 for y in viewport.top()..viewport.bottom() {
568 let expected = ramp.color_at_context(y, viewport.height, 0);
569 assert_eq!(buf[(viewport.left(), y)].bg, expected);
570 assert_eq!(buf[(viewport.right() - 1, y)].bg, expected);
571 }
572 assert_eq!(
573 buf[(60, 16)].bg,
574 theme.selection_bg,
575 "semantic surfaces must remain protected inside the full-width water column"
576 );
577 }
578
579 // ---- v0.9.4: life presence eases the animated/static boundary ----
580
581 #[test]
582 fn life_presence_is_pure_and_bounded() {
583 // Same inputs -> same output; presence never leaves 0..=1.
584 let inputs = [
585 (None, None, false, false, false),
586 (None, None, true, false, false),
587 (Some(0), Some(0), true, false, false),
588 (Some(500), Some(10_000), false, false, false),
589 (Some(2_000), None, true, false, false),
590 (None, Some(30_000), true, true, false),
591 ];
592 for (completion, turn, animated, browsing, empty) in inputs {
593 let a = life_presence(completion, turn, animated, browsing, empty);
594 let b = life_presence(completion, turn, animated, browsing, empty);
595 assert_eq!(a, b, "life_presence must be a pure function of its inputs");
596 assert!(
597 (0.0..=1.0).contains(&a),
598 "presence must stay in 0..=1, got {a}"
599 );
600 }
601 }
602
603 #[test]
604 fn life_presence_holds_full_through_completion_breath_then_settles() {
605 // During the breath the water keeps full life so the settle flourish is
606 // accompanied by swimming fish, then presence eases out.
607 assert_eq!(life_presence(Some(0), None, false, false, false), 1.0);
608 assert_eq!(
609 life_presence(Some(COMPLETION_BREATH_MS - 1), None, false, false, false),
610 1.0
611 );
612 assert_eq!(
613 life_presence(Some(COMPLETION_BREATH_MS), None, false, false, false),
614 1.0,
615 "presence holds at the breath boundary before easing"
616 );
617 let mid = life_presence(
618 Some(COMPLETION_BREATH_MS + SETTLE_MS / 2),
619 None,
620 false,
621 false,
622 false,
623 );
624 assert!(
625 mid > 0.0 && mid < 1.0,
626 "presence must be mid-fade during the settle window, got {mid}"
627 );
628 assert_eq!(
629 life_presence(
630 Some(COMPLETION_BREATH_MS + SETTLE_MS),
631 None,
632 false,
633 false,
634 false
635 ),
636 0.0,
637 "presence reaches zero at the end of the settle window"
638 );
639 }
640
641 #[test]
642 fn life_presence_ramps_in_from_turn_anchor_with_bounded_velocity() {
643 // Working/Verifying ramps in over RAMP_MS; the ramp is monotone and
644 // zero-velocity at both ends (smoothstep), so bursty streams ease in.
645 let at = |ms: u128| life_presence(None, Some(ms), true, false, false);
646 assert_eq!(at(0), 0.0);
647 assert_eq!(at(RAMP_MS / 2), 0.5, "smoothstep midpoint is 0.5");
648 assert_eq!(at(RAMP_MS), 1.0);
649 assert_eq!(at(RAMP_MS * 10), 1.0);
650 // Monotone non-decreasing across the ramp.
651 let mut prev = 0.0f32;
652 for step in 1..=16u128 {
653 let ms = step * RAMP_MS / 16;
654 let value = at(ms);
655 assert!(value >= prev, "presence must not regress while ramping");
656 prev = value;
657 }
658 }
659
660 #[test]
661 fn life_presence_user_driven_states_are_immediate() {
662 // Browsing history and the pristine empty state are deliberate user
663 // surfaces: full presence with no ramp.
664 assert_eq!(life_presence(None, Some(0), true, true, false), 1.0);
665 assert_eq!(life_presence(None, Some(0), true, false, true), 1.0);
666 // Fully static contexts are exactly zero.
667 assert_eq!(life_presence(None, None, false, false, false), 0.0);
668 assert_eq!(life_presence(None, Some(50_000), false, false, false), 0.0);
669 }
670
670 lines RUST