1use std::collections::BTreeSet;
10use std::error::Error;
11use std::fmt::{Display, Formatter};
12
13use serde::{Deserialize, Serialize};
14
15pub const SHOWCASE_INTERACTION_SPEC_VERSION: u16 = 1;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum InteractionSurfaceKind {
22 Search,
23 Results,
24 Operations,
25 Explainability,
26}
27
28impl InteractionSurfaceKind {
29 #[must_use]
31 pub const fn id(self) -> &'static str {
32 match self {
33 Self::Search => "search",
34 Self::Results => "results",
35 Self::Operations => "operations",
36 Self::Explainability => "explainability",
37 }
38 }
39
40 #[must_use]
42 pub const fn all() -> [Self; 4] {
43 [
44 Self::Search,
45 Self::Results,
46 Self::Operations,
47 Self::Explainability,
48 ]
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum LayoutAxis {
56 Horizontal,
57 Vertical,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum CardRole {
64 QueryInput,
65 Filters,
66 ResultList,
67 ResultPreview,
68 JobQueue,
69 ResourcePressure,
70 Timeline,
71 ScoreBreakdown,
72 Provenance,
73 OperatorControls,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct CardLayoutRule {
79 pub card_id: String,
80 pub role: CardRole,
81 pub axis: LayoutAxis,
82 pub min_width_cols: u16,
83 pub min_height_rows: u16,
84 pub virtualized: bool,
85 pub sticky_header: bool,
86}
87
88impl CardLayoutRule {
89 #[must_use]
90 pub fn new(
91 card_id: impl Into<String>,
92 role: CardRole,
93 axis: LayoutAxis,
94 min_width_cols: u16,
95 min_height_rows: u16,
96 virtualized: bool,
97 sticky_header: bool,
98 ) -> Self {
99 Self {
100 card_id: card_id.into(),
101 role,
102 axis,
103 min_width_cols,
104 min_height_rows,
105 virtualized,
106 sticky_header,
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum PaletteIntent {
115 NavigateSurface,
116 FocusQuery,
117 RepeatQuery,
118 PauseIndexing,
119 ResumeIndexing,
120 ToggleExplainability,
121 OpenTimeline,
122 ReplayTrace,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct PaletteIntentRoute {
128 pub intent: PaletteIntent,
129 pub action_id: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub target_surface: Option<InteractionSurfaceKind>,
132 pub cross_screen_semantics: bool,
133}
134
135impl PaletteIntentRoute {
136 #[must_use]
137 pub fn new(
138 intent: PaletteIntent,
139 action_id: impl Into<String>,
140 target_surface: Option<InteractionSurfaceKind>,
141 cross_screen_semantics: bool,
142 ) -> Self {
143 Self {
144 intent,
145 action_id: action_id.into(),
146 target_surface,
147 cross_screen_semantics,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum DeterministicCheckpoint {
156 BeforeInputDispatch,
157 AfterInputDispatch,
158 BeforeStateSerialize,
159 AfterStateSerialize,
160 BeforeFrameCommit,
161 AfterFrameCommit,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct DeterministicStateBoundary {
167 pub checkpoint: DeterministicCheckpoint,
168 pub state_keys: Vec<String>,
169}
170
171impl DeterministicStateBoundary {
172 #[must_use]
173 pub fn new(checkpoint: DeterministicCheckpoint, state_keys: Vec<&str>) -> Self {
174 Self {
175 checkpoint,
176 state_keys: state_keys.into_iter().map(str::to_owned).collect(),
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183pub struct InteractionLatencyHooks {
184 pub input_to_route_ms: u16,
185 pub route_to_state_ms: u16,
186 pub state_to_render_ms: u16,
187 pub frame_budget_ms: u16,
188}
189
190impl InteractionLatencyHooks {
191 #[must_use]
192 pub const fn new(
193 input_to_route_ms: u16,
194 route_to_state_ms: u16,
195 state_to_render_ms: u16,
196 frame_budget_ms: u16,
197 ) -> Self {
198 Self {
199 input_to_route_ms,
200 route_to_state_ms,
201 state_to_render_ms,
202 frame_budget_ms,
203 }
204 }
205
206 #[must_use]
210 pub const fn component_budget_ms(self) -> u32 {
211 self.input_to_route_ms as u32
212 + self.route_to_state_ms as u32
213 + self.state_to_render_ms as u32
214 }
215
216 fn validate(self, surface: InteractionSurfaceKind) -> Result<(), ShowcaseInteractionSpecError> {
217 if self.input_to_route_ms == 0
218 || self.route_to_state_ms == 0
219 || self.state_to_render_ms == 0
220 || self.frame_budget_ms == 0
221 {
222 return Err(ShowcaseInteractionSpecError::InvalidLatencyBudget(
223 surface,
224 "latency hooks must all be > 0".to_owned(),
225 ));
226 }
227 if self.component_budget_ms() > u32::from(self.frame_budget_ms) {
228 return Err(ShowcaseInteractionSpecError::InvalidLatencyBudget(
229 surface,
230 format!(
231 "component budget {}ms exceeds frame budget {}ms",
232 self.component_budget_ms(),
233 self.frame_budget_ms
234 ),
235 ));
236 }
237 Ok(())
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct InteractionSurfaceContract {
244 pub surface: InteractionSurfaceKind,
245 pub cards: Vec<CardLayoutRule>,
246 pub palette_routes: Vec<PaletteIntentRoute>,
247 pub deterministic_boundaries: Vec<DeterministicStateBoundary>,
248 pub latency_hooks: InteractionLatencyHooks,
249}
250
251impl InteractionSurfaceContract {
252 fn validate(&self) -> Result<(), ShowcaseInteractionSpecError> {
253 if self.cards.is_empty() {
254 return Err(ShowcaseInteractionSpecError::EmptyCardGrammar(self.surface));
255 }
256 if self.palette_routes.is_empty() {
257 return Err(ShowcaseInteractionSpecError::EmptyPaletteRoutes(
258 self.surface,
259 ));
260 }
261
262 let mut card_ids = BTreeSet::new();
263 for card in &self.cards {
264 if !card_ids.insert(card.card_id.clone()) {
265 return Err(ShowcaseInteractionSpecError::DuplicateCardId(
266 self.surface,
267 card.card_id.clone(),
268 ));
269 }
270 }
271
272 let mut route_ids = BTreeSet::new();
273 for route in &self.palette_routes {
274 if !route_ids.insert(route.action_id.clone()) {
275 return Err(ShowcaseInteractionSpecError::DuplicatePaletteActionId(
276 self.surface,
277 route.action_id.clone(),
278 ));
279 }
280 }
281
282 let has_before_serialize = self
283 .deterministic_boundaries
284 .iter()
285 .any(|b| b.checkpoint == DeterministicCheckpoint::BeforeStateSerialize);
286 let has_after_serialize = self
287 .deterministic_boundaries
288 .iter()
289 .any(|b| b.checkpoint == DeterministicCheckpoint::AfterStateSerialize);
290 if !(has_before_serialize && has_after_serialize) {
291 return Err(ShowcaseInteractionSpecError::MissingSerializationBoundary(
292 self.surface,
293 ));
294 }
295
296 for boundary in &self.deterministic_boundaries {
297 if matches!(
298 boundary.checkpoint,
299 DeterministicCheckpoint::BeforeStateSerialize
300 | DeterministicCheckpoint::AfterStateSerialize
301 ) && boundary.state_keys.is_empty()
302 {
303 return Err(ShowcaseInteractionSpecError::EmptySerializationStateKeys(
304 self.surface,
305 ));
306 }
307 }
308
309 self.latency_hooks.validate(self.surface)
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct ShowcaseInteractionSpec {
316 pub spec_version: u16,
317 pub source_profile: String,
318 pub surfaces: Vec<InteractionSurfaceContract>,
319}
320
321impl ShowcaseInteractionSpec {
322 #[must_use]
324 pub fn canonical() -> Self {
325 Self {
326 spec_version: SHOWCASE_INTERACTION_SPEC_VERSION,
327 source_profile: "ftui-demo-showcase".to_owned(),
328 surfaces: vec![
329 search_surface_contract(),
330 results_surface_contract(),
331 operations_surface_contract(),
332 explainability_surface_contract(),
333 ],
334 }
335 }
336
337 #[must_use]
339 pub fn surface(&self, surface: InteractionSurfaceKind) -> Option<&InteractionSurfaceContract> {
340 self.surfaces
341 .iter()
342 .find(|candidate| candidate.surface == surface)
343 }
344
345 pub fn validate(&self) -> Result<(), ShowcaseInteractionSpecError> {
352 if self.spec_version != SHOWCASE_INTERACTION_SPEC_VERSION {
353 return Err(ShowcaseInteractionSpecError::UnsupportedSpecVersion(
354 self.spec_version,
355 ));
356 }
357
358 let mut seen = BTreeSet::new();
359 for surface in &self.surfaces {
360 if !seen.insert(surface.surface) {
361 return Err(ShowcaseInteractionSpecError::DuplicateSurface(
362 surface.surface,
363 ));
364 }
365 surface.validate()?;
366 }
367
368 for required in InteractionSurfaceKind::all() {
369 if !seen.contains(&required) {
370 return Err(ShowcaseInteractionSpecError::MissingSurface(required));
371 }
372 }
373
374 Ok(())
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
380pub enum ShowcaseInteractionSpecError {
381 UnsupportedSpecVersion(u16),
382 DuplicateSurface(InteractionSurfaceKind),
383 MissingSurface(InteractionSurfaceKind),
384 EmptyCardGrammar(InteractionSurfaceKind),
385 EmptyPaletteRoutes(InteractionSurfaceKind),
386 DuplicateCardId(InteractionSurfaceKind, String),
387 DuplicatePaletteActionId(InteractionSurfaceKind, String),
388 MissingSerializationBoundary(InteractionSurfaceKind),
389 EmptySerializationStateKeys(InteractionSurfaceKind),
390 InvalidLatencyBudget(InteractionSurfaceKind, String),
391}
392
393impl Display for ShowcaseInteractionSpecError {
394 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
395 match self {
396 Self::UnsupportedSpecVersion(version) => {
397 write!(
398 f,
399 "unsupported showcase interaction spec version: {version}"
400 )
401 }
402 Self::DuplicateSurface(surface) => {
403 write!(f, "duplicate showcase surface contract: {}", surface.id())
404 }
405 Self::MissingSurface(surface) => {
406 write!(f, "missing required showcase surface: {}", surface.id())
407 }
408 Self::EmptyCardGrammar(surface) => {
409 write!(f, "surface {} has empty card grammar", surface.id())
410 }
411 Self::EmptyPaletteRoutes(surface) => {
412 write!(f, "surface {} has empty palette routes", surface.id())
413 }
414 Self::DuplicateCardId(surface, card_id) => write!(
415 f,
416 "surface {} defines duplicate card id: {card_id}",
417 surface.id()
418 ),
419 Self::DuplicatePaletteActionId(surface, action_id) => write!(
420 f,
421 "surface {} defines duplicate palette action id: {action_id}",
422 surface.id()
423 ),
424 Self::MissingSerializationBoundary(surface) => write!(
425 f,
426 "surface {} is missing before/after serialization checkpoints",
427 surface.id()
428 ),
429 Self::EmptySerializationStateKeys(surface) => write!(
430 f,
431 "surface {} has serialization checkpoint with empty state keys",
432 surface.id()
433 ),
434 Self::InvalidLatencyBudget(surface, detail) => write!(
435 f,
436 "surface {} has invalid latency budget: {detail}",
437 surface.id()
438 ),
439 }
440 }
441}
442
443impl Error for ShowcaseInteractionSpecError {}
444
445fn search_surface_contract() -> InteractionSurfaceContract {
446 InteractionSurfaceContract {
447 surface: InteractionSurfaceKind::Search,
448 cards: vec![
449 CardLayoutRule::new(
450 "search.query",
451 CardRole::QueryInput,
452 LayoutAxis::Horizontal,
453 60,
454 3,
455 false,
456 true,
457 ),
458 CardLayoutRule::new(
459 "search.filters",
460 CardRole::Filters,
461 LayoutAxis::Horizontal,
462 40,
463 3,
464 false,
465 true,
466 ),
467 ],
468 palette_routes: vec![
469 PaletteIntentRoute::new(
470 PaletteIntent::FocusQuery,
471 "search.focus_query",
472 Some(InteractionSurfaceKind::Search),
473 false,
474 ),
475 PaletteIntentRoute::new(
476 PaletteIntent::RepeatQuery,
477 "search.repeat_last",
478 Some(InteractionSurfaceKind::Search),
479 false,
480 ),
481 ],
482 deterministic_boundaries: vec![
483 DeterministicStateBoundary::new(
484 DeterministicCheckpoint::BeforeInputDispatch,
485 vec!["active_screen", "palette.query", "search.query"],
486 ),
487 DeterministicStateBoundary::new(
488 DeterministicCheckpoint::BeforeStateSerialize,
489 vec!["search.query", "search.filters", "search.mode"],
490 ),
491 DeterministicStateBoundary::new(
492 DeterministicCheckpoint::AfterStateSerialize,
493 vec!["search.query", "search.filters", "search.cursor"],
494 ),
495 DeterministicStateBoundary::new(
496 DeterministicCheckpoint::AfterFrameCommit,
497 vec!["frame.seq", "search.focused"],
498 ),
499 ],
500 latency_hooks: InteractionLatencyHooks::new(4, 4, 8, 16),
501 }
502}
503
504fn results_surface_contract() -> InteractionSurfaceContract {
505 InteractionSurfaceContract {
506 surface: InteractionSurfaceKind::Results,
507 cards: vec![
508 CardLayoutRule::new(
509 "results.list",
510 CardRole::ResultList,
511 LayoutAxis::Vertical,
512 64,
513 12,
514 true,
515 true,
516 ),
517 CardLayoutRule::new(
518 "results.preview",
519 CardRole::ResultPreview,
520 LayoutAxis::Vertical,
521 48,
522 10,
523 false,
524 false,
525 ),
526 ],
527 palette_routes: vec![
528 PaletteIntentRoute::new(
529 PaletteIntent::NavigateSurface,
530 "nav.fsfs.search",
531 Some(InteractionSurfaceKind::Results),
532 false,
533 ),
534 PaletteIntentRoute::new(
535 PaletteIntent::ToggleExplainability,
536 "explain.toggle_panel",
537 Some(InteractionSurfaceKind::Explainability),
538 true,
539 ),
540 ],
541 deterministic_boundaries: vec![
542 DeterministicStateBoundary::new(
543 DeterministicCheckpoint::AfterInputDispatch,
544 vec!["results.selected_index", "results.scroll_offset"],
545 ),
546 DeterministicStateBoundary::new(
547 DeterministicCheckpoint::BeforeStateSerialize,
548 vec!["results.selected_doc_id", "results.visible_window"],
549 ),
550 DeterministicStateBoundary::new(
551 DeterministicCheckpoint::AfterStateSerialize,
552 vec!["results.selected_doc_id", "results.render_model_hash"],
553 ),
554 DeterministicStateBoundary::new(
555 DeterministicCheckpoint::BeforeFrameCommit,
556 vec!["frame.seq", "results.virtualized_window"],
557 ),
558 ],
559 latency_hooks: InteractionLatencyHooks::new(3, 5, 8, 16),
560 }
561}
562
563fn operations_surface_contract() -> InteractionSurfaceContract {
564 InteractionSurfaceContract {
565 surface: InteractionSurfaceKind::Operations,
566 cards: vec![
567 CardLayoutRule::new(
568 "ops.jobs",
569 CardRole::JobQueue,
570 LayoutAxis::Vertical,
571 48,
572 8,
573 false,
574 true,
575 ),
576 CardLayoutRule::new(
577 "ops.pressure",
578 CardRole::ResourcePressure,
579 LayoutAxis::Horizontal,
580 48,
581 6,
582 false,
583 true,
584 ),
585 CardLayoutRule::new(
586 "ops.timeline",
587 CardRole::Timeline,
588 LayoutAxis::Vertical,
589 64,
590 10,
591 true,
592 true,
593 ),
594 ],
595 palette_routes: vec![
596 PaletteIntentRoute::new(
597 PaletteIntent::PauseIndexing,
598 "index.pause",
599 Some(InteractionSurfaceKind::Operations),
600 false,
601 ),
602 PaletteIntentRoute::new(
603 PaletteIntent::ResumeIndexing,
604 "index.resume",
605 Some(InteractionSurfaceKind::Operations),
606 false,
607 ),
608 PaletteIntentRoute::new(
609 PaletteIntent::OpenTimeline,
610 "ops.open_timeline",
611 Some(InteractionSurfaceKind::Operations),
612 false,
613 ),
614 ],
615 deterministic_boundaries: vec![
616 DeterministicStateBoundary::new(
617 DeterministicCheckpoint::BeforeInputDispatch,
618 vec!["ops.active_lane", "ops.pause_state"],
619 ),
620 DeterministicStateBoundary::new(
621 DeterministicCheckpoint::BeforeStateSerialize,
622 vec![
623 "ops.queue_depth",
624 "ops.disk_budget_stage",
625 "ops.pressure_state",
626 ],
627 ),
628 DeterministicStateBoundary::new(
629 DeterministicCheckpoint::AfterStateSerialize,
630 vec!["ops.timeline_cursor", "ops.alert_counts"],
631 ),
632 DeterministicStateBoundary::new(
633 DeterministicCheckpoint::AfterFrameCommit,
634 vec!["frame.seq", "ops.timeline_window"],
635 ),
636 ],
637 latency_hooks: InteractionLatencyHooks::new(5, 4, 9, 20),
638 }
639}
640
641fn explainability_surface_contract() -> InteractionSurfaceContract {
642 InteractionSurfaceContract {
643 surface: InteractionSurfaceKind::Explainability,
644 cards: vec![
645 CardLayoutRule::new(
646 "explain.scores",
647 CardRole::ScoreBreakdown,
648 LayoutAxis::Vertical,
649 48,
650 8,
651 false,
652 true,
653 ),
654 CardLayoutRule::new(
655 "explain.provenance",
656 CardRole::Provenance,
657 LayoutAxis::Vertical,
658 48,
659 8,
660 false,
661 false,
662 ),
663 CardLayoutRule::new(
664 "explain.controls",
665 CardRole::OperatorControls,
666 LayoutAxis::Horizontal,
667 32,
668 4,
669 false,
670 false,
671 ),
672 ],
673 palette_routes: vec![
674 PaletteIntentRoute::new(
675 PaletteIntent::ToggleExplainability,
676 "explain.toggle_panel",
677 Some(InteractionSurfaceKind::Explainability),
678 false,
679 ),
680 PaletteIntentRoute::new(
681 PaletteIntent::ReplayTrace,
682 "diag.replay_trace",
683 Some(InteractionSurfaceKind::Explainability),
684 true,
685 ),
686 ],
687 deterministic_boundaries: vec![
688 DeterministicStateBoundary::new(
689 DeterministicCheckpoint::AfterInputDispatch,
690 vec!["explain.active_panel", "explain.selected_component"],
691 ),
692 DeterministicStateBoundary::new(
693 DeterministicCheckpoint::BeforeStateSerialize,
694 vec!["explain.rank_components", "explain.prior_evidence"],
695 ),
696 DeterministicStateBoundary::new(
697 DeterministicCheckpoint::AfterStateSerialize,
698 vec!["explain.panel_state_hash", "explain.selection_hash"],
699 ),
700 DeterministicStateBoundary::new(
701 DeterministicCheckpoint::BeforeFrameCommit,
702 vec!["frame.seq", "explain.viewport"],
703 ),
704 ],
705 latency_hooks: InteractionLatencyHooks::new(4, 6, 8, 20),
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::{
712 DeterministicCheckpoint, InteractionSurfaceKind, ShowcaseInteractionSpec,
713 ShowcaseInteractionSpecError,
714 };
715
716 #[test]
717 fn canonical_spec_contains_all_required_surfaces() {
718 let spec = ShowcaseInteractionSpec::canonical();
719 spec.validate().expect("canonical spec should validate");
720
721 for required in InteractionSurfaceKind::all() {
722 assert!(spec.surface(required).is_some());
723 }
724 }
725
726 #[test]
727 fn serialization_boundaries_are_present_for_each_surface() {
728 let spec = ShowcaseInteractionSpec::canonical();
729 for surface in &spec.surfaces {
730 assert!(
731 surface
732 .deterministic_boundaries
733 .iter()
734 .any(|b| b.checkpoint == DeterministicCheckpoint::BeforeStateSerialize)
735 );
736 assert!(
737 surface
738 .deterministic_boundaries
739 .iter()
740 .any(|b| b.checkpoint == DeterministicCheckpoint::AfterStateSerialize)
741 );
742 }
743 }
744
745 #[test]
746 fn latency_hooks_fit_inside_frame_budget() {
747 let spec = ShowcaseInteractionSpec::canonical();
748 for surface in &spec.surfaces {
749 assert!(
750 surface.latency_hooks.component_budget_ms()
751 <= u32::from(surface.latency_hooks.frame_budget_ms)
752 );
753 }
754 }
755
756 #[test]
757 fn validate_rejects_missing_required_surface() {
758 let mut spec = ShowcaseInteractionSpec::canonical();
759 spec.surfaces
760 .retain(|surface| surface.surface != InteractionSurfaceKind::Results);
761
762 let err = spec
763 .validate()
764 .expect_err("missing required surface must fail");
765 assert_eq!(
766 err,
767 ShowcaseInteractionSpecError::MissingSurface(InteractionSurfaceKind::Results)
768 );
769 }
770
771 #[test]
772 fn validate_rejects_duplicate_palette_routes() {
773 let mut spec = ShowcaseInteractionSpec::canonical();
774 let search_surface = spec
775 .surfaces
776 .iter_mut()
777 .find(|surface| surface.surface == InteractionSurfaceKind::Search)
778 .expect("search surface should exist");
779 let duplicated_route = search_surface.palette_routes[0].clone();
780 search_surface.palette_routes.push(duplicated_route.clone());
781
782 let err = spec.validate().expect_err("duplicate routes must fail");
783 assert_eq!(
784 err,
785 ShowcaseInteractionSpecError::DuplicatePaletteActionId(
786 InteractionSurfaceKind::Search,
787 duplicated_route.action_id
788 )
789 );
790 }
791
792 #[test]
795 fn surface_kind_ids_are_unique() {
796 let all = InteractionSurfaceKind::all();
797 let ids: Vec<&str> = all.iter().map(|kind| kind.id()).collect();
798 for (i, id) in ids.iter().enumerate() {
799 for (j, other) in ids.iter().enumerate() {
800 if i != j {
801 assert_ne!(id, other, "duplicate surface id: {id}");
802 }
803 }
804 }
805 }
806
807 #[test]
808 fn surface_kind_all_returns_four_variants() {
809 assert_eq!(InteractionSurfaceKind::all().len(), 4);
810 }
811
812 #[test]
813 fn surface_kind_ids_are_nonempty() {
814 for kind in InteractionSurfaceKind::all() {
815 assert!(!kind.id().is_empty());
816 }
817 }
818
819 #[test]
822 fn component_budget_sums_phases() {
823 let hooks = super::InteractionLatencyHooks::new(1, 2, 3, 10);
824 assert_eq!(hooks.component_budget_ms(), 6);
825 }
826
827 #[test]
828 fn validate_rejects_zero_latency_fields() {
829 let hooks = super::InteractionLatencyHooks::new(0, 2, 3, 10);
830 let err = hooks
831 .validate(InteractionSurfaceKind::Search)
832 .expect_err("zero field must fail");
833 match err {
834 ShowcaseInteractionSpecError::InvalidLatencyBudget(surface, _) => {
835 assert_eq!(surface, InteractionSurfaceKind::Search);
836 }
837 other => panic!("unexpected error: {other:?}"),
838 }
839 }
840
841 #[test]
842 fn validate_rejects_component_exceeding_frame() {
843 let hooks = super::InteractionLatencyHooks::new(5, 5, 5, 10);
844 let err = hooks
845 .validate(InteractionSurfaceKind::Results)
846 .expect_err("component > frame must fail");
847 match err {
848 ShowcaseInteractionSpecError::InvalidLatencyBudget(_, detail) => {
849 assert!(detail.contains("exceeds"));
850 }
851 other => panic!("unexpected error: {other:?}"),
852 }
853 }
854
855 #[test]
856 fn validate_accepts_component_equal_to_frame() {
857 let hooks = super::InteractionLatencyHooks::new(3, 3, 4, 10);
858 assert!(hooks.validate(InteractionSurfaceKind::Search).is_ok());
859 }
860
861 #[test]
864 fn validate_rejects_empty_card_grammar() {
865 let mut spec = ShowcaseInteractionSpec::canonical();
866 let surface = spec
867 .surfaces
868 .iter_mut()
869 .find(|s| s.surface == InteractionSurfaceKind::Search)
870 .unwrap();
871 surface.cards.clear();
872 let err = spec.validate().expect_err("empty cards must fail");
873 assert_eq!(
874 err,
875 ShowcaseInteractionSpecError::EmptyCardGrammar(InteractionSurfaceKind::Search)
876 );
877 }
878
879 #[test]
880 fn validate_rejects_empty_palette_routes() {
881 let mut spec = ShowcaseInteractionSpec::canonical();
882 let surface = spec
883 .surfaces
884 .iter_mut()
885 .find(|s| s.surface == InteractionSurfaceKind::Search)
886 .unwrap();
887 surface.palette_routes.clear();
888 let err = spec.validate().expect_err("empty routes must fail");
889 assert_eq!(
890 err,
891 ShowcaseInteractionSpecError::EmptyPaletteRoutes(InteractionSurfaceKind::Search)
892 );
893 }
894
895 #[test]
896 fn validate_rejects_duplicate_card_ids() {
897 let mut spec = ShowcaseInteractionSpec::canonical();
898 let surface = spec
899 .surfaces
900 .iter_mut()
901 .find(|s| s.surface == InteractionSurfaceKind::Search)
902 .unwrap();
903 let dup = surface.cards[0].clone();
904 surface.cards.push(dup.clone());
905 let err = spec.validate().expect_err("duplicate card ids must fail");
906 assert_eq!(
907 err,
908 ShowcaseInteractionSpecError::DuplicateCardId(
909 InteractionSurfaceKind::Search,
910 dup.card_id
911 )
912 );
913 }
914
915 #[test]
916 fn validate_rejects_missing_serialization_boundaries() {
917 let mut spec = ShowcaseInteractionSpec::canonical();
918 let surface = spec
919 .surfaces
920 .iter_mut()
921 .find(|s| s.surface == InteractionSurfaceKind::Search)
922 .unwrap();
923 surface
924 .deterministic_boundaries
925 .retain(|b| b.checkpoint != DeterministicCheckpoint::BeforeStateSerialize);
926 let err = spec
927 .validate()
928 .expect_err("missing serialization boundary must fail");
929 assert_eq!(
930 err,
931 ShowcaseInteractionSpecError::MissingSerializationBoundary(
932 InteractionSurfaceKind::Search
933 )
934 );
935 }
936
937 #[test]
938 fn validate_rejects_empty_state_keys_at_serialization_checkpoint() {
939 let mut spec = ShowcaseInteractionSpec::canonical();
940 let surface = spec
941 .surfaces
942 .iter_mut()
943 .find(|s| s.surface == InteractionSurfaceKind::Search)
944 .unwrap();
945 let boundary = surface
946 .deterministic_boundaries
947 .iter_mut()
948 .find(|b| b.checkpoint == DeterministicCheckpoint::BeforeStateSerialize)
949 .unwrap();
950 boundary.state_keys.clear();
951 let err = spec.validate().expect_err("empty state keys must fail");
952 assert_eq!(
953 err,
954 ShowcaseInteractionSpecError::EmptySerializationStateKeys(
955 InteractionSurfaceKind::Search
956 )
957 );
958 }
959
960 #[test]
961 fn validate_rejects_wrong_spec_version() {
962 let mut spec = ShowcaseInteractionSpec::canonical();
963 spec.spec_version = 999;
964 let err = spec.validate().expect_err("wrong version must fail");
965 assert_eq!(
966 err,
967 ShowcaseInteractionSpecError::UnsupportedSpecVersion(999)
968 );
969 }
970
971 #[test]
972 fn validate_rejects_duplicate_surfaces() {
973 let mut spec = ShowcaseInteractionSpec::canonical();
974 let dup = spec.surfaces[0].clone();
975 spec.surfaces.push(dup);
976 let err = spec.validate().expect_err("duplicate surface must fail");
977 assert!(matches!(
978 err,
979 ShowcaseInteractionSpecError::DuplicateSurface(_)
980 ));
981 }
982
983 #[test]
986 fn surface_lookup_returns_none_for_missing() {
987 let mut spec = ShowcaseInteractionSpec::canonical();
988 spec.surfaces
989 .retain(|s| s.surface != InteractionSurfaceKind::Explainability);
990 assert!(
991 spec.surface(InteractionSurfaceKind::Explainability)
992 .is_none()
993 );
994 }
995
996 #[test]
997 fn surface_lookup_returns_matching() {
998 let spec = ShowcaseInteractionSpec::canonical();
999 let search = spec.surface(InteractionSurfaceKind::Search);
1000 assert!(search.is_some());
1001 assert_eq!(search.unwrap().surface, InteractionSurfaceKind::Search);
1002 }
1003
1004 #[test]
1007 fn error_display_contains_surface_id() {
1008 let err = ShowcaseInteractionSpecError::EmptyCardGrammar(InteractionSurfaceKind::Search);
1009 let msg = format!("{err}");
1010 assert!(
1011 msg.contains("search"),
1012 "error should mention surface: {msg}"
1013 );
1014 }
1015
1016 #[test]
1017 fn error_display_version() {
1018 let err = ShowcaseInteractionSpecError::UnsupportedSpecVersion(42);
1019 let msg = format!("{err}");
1020 assert!(msg.contains("42"));
1021 }
1022
1023 #[test]
1026 fn canonical_spec_serde_roundtrip() {
1027 let spec = ShowcaseInteractionSpec::canonical();
1028 let json = serde_json::to_string(&spec).expect("serialize");
1029 let deser: ShowcaseInteractionSpec = serde_json::from_str(&json).expect("deserialize");
1030 assert_eq!(spec, deser);
1031 }
1032
1033 #[test]
1036 fn card_layout_rule_construction() {
1037 let rule = super::CardLayoutRule::new(
1038 "test.card",
1039 super::CardRole::QueryInput,
1040 super::LayoutAxis::Horizontal,
1041 40,
1042 3,
1043 true,
1044 false,
1045 );
1046 assert_eq!(rule.card_id, "test.card");
1047 assert!(rule.virtualized);
1048 assert!(!rule.sticky_header);
1049 }
1050
1051 #[test]
1052 fn palette_intent_route_construction() {
1053 let route = super::PaletteIntentRoute::new(
1054 super::PaletteIntent::FocusQuery,
1055 "test.action",
1056 None,
1057 true,
1058 );
1059 assert_eq!(route.action_id, "test.action");
1060 assert!(route.cross_screen_semantics);
1061 assert!(route.target_surface.is_none());
1062 }
1063
1064 #[test]
1065 fn deterministic_state_boundary_converts_keys() {
1066 let boundary = super::DeterministicStateBoundary::new(
1067 DeterministicCheckpoint::BeforeFrameCommit,
1068 vec!["key1", "key2"],
1069 );
1070 assert_eq!(boundary.state_keys, vec!["key1", "key2"]);
1071 }
1072}