Skip to main content

lingxia_surface/
lib.rs

1//! `lingxia-surface` — the platform-agnostic core of the Adaptive Surface
2//! Layout model (see `docs/internal/shell-ui-spec.md`).
3//!
4//! Pure Rust, no UI: the Surface Graph, its invariants and state transitions,
5//! the two-axis derivation into `DerivedLayout`, and the Host arbitration
6//! pure function. Each platform skin binds the `DerivedLayout` output.
7
8mod arbitrate;
9mod content;
10mod graph;
11mod layout;
12mod manager;
13mod model;
14mod presentation;
15mod switcher;
16
17pub use arbitrate::{Decision, OpenOutcome, Policy, arbitrate, normalize_initial_url};
18pub use content::{SlotKind, SurfaceContent};
19pub use graph::SurfaceGraph;
20pub use layout::PlanAsideSlot;
21pub use layout::{
22    Axis, BottomOwner, DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, LayoutTree,
23    PlanAside, PlanFloat, SizeClass, SplitForm, SwitcherForm,
24};
25pub use manager::SurfaceManager;
26pub use model::{
27    Edge, FloatAnchor, FloatDismiss, FloatSpec, Placement, Role, Surface, SurfaceId,
28    SurfaceInteraction, SurfaceOwner, SurfaceState,
29};
30pub use presentation::{SurfaceCapabilities, SurfaceIcon, SurfacePresentation};
31pub use switcher::{
32    CloseOutcome, ReplaceMainsError, SurfaceSwitcherItem, SurfaceSwitcherSnapshot,
33    SwitcherContentKind,
34};
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39
40    fn main_s(id: &str) -> Surface {
41        Surface::lxapp(id, Role::Main, id)
42    }
43    fn aside_s(id: &str, edge: Edge) -> Surface {
44        let mut s = Surface::lxapp(id, Role::Aside, id);
45        s.placement.edge = Some(edge);
46        s
47    }
48    fn web_aside_s(id: &str, url: &str, edge: Edge) -> Surface {
49        let mut s = aside_s(id, edge);
50        s.content = SurfaceContent::Browser {
51            initial_url: url.to_string(),
52            reuse_by_url: true,
53        };
54        s
55    }
56
57    fn non_reusable_web_aside_s(id: &str, url: &str, edge: Edge) -> Surface {
58        let mut surface = web_aside_s(id, url, edge);
59        if let SurfaceContent::Browser { reuse_by_url, .. } = &mut surface.content {
60            *reuse_by_url = false;
61        }
62        surface
63    }
64    fn terminal_aside_s(id: &str, edge: Edge) -> Surface {
65        let mut s = Surface::native(id, Role::Aside, "terminal");
66        s.placement.edge = Some(edge);
67        s
68    }
69
70    // ---- invariants & state transitions (§1.3 / §1.5) ----
71
72    #[test]
73    fn empty_graph_is_valid() {
74        let g = SurfaceGraph::new();
75        assert!(g.is_valid());
76        assert_eq!(g.active_main_id, None);
77        assert_eq!(g.focused_surface_id, None);
78    }
79
80    #[test]
81    fn first_main_becomes_active_and_focused() {
82        let mut g = SurfaceGraph::new();
83        g.insert(main_s("home"));
84        assert_eq!(g.active_main_id.as_deref(), Some("home"));
85        assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
86        assert!(g.is_valid());
87    }
88
89    #[test]
90    fn root_main_keeps_companion_graph_anchored() {
91        // Construct an illegal graph directly and assert the checker catches it.
92        let mut g = SurfaceGraph::new();
93        g.insert(main_s("home"));
94        g.insert(aside_s("assistant", Edge::Right));
95        assert!(g.is_valid());
96        // The first main is the stable root; ordinary close cannot remove it
97        // or cascade its companions.
98        assert_eq!(
99            g.close("home"),
100            CloseOutcome::RejectedRoot {
101                surface_id: "home".into()
102            }
103        );
104        assert_eq!(g.asides().len(), 1);
105        assert_eq!(g.active_main_id.as_deref(), Some("home"));
106        assert!(g.is_valid());
107    }
108
109    #[test]
110    fn closing_active_main_picks_adjacent_successor() {
111        let mut g = SurfaceGraph::new();
112        g.insert(main_s("a"));
113        g.insert(main_s("b"));
114        g.insert(main_s("c"));
115        g.set_active_main("b");
116        g.close("b");
117        // prefer the next main after the removed position.
118        assert_eq!(g.active_main_id.as_deref(), Some("c"));
119        assert!(g.is_valid());
120    }
121
122    #[test]
123    fn modal_float_restores_focus_on_close() {
124        let mut g = SurfaceGraph::new();
125        g.insert(main_s("home"));
126        assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
127        let mut modal = Surface::native("dialog", Role::Float, "confirm");
128        modal.float = Some(FloatSpec {
129            modal: true,
130            ..Default::default()
131        });
132        g.insert(modal);
133        g.set_focus("dialog");
134        g.close("dialog");
135        assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
136        assert!(g.is_valid());
137    }
138
139    // ---- two-axis derivation (§2 / §6) ----
140
141    #[test]
142    fn single_main_no_switcher_no_split() {
143        let mut g = SurfaceGraph::new();
144        g.insert(main_s("home"));
145        let d = g.derive_layout(SizeClass::Expanded);
146        assert_eq!(d.switcher_form, SwitcherForm::None);
147        assert_eq!(d.split_form, SplitForm::None);
148        assert!(matches!(d.layout_tree, Some(LayoutTree::Leaf { .. })));
149    }
150
151    #[test]
152    fn switcher_only_with_multiple_mains() {
153        let mut g = SurfaceGraph::new();
154        g.insert(main_s("a"));
155        assert_eq!(
156            g.derive_layout(SizeClass::Expanded).switcher_form,
157            SwitcherForm::None
158        );
159        g.insert(main_s("b"));
160        assert_eq!(
161            g.derive_layout(SizeClass::Expanded).switcher_form,
162            SwitcherForm::Sidebar
163        );
164    }
165
166    #[test]
167    fn aside_splits_on_expanded_fullscreen_on_compact() {
168        let mut g = SurfaceGraph::new();
169        g.insert(main_s("home"));
170        g.insert(aside_s("assistant", Edge::Right));
171        assert_eq!(
172            g.derive_layout(SizeClass::Expanded).split_form,
173            SplitForm::Split
174        );
175        assert_eq!(
176            g.derive_layout(SizeClass::Compact).split_form,
177            SplitForm::FullScreen
178        );
179    }
180
181    #[test]
182    fn compact_plan_keeps_existing_aside_desired() {
183        let mut g = SurfaceGraph::new();
184        g.insert(main_s("home"));
185        g.insert(aside_s("assistant", Edge::Right));
186
187        let plan = g.presentation_plan(
188            SizeClass::Compact,
189            390.0,
190            &crate::arbitrate::Policy::default(),
191        );
192        assert_eq!(plan.split_form, SplitForm::FullScreen);
193        assert!(plan.asides.iter().any(|aside| aside.id == "assistant"));
194        assert!(
195            plan.tree
196                .as_ref()
197                .is_some_and(|tree| tree.surface_ids().iter().any(|id| id == "assistant"))
198        );
199    }
200
201    #[test]
202    fn compact_bottom_owner_stays_app() {
203        let mut g = SurfaceGraph::new();
204        g.insert(main_s("a"));
205        // single main → app owns bottom
206        assert_eq!(
207            g.derive_layout(SizeClass::Compact).bottom_owner,
208            BottomOwner::App
209        );
210        g.insert(main_s("b"));
211        // compact has no separate switcher.
212        assert_eq!(
213            g.derive_layout(SizeClass::Compact).bottom_owner,
214            BottomOwner::App
215        );
216    }
217
218    #[test]
219    fn canonical_layout_validates() {
220        let mut g = SurfaceGraph::new();
221        g.insert(main_s("a"));
222        g.insert(main_s("b"));
223        g.insert(aside_s("assistant", Edge::Right));
224        let tree = g.canonical_layout(SizeClass::Expanded).unwrap();
225        tree.validate().expect("canonical tree must be valid");
226        // floats never appear in the tree.
227        g.insert(Surface::native("toast", Role::Float, "toast"));
228        let ids = g
229            .canonical_layout(SizeClass::Expanded)
230            .unwrap()
231            .surface_ids();
232        assert!(!ids.contains(&"toast".to_string()));
233    }
234
235    // ---- sizeClass breakpoints + hysteresis (§6.1) ----
236
237    #[test]
238    fn breakpoints_align_to_material() {
239        assert_eq!(SizeClass::from_width(599.0), SizeClass::Compact);
240        assert_eq!(SizeClass::from_width(600.0), SizeClass::Medium);
241        assert_eq!(SizeClass::from_width(840.0), SizeClass::Medium);
242        assert_eq!(SizeClass::from_width(841.0), SizeClass::Expanded);
243    }
244
245    #[test]
246    fn hysteresis_holds_class_near_boundary() {
247        // sitting just under the 600 boundary, within margin, keeps Medium.
248        let held = SizeClass::resolve(Some(SizeClass::Medium), 590.0, DEFAULT_HYSTERESIS);
249        assert_eq!(held, SizeClass::Medium);
250        // clearly past the boundary switches.
251        let switched = SizeClass::resolve(Some(SizeClass::Medium), 500.0, DEFAULT_HYSTERESIS);
252        assert_eq!(switched, SizeClass::Compact);
253    }
254
255    #[test]
256    fn hysteresis_does_not_hold_across_two_classes() {
257        assert_eq!(
258            SizeClass::resolve(Some(SizeClass::Expanded), 590.0, DEFAULT_HYSTERESIS),
259            SizeClass::Compact
260        );
261        assert_eq!(
262            SizeClass::resolve(Some(SizeClass::Compact), 850.0, DEFAULT_HYSTERESIS),
263            SizeClass::Expanded
264        );
265    }
266
267    // ---- arbitration (§3.4) ----
268
269    #[test]
270    fn same_kind_asides_join_their_slot() {
271        let mut g = SurfaceGraph::new();
272        g.insert(main_s("home"));
273        g.insert(aside_s("a1", Edge::Right));
274        // A second lxapp aside joins the ONE lxapp slot as another tab —
275        // nothing is evicted, tab order = open order, newest tab is active.
276        let (next, decision) = arbitrate(
277            &g,
278            aside_s("a2", Edge::Right),
279            &Policy::default(),
280            SizeClass::Expanded,
281        );
282        assert_eq!(decision, Decision::MergedIntoTabs);
283        assert!(next.get("a1").is_some());
284        assert!(next.get("a2").is_some());
285        let slots = next.aside_slots(SizeClass::Expanded);
286        assert_eq!(slots.len(), 1);
287        assert_eq!(slots[0].kind, SlotKind::Lxapp);
288        assert_eq!(slots[0].children, vec!["a1".to_string(), "a2".to_string()]);
289        assert_eq!(slots[0].active_child.as_deref(), Some("a2"));
290        assert!(slots[0].visible);
291        assert!(next.is_valid());
292    }
293
294    #[test]
295    fn slots_hide_beyond_admission_never_evict() {
296        let mut next = SurfaceGraph::new();
297        next.insert(main_s("home"));
298        for request in [
299            aside_s("chat", Edge::Right),
300            terminal_aside_s("terminal", Edge::Bottom),
301            web_aside_s("b1", "https://a.example", Edge::Right),
302        ] {
303            let (n, d) = arbitrate(&next, request, &Policy::default(), SizeClass::Expanded);
304            assert_eq!(d, Decision::Accepted);
305            next = n;
306        }
307        // Three kinds → three slots, all admitted on expanded.
308        let expanded = next.aside_slots(SizeClass::Expanded);
309        assert_eq!(expanded.len(), 3);
310        assert!(expanded.iter().all(|slot| slot.visible));
311        // Medium admits only the most recently used slot; the others stay
312        // alive hidden — the graph itself never shrinks.
313        let medium = next.aside_slots(SizeClass::Medium);
314        assert_eq!(medium.iter().filter(|slot| slot.visible).count(), 1);
315        assert!(
316            medium
317                .iter()
318                .find(|slot| slot.visible)
319                .is_some_and(|slot| slot.kind == SlotKind::Browser)
320        );
321        assert_eq!(next.asides().len(), 3);
322        assert!(next.is_valid());
323    }
324
325    #[test]
326    fn physical_admission_caps_below_the_count_ceiling() {
327        // Two right-docked lxapp/browser slots + one bottom terminal slot.
328        let mut next = SurfaceGraph::new();
329        next.insert(main_s("home"));
330        for request in [
331            aside_s("chat", Edge::Right),
332            web_aside_s("b1", "https://a.example", Edge::Right),
333            terminal_aside_s("terminal", Edge::Bottom),
334        ] {
335            let (n, _) = arbitrate(&next, request, &Policy::default(), SizeClass::Expanded);
336            next = n;
337        }
338        let policy = Policy::default(); // main_min 360, aside_min 240
339
340        // Wide expanded (1200): main 360 + 2×240 = 840 ≤ 1200 → both right
341        // slots fit; the bottom terminal never consumes horizontal budget.
342        let wide = next.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
343        assert_eq!(wide.iter().filter(|s| s.visible).count(), 3);
344
345        // Narrow expanded (850): count ceiling says 3, but 360 + 240 = 600 ≤
346        // 850 fits ONE right slot; the second right slot (600 + 240 = 840 —
347        // wait, 840 ≤ 850) — pick 700 to force exactly one.
348        let narrow = next.aside_slots_admitted(SizeClass::Expanded, 700.0, &policy);
349        let visible_right = narrow
350            .iter()
351            .filter(|s| s.visible && !matches!(s.edge, Some(Edge::Bottom)))
352            .count();
353        assert_eq!(visible_right, 1, "only one right slot fits at 700pt");
354        // The bottom terminal stays visible regardless of horizontal budget.
355        assert!(
356            narrow
357                .iter()
358                .any(|s| s.visible && matches!(s.edge, Some(Edge::Bottom)))
359        );
360        // Nothing was evicted — the graph still holds all three asides.
361        assert_eq!(next.asides().len(), 3);
362    }
363
364    #[test]
365    fn slot_admission_uses_policy_and_true_focus_recency() {
366        let mut graph = SurfaceGraph::new();
367        graph.insert(main_s("home"));
368        graph.insert(aside_s("chat", Edge::Right));
369        graph.insert(web_aside_s("browser", "https://example.com", Edge::Right));
370
371        // Browser was opened last, so it initially owns Medium's one slot.
372        let medium = graph.aside_slots(SizeClass::Medium);
373        assert_eq!(
374            medium
375                .iter()
376                .find(|slot| slot.visible)
377                .map(|slot| slot.kind),
378            Some(SlotKind::Browser)
379        );
380
381        // Focusing an older slot makes it MRU without changing tab/open order.
382        assert!(graph.set_focus("chat"));
383        let medium = graph.aside_slots(SizeClass::Medium);
384        assert_eq!(
385            medium
386                .iter()
387                .find(|slot| slot.visible)
388                .map(|slot| slot.kind),
389            Some(SlotKind::Lxapp)
390        );
391
392        let policy = Policy {
393            max_asides_expanded: 1,
394            ..Policy::default()
395        };
396        let expanded = graph.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
397        assert_eq!(expanded.iter().filter(|slot| slot.visible).count(), 1);
398        assert_eq!(
399            expanded
400                .iter()
401                .find(|slot| slot.visible)
402                .map(|slot| slot.kind),
403            Some(SlotKind::Lxapp)
404        );
405    }
406
407    #[test]
408    fn physical_admission_keeps_the_most_recent_horizontal_slot() {
409        let mut graph = SurfaceGraph::new();
410        graph.insert(main_s("home"));
411        graph.insert(aside_s("chat", Edge::Right));
412        graph.insert(web_aside_s("browser", "https://example.com", Edge::Right));
413        graph.insert(terminal_aside_s("terminal", Edge::Right));
414
415        // 700 fits main + one horizontal slot. The newest slot wins, even
416        // though returned slots stay in stable first-open order.
417        let admitted = graph.aside_slots_admitted(SizeClass::Expanded, 700.0, &Policy::default());
418        let visible: Vec<_> = admitted
419            .iter()
420            .filter(|slot| slot.visible)
421            .map(|slot| slot.kind)
422            .collect();
423        assert_eq!(visible, vec![SlotKind::Native]);
424    }
425
426    #[test]
427    fn physical_admission_falls_back_to_an_older_fitting_slot() {
428        let mut graph = SurfaceGraph::new();
429        graph.insert(main_s("home"));
430        let mut terminal = terminal_aside_s("terminal", Edge::Top);
431        terminal.placement.edge = Some(Edge::Top);
432        graph.insert(terminal);
433        graph.insert(aside_s("chat", Edge::Right));
434
435        // Medium admits one slot. The MRU right slot cannot fit beside main,
436        // so the older top overlay must be considered instead of leaving the
437        // entire aside area empty.
438        let admitted = graph.aside_slots_admitted(SizeClass::Medium, 500.0, &Policy::default());
439        let visible: Vec<_> = admitted
440            .iter()
441            .filter(|slot| slot.visible)
442            .map(|slot| slot.kind)
443            .collect();
444        assert_eq!(visible, vec![SlotKind::Native]);
445    }
446
447    #[test]
448    fn web_asides_coexist_as_tabs() {
449        let mut g = SurfaceGraph::new();
450        g.insert(main_s("home"));
451        g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
452        // A second browser aside for a DIFFERENT url coexists as another tab of
453        // the one multi-tab panel (exempt from the generic aside cap), not
454        // replacing the first.
455        let (next, decision) = arbitrate(
456            &g,
457            web_aside_s("browser-2", "https://b.example", Edge::Right),
458            &Policy::default(),
459            SizeClass::Expanded,
460        );
461        assert_eq!(decision, Decision::MergedIntoTabs);
462        assert_eq!(decision.resolved_surface_id, "browser-2");
463        assert!(next.get("browser-1").is_some());
464        assert!(next.get("browser-2").is_some());
465        assert_eq!(
466            next.asides()
467                .iter()
468                .filter(|s| matches!(s.content, SurfaceContent::Browser { .. }))
469                .count(),
470            2
471        );
472        assert!(next.is_valid());
473    }
474
475    #[test]
476    fn hiding_active_aside_selects_recent_visible_sibling() {
477        let mut graph = SurfaceGraph::new();
478        graph.insert(main_s("home"));
479        graph.insert(aside_s("first", Edge::Right));
480        graph.insert(aside_s("second", Edge::Right));
481        graph.set_focus("first");
482        graph.set_focus("second");
483
484        assert!(graph.hide("second"));
485        assert_eq!(graph.focused_surface_id.as_deref(), Some("first"));
486        assert_eq!(
487            graph.get("second").map(|surface| surface.state),
488            Some(SurfaceState::Hidden)
489        );
490        let slots = graph.aside_slots(SizeClass::Expanded);
491        assert_eq!(slots[0].active_child.as_deref(), Some("first"));
492
493        assert!(graph.show("second"));
494        assert_eq!(graph.focused_surface_id.as_deref(), Some("second"));
495        assert_eq!(
496            graph.get("second").map(|surface| surface.state),
497            Some(SurfaceState::Mounted)
498        );
499    }
500
501    #[test]
502    fn closing_active_aside_selects_recent_visible_sibling() {
503        let mut graph = SurfaceGraph::new();
504        graph.insert(main_s("home"));
505        graph.insert(web_aside_s("first", "https://one.example", Edge::Right));
506        graph.insert(web_aside_s("second", "https://two.example", Edge::Right));
507        graph.set_focus("first");
508        graph.set_focus("second");
509
510        assert_eq!(
511            graph.close("second"),
512            CloseOutcome::Closed {
513                removed: vec!["second".into()]
514            }
515        );
516        assert_eq!(graph.focused_surface_id.as_deref(), Some("first"));
517        assert_eq!(
518            graph.aside_slots(SizeClass::Expanded)[0]
519                .active_child
520                .as_deref(),
521            Some("first")
522        );
523    }
524
525    #[test]
526    fn web_aside_dedups_by_url() {
527        let mut g = SurfaceGraph::new();
528        g.insert(main_s("home"));
529        g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
530        // Reopening the same url focuses the existing tab instead of adding a
531        // duplicate — no new surface is inserted.
532        let (next, decision) = arbitrate(
533            &g,
534            web_aside_s("browser-2", "https://a.example", Edge::Right),
535            &Policy::default(),
536            SizeClass::Expanded,
537        );
538        assert_eq!(decision, Decision::MergedIntoTabs);
539        assert_eq!(decision.resolved_surface_id, "browser-1");
540        assert_eq!(decision.resolved_role, Role::Aside);
541        assert!(next.get("browser-1").is_some());
542        assert!(next.get("browser-2").is_none());
543        assert_eq!(
544            next.asides()
545                .iter()
546                .filter(|s| matches!(s.content, SurfaceContent::Browser { .. }))
547                .count(),
548            1
549        );
550        assert!(next.is_valid());
551    }
552
553    #[test]
554    fn non_reusable_web_asides_never_dedup_by_url() {
555        let mut graph = SurfaceGraph::new();
556        graph.insert(main_s("home"));
557        graph.insert(web_aside_s("ordinary", "https://a.example", Edge::Right));
558
559        let (graph, callback_outcome) = arbitrate(
560            &graph,
561            non_reusable_web_aside_s("callback", "https://a.example", Edge::Right),
562            &Policy::default(),
563            SizeClass::Expanded,
564        );
565        assert_eq!(callback_outcome.resolved_surface_id, "callback");
566        assert!(graph.get("ordinary").is_some());
567        assert!(graph.get("callback").is_some());
568
569        let (graph, ordinary_outcome) = arbitrate(
570            &graph,
571            web_aside_s("ordinary-2", "https://a.example", Edge::Right),
572            &Policy::default(),
573            SizeClass::Expanded,
574        );
575        assert_eq!(ordinary_outcome.resolved_surface_id, "ordinary");
576        assert!(graph.get("ordinary-2").is_none());
577        assert!(graph.get("callback").is_some());
578    }
579
580    #[test]
581    fn web_aside_url_key_normalizes_origin_and_empty_path_only() {
582        assert_eq!(
583            normalize_initial_url("HTTPS://Example.COM:443"),
584            "https://example.com/"
585        );
586        assert_eq!(
587            normalize_initial_url("https://example.com/?q=One#Top"),
588            "https://example.com/?q=One#Top"
589        );
590        assert_ne!(
591            normalize_initial_url("https://example.com/?q=One#Top"),
592            normalize_initial_url("https://example.com/?q=one#Top")
593        );
594
595        let mut graph = SurfaceGraph::new();
596        graph.insert(main_s("home"));
597        let (graph, _) = arbitrate(
598            &graph,
599            web_aside_s("browser-1", "HTTPS://Example.COM:443", Edge::Right),
600            &Policy::default(),
601            SizeClass::Expanded,
602        );
603        let (graph, outcome) = arbitrate(
604            &graph,
605            web_aside_s("browser-2", "https://example.com/", Edge::Right),
606            &Policy::default(),
607            SizeClass::Expanded,
608        );
609        assert_eq!(outcome.resolved_surface_id, "browser-1");
610        assert!(graph.get("browser-2").is_none());
611    }
612
613    #[test]
614    fn browser_asides_never_evict_a_declared_aside() {
615        let mut next = SurfaceGraph::new();
616        next.insert(main_s("home"));
617        next.insert(aside_s("chat", Edge::Right)); // declared lxapp aside
618        // Open browser tabs beyond the generic aside cap (expanded=2). The
619        // declared `chat` aside must survive — web + non-web budgets are
620        // independent (they coexist as separate side panels).
621        for (i, url) in [
622            "https://a.example",
623            "https://b.example",
624            "https://c.example",
625        ]
626        .iter()
627        .enumerate()
628        {
629            let (n, d) = arbitrate(
630                &next,
631                web_aside_s(&format!("b{i}"), url, Edge::Right),
632                &Policy::default(),
633                SizeClass::Expanded,
634            );
635            // The first web aside claims the browser slot; the rest join it.
636            let expected = if i == 0 {
637                Decision::Accepted
638            } else {
639                Decision::MergedIntoTabs
640            };
641            assert_eq!(d, expected);
642            next = n;
643        }
644        assert!(next.get("chat").is_some(), "declared aside must survive");
645        assert_eq!(
646            next.asides()
647                .iter()
648                .filter(|s| matches!(s.content, SurfaceContent::Browser { .. }))
649                .count(),
650            3
651        );
652        assert!(next.is_valid());
653    }
654
655    #[test]
656    fn web_aside_coexists_with_a_page_aside() {
657        let mut g = SurfaceGraph::new();
658        g.insert(main_s("home"));
659        g.insert(aside_s("assistant", Edge::Right));
660        // A browser aside does NOT evict a non-web (declared/page) aside; both
661        // coexist under the expanded cap of 2.
662        let (next, decision) = arbitrate(
663            &g,
664            web_aside_s("browser-1", "https://a.example", Edge::Left),
665            &Policy::default(),
666            SizeClass::Expanded,
667        );
668        assert_eq!(decision, Decision::Accepted);
669        assert!(next.get("assistant").is_some());
670        assert!(next.get("browser-1").is_some());
671        assert_eq!(next.asides().len(), 2);
672        assert!(next.is_valid());
673    }
674
675    #[test]
676    fn aside_fullscreen_fallback_on_compact() {
677        let mut g = SurfaceGraph::new();
678        g.insert(main_s("home"));
679        let (next, decision) = arbitrate(
680            &g,
681            aside_s("assistant", Edge::Right),
682            &Policy::default(),
683            SizeClass::Compact,
684        );
685        assert_eq!(decision, Decision::FullScreenFallback);
686        assert!(decision.overlay);
687        assert_eq!(next.role_of("assistant"), Some(Role::Aside));
688        assert_eq!(next.active_main_id.as_deref(), Some("home"));
689        assert_eq!(
690            next.presentation_plan(
691                SizeClass::Compact,
692                390.0,
693                &crate::arbitrate::Policy::default()
694            )
695            .active_main_id
696            .as_deref(),
697            Some("home")
698        );
699        assert!(next.is_valid());
700    }
701
702    #[test]
703    fn aside_without_primary_promotes_to_main() {
704        // No main yet, expanded (room for asides) — an aside has nothing to
705        // dock to, so it must become the main, keeping the graph valid.
706        let g = SurfaceGraph::new();
707        let (next, decision) = arbitrate(
708            &g,
709            aside_s("assistant", Edge::Right),
710            &Policy::default(),
711            SizeClass::Expanded,
712        );
713        assert_eq!(decision, Decision::DowngradedRole);
714        assert_eq!(next.role_of("assistant"), Some(Role::Main));
715        assert!(next.is_valid());
716    }
717
718    #[test]
719    fn arbitrate_is_pure_and_keeps_graph_valid() {
720        let mut g = SurfaceGraph::new();
721        g.insert(main_s("home"));
722        let before = g.surfaces().len();
723        let (next, _) = arbitrate(
724            &g,
725            aside_s("x", Edge::Left),
726            &Policy::default(),
727            SizeClass::Expanded,
728        );
729        // original graph untouched (pure); result is valid.
730        assert_eq!(g.surfaces().len(), before);
731        assert!(next.is_valid());
732    }
733
734    // ---- serde round-trip (shared core <-> JSON for ui.json / FFI) ----
735
736    #[test]
737    fn surface_json_round_trip() {
738        let s = aside_s("assistant", Edge::Right);
739        let json = serde_json::to_string(&s).unwrap();
740        let back: Surface = serde_json::from_str(&json).unwrap();
741        assert_eq!(s, back);
742    }
743
744    #[test]
745    fn browser_surface_json_preserves_reuse_policy_default() {
746        let ordinary = web_aside_s("ordinary", "https://a.example", Edge::Right);
747        let ordinary_json = serde_json::to_string(&ordinary).unwrap();
748        assert!(!ordinary_json.contains("reuseByUrl"));
749        assert_eq!(
750            serde_json::from_str::<Surface>(&ordinary_json).unwrap(),
751            ordinary
752        );
753
754        let callback = non_reusable_web_aside_s("callback", "https://a.example", Edge::Right);
755        let callback_json = serde_json::to_string(&callback).unwrap();
756        assert!(callback_json.contains("\"reuseByUrl\":false"));
757        assert_eq!(
758            serde_json::from_str::<Surface>(&callback_json).unwrap(),
759            callback
760        );
761    }
762}