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, ContentSizeClass, DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan,
23    LayoutTree, 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    #[test]
268    fn content_class_collapses_medium_and_expanded() {
269        assert_eq!(
270            ContentSizeClass::from_width(599.0),
271            ContentSizeClass::Compact
272        );
273        assert_eq!(
274            ContentSizeClass::from_width(600.0),
275            ContentSizeClass::Regular
276        );
277        assert_eq!(
278            ContentSizeClass::from_width(840.0),
279            ContentSizeClass::Regular
280        );
281        assert_eq!(
282            ContentSizeClass::from_width(841.0),
283            ContentSizeClass::Regular
284        );
285        assert_eq!(SizeClass::Medium.to_content(), ContentSizeClass::Regular);
286        assert_eq!(SizeClass::Expanded.to_content(), ContentSizeClass::Regular);
287    }
288
289    #[test]
290    fn content_hysteresis_is_only_at_compact_boundary() {
291        let held =
292            ContentSizeClass::resolve(Some(ContentSizeClass::Regular), 590.0, DEFAULT_HYSTERESIS);
293        assert_eq!(held, ContentSizeClass::Regular);
294        let switched =
295            ContentSizeClass::resolve(Some(ContentSizeClass::Regular), 500.0, DEFAULT_HYSTERESIS);
296        assert_eq!(switched, ContentSizeClass::Compact);
297        assert_eq!(
298            ContentSizeClass::resolve(Some(ContentSizeClass::Regular), 900.0, DEFAULT_HYSTERESIS),
299            ContentSizeClass::Regular
300        );
301    }
302
303    // ---- arbitration (§3.4) ----
304
305    #[test]
306    fn same_kind_asides_join_their_slot() {
307        let mut g = SurfaceGraph::new();
308        g.insert(main_s("home"));
309        g.insert(aside_s("a1", Edge::Right));
310        // A second lxapp aside joins the ONE lxapp slot as another tab —
311        // nothing is evicted, tab order = open order, newest tab is active.
312        let (next, decision) = arbitrate(
313            &g,
314            aside_s("a2", Edge::Right),
315            &Policy::default(),
316            SizeClass::Expanded,
317        );
318        assert_eq!(decision, Decision::MergedIntoTabs);
319        assert!(next.get("a1").is_some());
320        assert!(next.get("a2").is_some());
321        let slots = next.aside_slots(SizeClass::Expanded);
322        assert_eq!(slots.len(), 1);
323        assert_eq!(slots[0].kind, SlotKind::Lxapp);
324        assert_eq!(slots[0].children, vec!["a1".to_string(), "a2".to_string()]);
325        assert_eq!(slots[0].active_child.as_deref(), Some("a2"));
326        assert!(slots[0].visible);
327        assert!(next.is_valid());
328    }
329
330    #[test]
331    fn slots_hide_beyond_admission_never_evict() {
332        let mut next = SurfaceGraph::new();
333        next.insert(main_s("home"));
334        for request in [
335            aside_s("chat", Edge::Right),
336            terminal_aside_s("terminal", Edge::Bottom),
337            web_aside_s("b1", "https://a.example", Edge::Right),
338        ] {
339            let (n, d) = arbitrate(&next, request, &Policy::default(), SizeClass::Expanded);
340            assert_eq!(d, Decision::Accepted);
341            next = n;
342        }
343        // Three kinds → three slots, all admitted on expanded.
344        let expanded = next.aside_slots(SizeClass::Expanded);
345        assert_eq!(expanded.len(), 3);
346        assert!(expanded.iter().all(|slot| slot.visible));
347        // Medium admits only the most recently used slot; the others stay
348        // alive hidden — the graph itself never shrinks.
349        let medium = next.aside_slots(SizeClass::Medium);
350        assert_eq!(medium.iter().filter(|slot| slot.visible).count(), 1);
351        assert!(
352            medium
353                .iter()
354                .find(|slot| slot.visible)
355                .is_some_and(|slot| slot.kind == SlotKind::Browser)
356        );
357        assert_eq!(next.asides().len(), 3);
358        assert!(next.is_valid());
359    }
360
361    #[test]
362    fn physical_admission_caps_below_the_count_ceiling() {
363        // Two right-docked lxapp/browser slots + one bottom terminal slot.
364        let mut next = SurfaceGraph::new();
365        next.insert(main_s("home"));
366        for request in [
367            aside_s("chat", Edge::Right),
368            web_aside_s("b1", "https://a.example", Edge::Right),
369            terminal_aside_s("terminal", Edge::Bottom),
370        ] {
371            let (n, _) = arbitrate(&next, request, &Policy::default(), SizeClass::Expanded);
372            next = n;
373        }
374        let policy = Policy::default(); // main_min 360, aside_min 240
375
376        // Wide expanded (1200): main 360 + 2×240 = 840 ≤ 1200 → both right
377        // slots fit; the bottom terminal never consumes horizontal budget.
378        let wide = next.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
379        assert_eq!(wide.iter().filter(|s| s.visible).count(), 3);
380
381        // Narrow expanded (850): count ceiling says 3, but 360 + 240 = 600 ≤
382        // 850 fits ONE right slot; the second right slot (600 + 240 = 840 —
383        // wait, 840 ≤ 850) — pick 700 to force exactly one.
384        let narrow = next.aside_slots_admitted(SizeClass::Expanded, 700.0, &policy);
385        let visible_right = narrow
386            .iter()
387            .filter(|s| s.visible && !matches!(s.edge, Some(Edge::Bottom)))
388            .count();
389        assert_eq!(visible_right, 1, "only one right slot fits at 700pt");
390        // The bottom terminal stays visible regardless of horizontal budget.
391        assert!(
392            narrow
393                .iter()
394                .any(|s| s.visible && matches!(s.edge, Some(Edge::Bottom)))
395        );
396        // Nothing was evicted — the graph still holds all three asides.
397        assert_eq!(next.asides().len(), 3);
398    }
399
400    #[test]
401    fn slot_admission_uses_policy_and_true_focus_recency() {
402        let mut graph = SurfaceGraph::new();
403        graph.insert(main_s("home"));
404        graph.insert(aside_s("chat", Edge::Right));
405        graph.insert(web_aside_s("browser", "https://example.com", Edge::Right));
406
407        // Browser was opened last, so it initially owns Medium's one slot.
408        let medium = graph.aside_slots(SizeClass::Medium);
409        assert_eq!(
410            medium
411                .iter()
412                .find(|slot| slot.visible)
413                .map(|slot| slot.kind),
414            Some(SlotKind::Browser)
415        );
416
417        // Focusing an older slot makes it MRU without changing tab/open order.
418        assert!(graph.set_focus("chat"));
419        let medium = graph.aside_slots(SizeClass::Medium);
420        assert_eq!(
421            medium
422                .iter()
423                .find(|slot| slot.visible)
424                .map(|slot| slot.kind),
425            Some(SlotKind::Lxapp)
426        );
427
428        let policy = Policy {
429            max_asides_expanded: 1,
430            ..Policy::default()
431        };
432        let expanded = graph.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
433        assert_eq!(expanded.iter().filter(|slot| slot.visible).count(), 1);
434        assert_eq!(
435            expanded
436                .iter()
437                .find(|slot| slot.visible)
438                .map(|slot| slot.kind),
439            Some(SlotKind::Lxapp)
440        );
441    }
442
443    #[test]
444    fn physical_admission_keeps_the_most_recent_horizontal_slot() {
445        let mut graph = SurfaceGraph::new();
446        graph.insert(main_s("home"));
447        graph.insert(aside_s("chat", Edge::Right));
448        graph.insert(web_aside_s("browser", "https://example.com", Edge::Right));
449        graph.insert(terminal_aside_s("terminal", Edge::Right));
450
451        // 700 fits main + one horizontal slot. The newest slot wins, even
452        // though returned slots stay in stable first-open order.
453        let admitted = graph.aside_slots_admitted(SizeClass::Expanded, 700.0, &Policy::default());
454        let visible: Vec<_> = admitted
455            .iter()
456            .filter(|slot| slot.visible)
457            .map(|slot| slot.kind)
458            .collect();
459        assert_eq!(visible, vec![SlotKind::Native]);
460    }
461
462    #[test]
463    fn physical_admission_falls_back_to_an_older_fitting_slot() {
464        let mut graph = SurfaceGraph::new();
465        graph.insert(main_s("home"));
466        let mut terminal = terminal_aside_s("terminal", Edge::Top);
467        terminal.placement.edge = Some(Edge::Top);
468        graph.insert(terminal);
469        graph.insert(aside_s("chat", Edge::Right));
470
471        // Medium admits one slot. The MRU right slot cannot fit beside main,
472        // so the older top overlay must be considered instead of leaving the
473        // entire aside area empty.
474        let admitted = graph.aside_slots_admitted(SizeClass::Medium, 500.0, &Policy::default());
475        let visible: Vec<_> = admitted
476            .iter()
477            .filter(|slot| slot.visible)
478            .map(|slot| slot.kind)
479            .collect();
480        assert_eq!(visible, vec![SlotKind::Native]);
481    }
482
483    #[test]
484    fn web_asides_coexist_as_tabs() {
485        let mut g = SurfaceGraph::new();
486        g.insert(main_s("home"));
487        g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
488        // A second browser aside for a DIFFERENT url coexists as another tab of
489        // the one multi-tab panel (exempt from the generic aside cap), not
490        // replacing the first.
491        let (next, decision) = arbitrate(
492            &g,
493            web_aside_s("browser-2", "https://b.example", Edge::Right),
494            &Policy::default(),
495            SizeClass::Expanded,
496        );
497        assert_eq!(decision, Decision::MergedIntoTabs);
498        assert_eq!(decision.resolved_surface_id, "browser-2");
499        assert!(next.get("browser-1").is_some());
500        assert!(next.get("browser-2").is_some());
501        assert_eq!(
502            next.asides()
503                .iter()
504                .filter(|s| matches!(s.content, SurfaceContent::Browser { .. }))
505                .count(),
506            2
507        );
508        assert!(next.is_valid());
509    }
510
511    #[test]
512    fn hiding_active_aside_selects_recent_visible_sibling() {
513        let mut graph = SurfaceGraph::new();
514        graph.insert(main_s("home"));
515        graph.insert(aside_s("first", Edge::Right));
516        graph.insert(aside_s("second", Edge::Right));
517        graph.set_focus("first");
518        graph.set_focus("second");
519
520        assert!(graph.hide("second"));
521        assert_eq!(graph.focused_surface_id.as_deref(), Some("first"));
522        assert_eq!(
523            graph.get("second").map(|surface| surface.state),
524            Some(SurfaceState::Hidden)
525        );
526        let slots = graph.aside_slots(SizeClass::Expanded);
527        assert_eq!(slots[0].active_child.as_deref(), Some("first"));
528
529        assert!(graph.show("second"));
530        assert_eq!(graph.focused_surface_id.as_deref(), Some("second"));
531        assert_eq!(
532            graph.get("second").map(|surface| surface.state),
533            Some(SurfaceState::Mounted)
534        );
535    }
536
537    #[test]
538    fn closing_active_aside_selects_recent_visible_sibling() {
539        let mut graph = SurfaceGraph::new();
540        graph.insert(main_s("home"));
541        graph.insert(web_aside_s("first", "https://one.example", Edge::Right));
542        graph.insert(web_aside_s("second", "https://two.example", Edge::Right));
543        graph.set_focus("first");
544        graph.set_focus("second");
545
546        assert_eq!(
547            graph.close("second"),
548            CloseOutcome::Closed {
549                removed: vec!["second".into()]
550            }
551        );
552        assert_eq!(graph.focused_surface_id.as_deref(), Some("first"));
553        assert_eq!(
554            graph.aside_slots(SizeClass::Expanded)[0]
555                .active_child
556                .as_deref(),
557            Some("first")
558        );
559    }
560
561    #[test]
562    fn web_aside_dedups_by_url() {
563        let mut g = SurfaceGraph::new();
564        g.insert(main_s("home"));
565        g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
566        // Reopening the same url focuses the existing tab instead of adding a
567        // duplicate — no new surface is inserted.
568        let (next, decision) = arbitrate(
569            &g,
570            web_aside_s("browser-2", "https://a.example", Edge::Right),
571            &Policy::default(),
572            SizeClass::Expanded,
573        );
574        assert_eq!(decision, Decision::MergedIntoTabs);
575        assert_eq!(decision.resolved_surface_id, "browser-1");
576        assert_eq!(decision.resolved_role, Role::Aside);
577        assert!(next.get("browser-1").is_some());
578        assert!(next.get("browser-2").is_none());
579        assert_eq!(
580            next.asides()
581                .iter()
582                .filter(|s| matches!(s.content, SurfaceContent::Browser { .. }))
583                .count(),
584            1
585        );
586        assert!(next.is_valid());
587    }
588
589    #[test]
590    fn non_reusable_web_asides_never_dedup_by_url() {
591        let mut graph = SurfaceGraph::new();
592        graph.insert(main_s("home"));
593        graph.insert(web_aside_s("ordinary", "https://a.example", Edge::Right));
594
595        let (graph, callback_outcome) = arbitrate(
596            &graph,
597            non_reusable_web_aside_s("callback", "https://a.example", Edge::Right),
598            &Policy::default(),
599            SizeClass::Expanded,
600        );
601        assert_eq!(callback_outcome.resolved_surface_id, "callback");
602        assert!(graph.get("ordinary").is_some());
603        assert!(graph.get("callback").is_some());
604
605        let (graph, ordinary_outcome) = arbitrate(
606            &graph,
607            web_aside_s("ordinary-2", "https://a.example", Edge::Right),
608            &Policy::default(),
609            SizeClass::Expanded,
610        );
611        assert_eq!(ordinary_outcome.resolved_surface_id, "ordinary");
612        assert!(graph.get("ordinary-2").is_none());
613        assert!(graph.get("callback").is_some());
614    }
615
616    #[test]
617    fn web_aside_url_key_normalizes_origin_and_empty_path_only() {
618        assert_eq!(
619            normalize_initial_url("HTTPS://Example.COM:443"),
620            "https://example.com/"
621        );
622        assert_eq!(
623            normalize_initial_url("https://example.com/?q=One#Top"),
624            "https://example.com/?q=One#Top"
625        );
626        assert_ne!(
627            normalize_initial_url("https://example.com/?q=One#Top"),
628            normalize_initial_url("https://example.com/?q=one#Top")
629        );
630
631        let mut graph = SurfaceGraph::new();
632        graph.insert(main_s("home"));
633        let (graph, _) = arbitrate(
634            &graph,
635            web_aside_s("browser-1", "HTTPS://Example.COM:443", Edge::Right),
636            &Policy::default(),
637            SizeClass::Expanded,
638        );
639        let (graph, outcome) = arbitrate(
640            &graph,
641            web_aside_s("browser-2", "https://example.com/", Edge::Right),
642            &Policy::default(),
643            SizeClass::Expanded,
644        );
645        assert_eq!(outcome.resolved_surface_id, "browser-1");
646        assert!(graph.get("browser-2").is_none());
647    }
648
649    #[test]
650    fn browser_asides_never_evict_a_declared_aside() {
651        let mut next = SurfaceGraph::new();
652        next.insert(main_s("home"));
653        next.insert(aside_s("chat", Edge::Right)); // declared lxapp aside
654        // Open browser tabs beyond the generic aside cap (expanded=2). The
655        // declared `chat` aside must survive — web + non-web budgets are
656        // independent (they coexist as separate side panels).
657        for (i, url) in [
658            "https://a.example",
659            "https://b.example",
660            "https://c.example",
661        ]
662        .iter()
663        .enumerate()
664        {
665            let (n, d) = arbitrate(
666                &next,
667                web_aside_s(&format!("b{i}"), url, Edge::Right),
668                &Policy::default(),
669                SizeClass::Expanded,
670            );
671            // The first web aside claims the browser slot; the rest join it.
672            let expected = if i == 0 {
673                Decision::Accepted
674            } else {
675                Decision::MergedIntoTabs
676            };
677            assert_eq!(d, expected);
678            next = n;
679        }
680        assert!(next.get("chat").is_some(), "declared aside must survive");
681        assert_eq!(
682            next.asides()
683                .iter()
684                .filter(|s| matches!(s.content, SurfaceContent::Browser { .. }))
685                .count(),
686            3
687        );
688        assert!(next.is_valid());
689    }
690
691    #[test]
692    fn web_aside_coexists_with_a_page_aside() {
693        let mut g = SurfaceGraph::new();
694        g.insert(main_s("home"));
695        g.insert(aside_s("assistant", Edge::Right));
696        // A browser aside does NOT evict a non-web (declared/page) aside; both
697        // coexist under the expanded cap of 2.
698        let (next, decision) = arbitrate(
699            &g,
700            web_aside_s("browser-1", "https://a.example", Edge::Left),
701            &Policy::default(),
702            SizeClass::Expanded,
703        );
704        assert_eq!(decision, Decision::Accepted);
705        assert!(next.get("assistant").is_some());
706        assert!(next.get("browser-1").is_some());
707        assert_eq!(next.asides().len(), 2);
708        assert!(next.is_valid());
709    }
710
711    #[test]
712    fn aside_fullscreen_fallback_on_compact() {
713        let mut g = SurfaceGraph::new();
714        g.insert(main_s("home"));
715        let (next, decision) = arbitrate(
716            &g,
717            aside_s("assistant", Edge::Right),
718            &Policy::default(),
719            SizeClass::Compact,
720        );
721        assert_eq!(decision, Decision::FullScreenFallback);
722        assert!(decision.overlay);
723        assert_eq!(next.role_of("assistant"), Some(Role::Aside));
724        assert_eq!(next.active_main_id.as_deref(), Some("home"));
725        assert_eq!(
726            next.presentation_plan(
727                SizeClass::Compact,
728                390.0,
729                &crate::arbitrate::Policy::default()
730            )
731            .active_main_id
732            .as_deref(),
733            Some("home")
734        );
735        assert!(next.is_valid());
736    }
737
738    #[test]
739    fn aside_without_primary_promotes_to_main() {
740        // No main yet, expanded (room for asides) — an aside has nothing to
741        // dock to, so it must become the main, keeping the graph valid.
742        let g = SurfaceGraph::new();
743        let (next, decision) = arbitrate(
744            &g,
745            aside_s("assistant", Edge::Right),
746            &Policy::default(),
747            SizeClass::Expanded,
748        );
749        assert_eq!(decision, Decision::DowngradedRole);
750        assert_eq!(next.role_of("assistant"), Some(Role::Main));
751        assert!(next.is_valid());
752    }
753
754    #[test]
755    fn arbitrate_is_pure_and_keeps_graph_valid() {
756        let mut g = SurfaceGraph::new();
757        g.insert(main_s("home"));
758        let before = g.surfaces().len();
759        let (next, _) = arbitrate(
760            &g,
761            aside_s("x", Edge::Left),
762            &Policy::default(),
763            SizeClass::Expanded,
764        );
765        // original graph untouched (pure); result is valid.
766        assert_eq!(g.surfaces().len(), before);
767        assert!(next.is_valid());
768    }
769
770    // ---- serde round-trip (shared core <-> JSON for ui.json / FFI) ----
771
772    #[test]
773    fn surface_json_round_trip() {
774        let s = aside_s("assistant", Edge::Right);
775        let json = serde_json::to_string(&s).unwrap();
776        let back: Surface = serde_json::from_str(&json).unwrap();
777        assert_eq!(s, back);
778    }
779
780    #[test]
781    fn browser_surface_json_preserves_reuse_policy_default() {
782        let ordinary = web_aside_s("ordinary", "https://a.example", Edge::Right);
783        let ordinary_json = serde_json::to_string(&ordinary).unwrap();
784        assert!(!ordinary_json.contains("reuseByUrl"));
785        assert_eq!(
786            serde_json::from_str::<Surface>(&ordinary_json).unwrap(),
787            ordinary
788        );
789
790        let callback = non_reusable_web_aside_s("callback", "https://a.example", Edge::Right);
791        let callback_json = serde_json::to_string(&callback).unwrap();
792        assert!(callback_json.contains("\"reuseByUrl\":false"));
793        assert_eq!(
794            serde_json::from_str::<Surface>(&callback_json).unwrap(),
795            callback
796        );
797    }
798}