返回 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 = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
36 assert_eq!(ramp.surface, Color::Rgb(0x0e, 0x17, 0x29));
37 assert_eq!(ramp.middle, Color::Rgb(0x08, 0x11, 0x1c));
38 assert_eq!(ramp.deep, Color::Rgb(0x03, 0x07, 0x0d));
39 assert!(
40 distance(ramp.surface, ramp.deep) >= 32,
41 "the selected underwater treatment must read at a glance"
42 );
43 assert_ne!(ramp.color_at(0, 20), ramp.color_at(19, 20));
44 }
45
46 #[test]
47 fn light_theme_stays_light_enough_for_light_theme_text() {
48 let ramp = OceanRamp::for_theme(&crate::palette::LIGHT_UI_THEME).expect("RGB theme");
49 assert_eq!(ramp.surface, Color::Rgb(0xff, 0xfd, 0xf8));
50 assert_eq!(ramp.middle, Color::Rgb(0xf4, 0xf7, 0xfb));
51 assert_eq!(ramp.deep, Color::Rgb(0xf0, 0xf4, 0xf9));
52 let (r, g, b) = rgb(ramp.deep).expect("RGB color");
53 assert!(u16::from(r) + u16::from(g) + u16::from(b) > 420);
54 }
55
56 #[test]
57 fn light_ocean_and_selection_keep_text_and_semantic_roles_readable() {
58 let theme = crate::palette::LIGHT_UI_THEME;
59 let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
60 let foregrounds = [
61 ("body", theme.text_body),
62 ("soft", theme.text_soft),
63 ("muted", theme.text_muted),
64 ("hint", theme.text_hint),
65 ("action", theme.accent_primary),
66 ("live", theme.status_working),
67 ("human", theme.accent_action),
68 ("warning", theme.warning),
69 ("danger", theme.error_fg),
70 ("act mode", theme.mode_agent),
71 ("plan mode", theme.mode_plan),
72 ("operate", theme.mode_operate),
73 ("full-access mode", theme.mode_yolo),
74 ("success", theme.success),
75 ("user", crate::palette::LIGHT_USER_BODY),
76 ];
77 let backgrounds = [
78 ("ocean surface", ramp.surface),
79 ("ocean middle", ramp.middle),
80 ("ocean deep", ramp.deep),
81 ("selection", theme.selection_bg),
82 ];
83
84 for (background_name, background) in backgrounds {
85 for (foreground_name, foreground) in foregrounds {
86 let ratio = contrast_ratio(foreground, background);
87 assert!(
88 ratio >= 4.5,
89 "light {foreground_name} on {background_name} contrast {ratio:.2} is below 4.50"
90 );
91 }
92 }
93 }
94
95 #[test]
96 fn whale_custom_background_uses_the_configured_surface() {
97 let custom = Color::Rgb(0x12, 0x1a, 0x2d);
98 let theme = crate::palette::UI_THEME.with_background_color(custom);
99 let ramp = OceanRamp::for_theme(&theme).expect("custom backgrounds retain ombre");
100
101 assert_ne!(ramp.surface, Color::Rgb(0x0e, 0x17, 0x29));
102 assert_ne!(ramp.surface, ramp.deep);
103 }
104
105 #[test]
106 fn inherited_terminal_background_reports_no_ramp() {
107 let mut theme = crate::palette::UI_THEME;
108 theme.surface_bg = Color::Reset;
109 assert_eq!(OceanRamp::for_theme(&theme), None);
110 }
111
112 #[test]
113 fn solarized_light_preserves_its_canonical_base3_background() {
114 let theme = crate::palette::SOLARIZED_LIGHT_UI_THEME;
115
116 assert_eq!(theme.surface_bg, Color::Rgb(0xfd, 0xf6, 0xe3));
117 assert_eq!(OceanRamp::for_theme(&theme), None);
118 }
119
120 #[test]
121 fn solarized_light_custom_background_preserves_ombre() {
122 let custom = Color::Rgb(0x1a, 0x1b, 0x26);
123 let theme = crate::palette::SOLARIZED_LIGHT_UI_THEME.with_background_color(custom);
124 let ramp = OceanRamp::for_theme(&theme).expect("custom backgrounds retain ombre");
125
126 assert_ne!(ramp.surface, custom);
127 assert_ne!(ramp.surface, ramp.deep);
128 }
129
130 #[test]
131 fn every_shipped_theme_has_an_intentional_ocean_treatment() {
132 use crate::palette::{SELECTABLE_THEMES, ThemeId};
133
134 for id in SELECTABLE_THEMES {
135 let ramp = OceanRamp::for_theme(&id.ui_theme());
136 if matches!(id, ThemeId::Terminal | ThemeId::SolarizedLight) {
137 assert_eq!(
138 ramp,
139 None,
140 "{} must keep its canonical background",
141 id.name()
142 );
143 } else {
144 let ramp = ramp.unwrap_or_else(|| panic!("{} has no ocean ramp", id.name()));
145 assert_ne!(
146 ramp.surface,
147 ramp.deep,
148 "{} lost underwater depth",
149 id.name()
150 );
151 }
152 }
153 }
154
155 #[test]
156 fn treatment_parses_saved_values_and_defaults_to_ombre() {
157 assert_eq!(OceanTreatment::parse("flat"), OceanTreatment::Flat);
158 assert_eq!(OceanTreatment::parse(" FLAT "), OceanTreatment::Flat);
159 assert_eq!(OceanTreatment::parse("ombre"), OceanTreatment::Ombre);
160 assert_eq!(OceanTreatment::parse("kelp"), OceanTreatment::Ombre);
161 assert_eq!(OceanTreatment::parse(""), OceanTreatment::Ombre);
162 // Migration shim: settings saved by pre-0.9.4 builds may still carry the
163 // removed classic shell; they load as the default ombre treatment.
164 assert_eq!(OceanTreatment::parse("classic"), OceanTreatment::Ombre);
165 }
166
167 #[test]
168 fn every_underwater_treatment_keeps_ambient_life() {
169 // The classic shell was the only treatment that stilled ambient life; with
170 // it removed there is no per-treatment ambient-life flag left to test.
171 // What remains worth pinning: both live treatments stay distinct so the
172 // flat/ombre choice keeps its meaning.
173 assert_ne!(OceanTreatment::Ombre, OceanTreatment::Flat);
174 assert!(OceanTreatment::Ombre.is_ombre());
175 assert!(OceanTreatment::Flat.is_flat());
176 }
177
178 #[test]
179 fn ambient_ink_matches_sunk_sky_shades_and_survives_reset_surfaces() {
180 // RGB themes: fish wear two sunk sky shades; seafoam remains live-work ink.
181 let theme = crate::palette::UI_THEME;
182 let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
183 let (primary, secondary) = ambient_inks(&theme);
184 assert_ne!(primary, ramp.ambient);
185 assert_ne!(primary, secondary);
186 assert_ne!(primary, theme.accent_secondary);
187
188 // Terminal-owned surfaces have no RGB base; the raw secondary accent
189 // lets the terminal's own palette color the life.
190 let terminal = crate::palette::TERMINAL_UI_THEME;
191 assert_eq!(ambient_inks(&terminal), (terminal.info, terminal.info));
192 }
193
194 #[test]
195 fn shimmer_is_subtle_and_concentrated_near_the_surface() {
196 let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
197 let surface_a = ramp.color_at_phase(0, 20, 0, ShellPhase::Idle);
198 let surface_b = ramp.color_at_phase(0, 20, 3_000, ShellPhase::Idle);
199 let deep_a = ramp.color_at_phase(19, 20, 0, ShellPhase::Idle);
200 let deep_b = ramp.color_at_phase(19, 20, 3_000, ShellPhase::Idle);
201
202 let surface_shift = distance(surface_a, surface_b);
203 assert!(
204 (1..=8).contains(&surface_shift),
205 "surface shift was {surface_shift}"
206 );
207 assert_eq!(
208 deep_a, deep_b,
209 "the floor should stay perceptually anchored"
210 );
211 }
212
213 #[test]
214 fn attention_phases_are_still_and_work_phases_have_distinct_depth_bias() {
215 let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
216 for phase in [
217 ShellPhase::Waiting,
218 ShellPhase::Approval,
219 ShellPhase::Failed,
220 ] {
221 assert_eq!(
222 ramp.color_at_phase(4, 20, 0, phase),
223 ramp.color_at_phase(4, 20, 45_000, phase)
224 );
225 }
226 assert_ne!(
227 ramp.color_at_phase(10, 20, 22_500, ShellPhase::Working),
228 ramp.color_at_phase(10, 20, 22_500, ShellPhase::Verifying)
229 );
230 }
231
232 #[test]
233 fn completion_breath_peaks_once_then_settles() {
234 let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
235 let start = ramp.color_at_completion(0, 20, 0);
236 let peak = ramp.color_at_completion(0, 20, 320);
237 let settled = ramp.color_at_completion(0, 20, 800);
238 assert_ne!(start, peak);
239 assert_ne!(peak, settled);
240 assert_eq!(settled, ramp.color_at(0, 20));
241 }
242
243 #[test]
244 fn cache_fingerprint_changes_when_only_ramp_colors_change() {
245 let viewport = Rect::new(3, 5, 80, 24);
246 let first_ramp = OceanRamp {
247 surface: Color::Rgb(1, 2, 3),
248 middle: Color::Rgb(4, 5, 6),
249 deep: Color::Rgb(7, 8, 9),
250 ambient: Color::Rgb(10, 11, 12),
251 };
252 let second_ramp = OceanRamp {
253 surface: Color::Rgb(21, 22, 23),
254 middle: Color::Rgb(24, 25, 26),
255 deep: Color::Rgb(27, 28, 29),
256 ambient: Color::Rgb(30, 31, 32),
257 };
258 let first = OceanColumn::new(
259 first_ramp,
260 viewport,
261 22_500,
262 None,
263 ShellPhase::Working,
264 true,
265 1000,
266 );
267 let second = OceanColumn::new(
268 second_ramp,
269 viewport,
270 22_500,
271 None,
272 ShellPhase::Working,
273 true,
274 1000,
275 );
276
277 assert_ne!(first.color_at_y(viewport.y), second.color_at_y(viewport.y));
278 assert_ne!(
279 first.ramp_fingerprint(),
280 second.ramp_fingerprint(),
281 "visibly different palettes must not reuse the same row-color cache"
282 );
283 }
284
285 #[test]
286 fn each_ramp_color_participates_in_the_typed_cache_identity() {
287 let viewport = Rect::new(3, 5, 80, 24);
288 let ramp = OceanRamp {
289 surface: Color::Rgb(1, 2, 3),
290 middle: Color::Rgb(4, 5, 6),
291 deep: Color::Rgb(7, 8, 9),
292 ambient: Color::Rgb(10, 11, 12),
293 };
294 let baseline = OceanColumn::new(
295 ramp,
296 viewport,
297 22_500,
298 None,
299 ShellPhase::Working,
300 true,
301 1000,
302 );
303 let alternatives = [
304 OceanRamp {
305 surface: Color::Rgb(101, 2, 3),
306 ..ramp
307 },
308 OceanRamp {
309 middle: Color::Rgb(104, 5, 6),
310 ..ramp
311 },
312 OceanRamp {
313 deep: Color::Rgb(107, 8, 9),
314 ..ramp
315 },
316 OceanRamp {
317 ambient: Color::Rgb(110, 11, 12),
318 ..ramp
319 },
320 ];
321
322 for alternative in alternatives {
323 let changed = OceanColumn::new(
324 alternative,
325 viewport,
326 22_500,
327 None,
328 ShellPhase::Working,
329 true,
330 1000,
331 );
332 assert_ne!(
333 baseline.ramp_cache_identity(),
334 changed.ramp_cache_identity()
335 );
336 assert_ne!(baseline.ramp_fingerprint(), changed.ramp_fingerprint());
337 }
338 }
339
340 #[test]
341 fn identical_semantic_cache_inputs_have_identical_identity() {
342 let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
343 let viewport = Rect::new(3, 5, 80, 24);
344 let first = OceanColumn::new(
345 ramp,
346 viewport,
347 22_500,
348 None,
349 ShellPhase::Working,
350 true,
351 1000,
352 );
353 let second = OceanColumn::new(
354 ramp,
355 viewport,
356 22_500,
357 None,
358 ShellPhase::Working,
359 true,
360 1000,
361 );
362
363 assert_eq!(first.ramp_cache_identity(), second.ramp_cache_identity());
364 assert_eq!(first.ramp_fingerprint(), second.ramp_fingerprint());
365 }
366
367 #[test]
368 fn split_shell_surfaces_share_one_absolute_row_column() {
369 let theme = crate::palette::UI_THEME;
370 let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
371 let viewport = Rect::new(0, 0, 12, 12);
372 let header = Rect::new(0, 0, 12, 2);
373 let composer = Rect::new(0, 10, 12, 2);
374 let mut buf = Buffer::empty(viewport);
375 for y in header.top()..header.bottom() {
376 for x in header.left()..header.right() {
377 buf[(x, y)].set_bg(theme.header_bg);
378 }
379 }
380 for y in composer.top()..composer.bottom() {
381 for x in composer.left()..composer.right() {
382 buf[(x, y)].set_bg(theme.composer_bg);
383 }
384 }
385 buf[(4, 10)].set_bg(theme.selection_bg);
386
387 let column = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Idle, false, 0);
388 column.paint_matching(header, &mut buf, theme.header_bg);
389 column.paint_matching(composer, &mut buf, theme.composer_bg);
390
391 assert_eq!(buf[(0, 0)].bg, ramp.color_at(0, 12));
392 assert_eq!(buf[(0, 11)].bg, ramp.color_at(11, 12));
393 assert_ne!(buf[(0, 1)].bg, buf[(0, 10)].bg);
394 assert_eq!(
395 buf[(4, 10)].bg,
396 theme.selection_bg,
397 "semantic surfaces must survive the shell ombre pass"
398 );
399 }
400
401 // ---- v0.9.4: life presence eases the animated/static boundary ----
402
403 #[test]
404 fn life_presence_is_pure_and_bounded() {
405 // Same inputs -> same output; presence never leaves 0..=1.
406 let inputs = [
407 (None, None, false, false, false),
408 (None, None, true, false, false),
409 (Some(0), Some(0), true, false, false),
410 (Some(500), Some(10_000), false, false, false),
411 (Some(2_000), None, true, false, false),
412 (None, Some(30_000), true, true, false),
413 ];
414 for (completion, turn, animated, browsing, empty) in inputs {
415 let a = life_presence(completion, turn, animated, browsing, empty);
416 let b = life_presence(completion, turn, animated, browsing, empty);
417 assert_eq!(a, b, "life_presence must be a pure function of its inputs");
418 assert!(
419 (0.0..=1.0).contains(&a),
420 "presence must stay in 0..=1, got {a}"
421 );
422 }
423 }
424
425 #[test]
426 fn life_presence_holds_full_through_completion_breath_then_settles() {
427 // During the breath the water keeps full life so the settle flourish is
428 // accompanied by swimming fish, then presence eases out.
429 assert_eq!(life_presence(Some(0), None, false, false, false), 1.0);
430 assert_eq!(
431 life_presence(Some(COMPLETION_BREATH_MS - 1), None, false, false, false),
432 1.0
433 );
434 assert_eq!(
435 life_presence(Some(COMPLETION_BREATH_MS), None, false, false, false),
436 1.0,
437 "presence holds at the breath boundary before easing"
438 );
439 let mid = life_presence(
440 Some(COMPLETION_BREATH_MS + SETTLE_MS / 2),
441 None,
442 false,
443 false,
444 false,
445 );
446 assert!(
447 mid > 0.0 && mid < 1.0,
448 "presence must be mid-fade during the settle window, got {mid}"
449 );
450 assert_eq!(
451 life_presence(
452 Some(COMPLETION_BREATH_MS + SETTLE_MS),
453 None,
454 false,
455 false,
456 false
457 ),
458 0.0,
459 "presence reaches zero at the end of the settle window"
460 );
461 }
462
463 #[test]
464 fn life_presence_ramps_in_from_turn_anchor_with_bounded_velocity() {
465 // Working/Verifying ramps in over RAMP_MS; the ramp is monotone and
466 // zero-velocity at both ends (smoothstep), so bursty streams ease in.
467 let at = |ms: u128| life_presence(None, Some(ms), true, false, false);
468 assert_eq!(at(0), 0.0);
469 assert_eq!(at(RAMP_MS / 2), 0.5, "smoothstep midpoint is 0.5");
470 assert_eq!(at(RAMP_MS), 1.0);
471 assert_eq!(at(RAMP_MS * 10), 1.0);
472 // Monotone non-decreasing across the ramp.
473 let mut prev = 0.0f32;
474 for step in 1..=16u128 {
475 let ms = step * RAMP_MS / 16;
476 let value = at(ms);
477 assert!(value >= prev, "presence must not regress while ramping");
478 prev = value;
479 }
480 }
481
482 #[test]
483 fn life_presence_user_driven_states_are_immediate() {
484 // Browsing history and the pristine empty state are deliberate user
485 // surfaces: full presence with no ramp.
486 assert_eq!(life_presence(None, Some(0), true, true, false), 1.0);
487 assert_eq!(life_presence(None, Some(0), true, false, true), 1.0);
488 // Fully static contexts are exactly zero.
489 assert_eq!(life_presence(None, None, false, false, false), 0.0);
490 assert_eq!(life_presence(None, Some(50_000), false, false, false), 0.0);
491 }
492
492 lines RUST