1mod 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 #[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 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 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 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 #[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 assert_eq!(
207 g.derive_layout(SizeClass::Compact).bottom_owner,
208 BottomOwner::App
209 );
210 g.insert(main_s("b"));
211 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 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 #[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 let held = SizeClass::resolve(Some(SizeClass::Medium), 590.0, DEFAULT_HYSTERESIS);
249 assert_eq!(held, SizeClass::Medium);
250 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 #[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 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 let expanded = next.aside_slots(SizeClass::Expanded);
345 assert_eq!(expanded.len(), 3);
346 assert!(expanded.iter().all(|slot| slot.visible));
347 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 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(); let wide = next.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
379 assert_eq!(wide.iter().filter(|s| s.visible).count(), 3);
380
381 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 assert!(
392 narrow
393 .iter()
394 .any(|s| s.visible && matches!(s.edge, Some(Edge::Bottom)))
395 );
396 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 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 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 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 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 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 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)); 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 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 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 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 assert_eq!(g.surfaces().len(), before);
767 assert!(next.is_valid());
768 }
769
770 #[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}