1mod arbitrate;
9mod graph;
10mod layout;
11mod manager;
12mod model;
13
14pub use arbitrate::{Decision, Policy, arbitrate};
15pub use graph::SurfaceGraph;
16pub use layout::{
17 Axis, BottomOwner, DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, LayoutTree,
18 PlanAside, PlanFloat, SizeClass, SplitForm, SwitcherForm,
19};
20pub use manager::SurfaceManager;
21pub use model::{
22 Edge, FloatAnchor, FloatDismiss, FloatSpec, Placement, Role, Surface, SurfaceContent,
23 SurfaceId, SurfaceOwner, SurfaceState,
24};
25
26#[cfg(test)]
27mod tests {
28 use super::*;
29
30 fn main_s(id: &str) -> Surface {
31 Surface::entry(id, Role::Main, id)
32 }
33 fn aside_s(id: &str, edge: Edge) -> Surface {
34 let mut s = Surface::entry(id, Role::Aside, id);
35 s.placement.edge = Some(edge);
36 s
37 }
38 fn web_aside_s(id: &str, url: &str, edge: Edge) -> Surface {
39 let mut s = aside_s(id, edge);
40 s.content = SurfaceContent::Web {
41 url: url.to_string(),
42 };
43 s
44 }
45
46 #[test]
49 fn empty_graph_is_valid() {
50 let g = SurfaceGraph::new();
51 assert!(g.is_valid());
52 assert_eq!(g.active_main_id, None);
53 assert_eq!(g.focused_surface_id, None);
54 }
55
56 #[test]
57 fn first_main_becomes_active_and_focused() {
58 let mut g = SurfaceGraph::new();
59 g.insert(main_s("home"));
60 assert_eq!(g.active_main_id.as_deref(), Some("home"));
61 assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
62 assert!(g.is_valid());
63 }
64
65 #[test]
66 fn aside_requires_a_main_invariant() {
67 let mut g = SurfaceGraph::new();
69 g.insert(main_s("home"));
70 g.insert(aside_s("assistant", Edge::Right));
71 assert!(g.is_valid());
72 let removed = g.remove("home");
74 assert!(removed.contains(&"assistant".to_string()));
75 assert!(g.asides().is_empty());
76 assert_eq!(g.active_main_id, None);
77 assert!(g.is_valid());
78 }
79
80 #[test]
81 fn closing_active_main_picks_adjacent_successor() {
82 let mut g = SurfaceGraph::new();
83 g.insert(main_s("a"));
84 g.insert(main_s("b"));
85 g.insert(main_s("c"));
86 g.set_active_main("b");
87 g.remove("b");
88 assert_eq!(g.active_main_id.as_deref(), Some("c"));
90 assert!(g.is_valid());
91 }
92
93 #[test]
94 fn modal_float_restores_focus_on_close() {
95 let mut g = SurfaceGraph::new();
96 g.insert(main_s("home"));
97 assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
98 let mut modal = Surface::entry("dialog", Role::Float, "confirm");
99 modal.float = Some(FloatSpec {
100 modal: true,
101 ..Default::default()
102 });
103 g.insert(modal);
104 g.set_focus("dialog");
105 g.remove("dialog");
106 assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
107 assert!(g.is_valid());
108 }
109
110 #[test]
113 fn single_main_no_switcher_no_split() {
114 let mut g = SurfaceGraph::new();
115 g.insert(main_s("home"));
116 let d = g.derive_layout(SizeClass::Expanded);
117 assert_eq!(d.switcher_form, SwitcherForm::None);
118 assert_eq!(d.split_form, SplitForm::None);
119 assert!(matches!(d.layout_tree, Some(LayoutTree::Leaf { .. })));
120 }
121
122 #[test]
123 fn switcher_only_with_multiple_mains() {
124 let mut g = SurfaceGraph::new();
125 g.insert(main_s("a"));
126 assert_eq!(
127 g.derive_layout(SizeClass::Expanded).switcher_form,
128 SwitcherForm::None
129 );
130 g.insert(main_s("b"));
131 assert_eq!(
132 g.derive_layout(SizeClass::Expanded).switcher_form,
133 SwitcherForm::Sidebar
134 );
135 }
136
137 #[test]
138 fn aside_splits_on_expanded_fullscreen_on_compact() {
139 let mut g = SurfaceGraph::new();
140 g.insert(main_s("home"));
141 g.insert(aside_s("assistant", Edge::Right));
142 assert_eq!(
143 g.derive_layout(SizeClass::Expanded).split_form,
144 SplitForm::Split
145 );
146 assert_eq!(
147 g.derive_layout(SizeClass::Compact).split_form,
148 SplitForm::FullScreen
149 );
150 }
151
152 #[test]
153 fn compact_plan_keeps_existing_aside_desired() {
154 let mut g = SurfaceGraph::new();
155 g.insert(main_s("home"));
156 g.insert(aside_s("assistant", Edge::Right));
157
158 let plan = g.presentation_plan(SizeClass::Compact);
159 assert_eq!(plan.split_form, SplitForm::FullScreen);
160 assert!(plan.asides.iter().any(|aside| aside.id == "assistant"));
161 assert!(
162 plan.tree
163 .as_ref()
164 .is_some_and(|tree| tree.surface_ids().iter().any(|id| id == "assistant"))
165 );
166 }
167
168 #[test]
169 fn compact_bottom_owner_stays_app() {
170 let mut g = SurfaceGraph::new();
171 g.insert(main_s("a"));
172 assert_eq!(
174 g.derive_layout(SizeClass::Compact).bottom_owner,
175 BottomOwner::App
176 );
177 g.insert(main_s("b"));
178 assert_eq!(
180 g.derive_layout(SizeClass::Compact).bottom_owner,
181 BottomOwner::App
182 );
183 }
184
185 #[test]
186 fn canonical_layout_validates() {
187 let mut g = SurfaceGraph::new();
188 g.insert(main_s("a"));
189 g.insert(main_s("b"));
190 g.insert(aside_s("assistant", Edge::Right));
191 let tree = g.canonical_layout(SizeClass::Expanded).unwrap();
192 tree.validate().expect("canonical tree must be valid");
193 g.insert(Surface::entry("toast", Role::Float, "toast"));
195 let ids = g
196 .canonical_layout(SizeClass::Expanded)
197 .unwrap()
198 .surface_ids();
199 assert!(!ids.contains(&"toast".to_string()));
200 }
201
202 #[test]
205 fn breakpoints_align_to_material() {
206 assert_eq!(SizeClass::from_width(599.0), SizeClass::Compact);
207 assert_eq!(SizeClass::from_width(600.0), SizeClass::Medium);
208 assert_eq!(SizeClass::from_width(840.0), SizeClass::Medium);
209 assert_eq!(SizeClass::from_width(841.0), SizeClass::Expanded);
210 }
211
212 #[test]
213 fn hysteresis_holds_class_near_boundary() {
214 let held = SizeClass::resolve(Some(SizeClass::Medium), 590.0, DEFAULT_HYSTERESIS);
216 assert_eq!(held, SizeClass::Medium);
217 let switched = SizeClass::resolve(Some(SizeClass::Medium), 500.0, DEFAULT_HYSTERESIS);
219 assert_eq!(switched, SizeClass::Compact);
220 }
221
222 #[test]
225 fn aside_over_limit_replaces_oldest() {
226 let mut g = SurfaceGraph::new();
227 g.insert(main_s("home"));
228 g.insert(aside_s("a1", Edge::Right));
229 g.insert(aside_s("a2", Edge::Right));
230 let (next, decision) = arbitrate(
232 &g,
233 aside_s("a3", Edge::Right),
234 &Policy::default(),
235 SizeClass::Expanded,
236 );
237 assert_eq!(decision, Decision::ReplacedExisting);
238 assert!(next.get("a1").is_none());
239 assert!(next.get("a3").is_some());
240 assert_eq!(next.asides().len(), 2);
241 assert!(next.is_valid());
242 }
243
244 #[test]
245 fn web_aside_is_singleton_replacing_existing() {
246 let mut g = SurfaceGraph::new();
247 g.insert(main_s("home"));
248 g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
249 let (next, decision) = arbitrate(
252 &g,
253 web_aside_s("browser-2", "https://b.example", Edge::Left),
254 &Policy::default(),
255 SizeClass::Expanded,
256 );
257 assert_eq!(decision, Decision::ReplacedExisting);
258 assert!(next.get("browser-1").is_none());
259 assert!(next.get("browser-2").is_some());
260 assert_eq!(
262 next.asides()
263 .iter()
264 .filter(|s| matches!(s.content, SurfaceContent::Web { .. }))
265 .count(),
266 1
267 );
268 assert!(next.is_valid());
269 }
270
271 #[test]
272 fn web_aside_coexists_with_a_page_aside() {
273 let mut g = SurfaceGraph::new();
274 g.insert(main_s("home"));
275 g.insert(aside_s("assistant", Edge::Right));
276 let (next, decision) = arbitrate(
279 &g,
280 web_aside_s("browser-1", "https://a.example", Edge::Left),
281 &Policy::default(),
282 SizeClass::Expanded,
283 );
284 assert_eq!(decision, Decision::Accepted);
285 assert!(next.get("assistant").is_some());
286 assert!(next.get("browser-1").is_some());
287 assert_eq!(next.asides().len(), 2);
288 assert!(next.is_valid());
289 }
290
291 #[test]
292 fn aside_fullscreen_fallback_on_compact() {
293 let mut g = SurfaceGraph::new();
294 g.insert(main_s("home"));
295 let (next, decision) = arbitrate(
296 &g,
297 aside_s("assistant", Edge::Right),
298 &Policy::default(),
299 SizeClass::Compact,
300 );
301 assert_eq!(decision, Decision::FullScreenFallback);
302 assert_eq!(next.role_of("assistant"), Some(Role::Main));
304 assert_eq!(next.active_main_id.as_deref(), Some("assistant"));
305 assert_eq!(
306 next.presentation_plan(SizeClass::Compact)
307 .active_main_id
308 .as_deref(),
309 Some("assistant")
310 );
311 assert!(next.is_valid());
312 }
313
314 #[test]
315 fn aside_without_primary_promotes_to_main() {
316 let g = SurfaceGraph::new();
319 let (next, decision) = arbitrate(
320 &g,
321 aside_s("assistant", Edge::Right),
322 &Policy::default(),
323 SizeClass::Expanded,
324 );
325 assert_eq!(decision, Decision::DowngradedRole);
326 assert_eq!(next.role_of("assistant"), Some(Role::Main));
327 assert!(next.is_valid());
328 }
329
330 #[test]
331 fn arbitrate_is_pure_and_keeps_graph_valid() {
332 let mut g = SurfaceGraph::new();
333 g.insert(main_s("home"));
334 let before = g.surfaces().len();
335 let (next, _) = arbitrate(
336 &g,
337 aside_s("x", Edge::Left),
338 &Policy::default(),
339 SizeClass::Expanded,
340 );
341 assert_eq!(g.surfaces().len(), before);
343 assert!(next.is_valid());
344 }
345
346 #[test]
349 fn surface_json_round_trip() {
350 let s = aside_s("assistant", Edge::Right);
351 let json = serde_json::to_string(&s).unwrap();
352 let back: Surface = serde_json::from_str(&json).unwrap();
353 assert_eq!(s, back);
354 }
355}