Skip to main content

frankensearch_tui/
interaction.rs

1//! Canonical interaction primitives ported from ftui-demo showcase patterns.
2//!
3//! This module defines product-agnostic contracts for:
4//! - card/layout grammar
5//! - command-palette intent semantics
6//! - deterministic state serialization checkpoints
7//! - interaction latency budget hooks
8
9use std::collections::BTreeSet;
10use std::error::Error;
11use std::fmt::{Display, Formatter};
12
13use serde::{Deserialize, Serialize};
14
15/// Versioned schema for interaction primitive contracts.
16pub const SHOWCASE_INTERACTION_SPEC_VERSION: u16 = 1;
17
18/// High-level interaction surfaces shared across frankensearch TUIs.
19#[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    /// Stable identifier for serialization and diagnostics.
30    #[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    /// Required canonical surfaces from the showcase contract.
41    #[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/// Preferred layout axis for a card region.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum LayoutAxis {
56    Horizontal,
57    Vertical,
58}
59
60/// Semantic role of a layout card.
61#[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/// Canonical card/layout grammar rule.
77#[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/// Intent-level command semantics used by the command palette.
112#[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/// Route from a palette action ID to canonical intent semantics.
126#[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/// Deterministic checkpoints used by replay/snapshot contracts.
153#[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/// Explicit state boundary serialized at a deterministic checkpoint.
165#[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/// Latency budget hooks exposed at interaction/component boundaries.
182#[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    /// Returns the total component budget in milliseconds.
207    ///
208    /// Uses `u32` to avoid overflow when summing three `u16` fields.
209    #[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/// Contract for one canonical interaction surface.
242#[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/// Canonical interaction contract snapshot.
314#[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    /// Canonical showcase-derived contract that downstream TUIs should map to.
323    #[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    /// Look up a surface by kind.
338    #[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    /// Validate contract determinism and replay/snapshot suitability.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`ShowcaseInteractionSpecError`] if the contract is incomplete
350    /// or violates deterministic interaction constraints.
351    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/// Validation failure for showcase interaction contracts.
379#[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    // ── InteractionSurfaceKind tests ─────────────────────────────────
793
794    #[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    // ── InteractionLatencyHooks tests ────────────────────────────────
820
821    #[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    // ── Contract validation tests ────────────────────────────────────
862
863    #[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    // ── surface() lookup ─────────────────────────────────────────────
984
985    #[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    // ── Error Display formatting ─────────────────────────────────────
1005
1006    #[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    // ── Serde roundtrip ──────────────────────────────────────────────
1024
1025    #[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    // ── Construction helpers ─────────────────────────────────────────
1034
1035    #[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}