Skip to main content

lingxia_surface/
lib.rs

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