Skip to main content

bern_runtime/
lib.rs

1//! UI session runtime shared by every target.
2//!
3//! A UI session owns window instances and the typed session drafts each
4//! window carries. It validates every write against the declared schema and
5//! gives each window instance a generation, so a reference to a closed
6//! window is rejected instead of writing into a newer instance. It also runs
7//! the close protocol: a close request either closes the window or yields a
8//! confirmation bound to the window's generation and draft revision, so a
9//! decision made before later edits cannot discard them. It contains no UI
10//! SDK code: targets report window facts and forward control input.
11
12pub mod query;
13mod window;
14
15pub use window::{
16    FactSource, Frame, Lifecycle, Presentation, SizeState, Visibility, WindowAction, WindowFacts,
17    WindowInfo,
18};
19
20use bern_protocol::{CommandError, SessionCommand, Type, TypeTable, Value};
21use std::collections::BTreeMap;
22use std::sync::{Arc, Mutex};
23
24/// One declared session state type.
25#[derive(Clone, Debug)]
26pub struct SessionType {
27    pub name: String,
28    /// Field name, type and initial value.
29    pub fields: Vec<(String, Type, Value)>,
30}
31
32/// One declared window kind.
33#[derive(Clone, Debug)]
34pub struct WindowKind {
35    pub id: String,
36    /// Indices into [`SessionSpec::sessions`] instantiated per instance.
37    pub sessions: Vec<usize>,
38    pub presentation: Presentation,
39}
40
41/// Static description of the session layer, generated by the build.
42#[derive(Clone, Debug)]
43pub struct SessionSpec {
44    pub types: TypeTable,
45    pub sessions: Vec<SessionType>,
46    pub windows: Vec<WindowKind>,
47}
48
49#[derive(Clone, Debug, PartialEq)]
50pub struct WindowInstance {
51    pub kind: usize,
52    pub generation: u64,
53    /// External identity, `<kind id>#<generation>`.
54    pub id: String,
55    /// Draft values per session of the window, in [`WindowKind::sessions`] order.
56    pub drafts: Vec<Vec<Value>>,
57}
58
59/// A typed decision on a pending close confirmation.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum CloseDecision {
62    /// Run the window's declared save command, then close if it succeeds.
63    Save,
64    /// Close without saving; the window's drafts are released.
65    Discard,
66    /// Keep the window open.
67    Cancel,
68}
69
70impl CloseDecision {
71    pub const ALL: [CloseDecision; 3] = [
72        CloseDecision::Save,
73        CloseDecision::Discard,
74        CloseDecision::Cancel,
75    ];
76
77    pub fn index(self) -> u32 {
78        Self::ALL
79            .iter()
80            .position(|decision| *decision == self)
81            .unwrap() as u32
82    }
83
84    pub fn from_index(index: u32) -> Option<Self> {
85        Self::ALL.get(index as usize).copied()
86    }
87}
88
89/// A close request that needs a decision. It names the window generation
90/// and the draft revision it was made for.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct Confirmation {
93    pub id: String,
94    pub window: String,
95    pub generation: u64,
96    pub draft_revision: u64,
97    pub can_save: bool,
98}
99
100impl Confirmation {
101    /// Decisions this confirmation accepts.
102    pub fn decisions(&self) -> Vec<CloseDecision> {
103        CloseDecision::ALL
104            .into_iter()
105            .filter(|decision| self.can_save || *decision != CloseDecision::Save)
106            .collect()
107    }
108}
109
110/// What a close request leads to.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub enum CloseStep {
113    /// No declared check objects: the target closes the window now.
114    Close,
115    /// A declared check needs a decision. Repeated requests while it is
116    /// pending return the same confirmation instead of asking again.
117    Confirm(Confirmation),
118}
119
120/// What the target does after an accepted decision.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum DecisionStep {
123    /// Close the window.
124    Close,
125    /// Run the declared save command; close only if it succeeds.
126    Save,
127    /// Keep the window open; the close request is rejected.
128    Cancel,
129}
130
131#[derive(Debug)]
132struct WindowState {
133    generation: u64,
134    open: bool,
135    drafts: Vec<Vec<Value>>,
136    /// Incremented by every accepted draft write of this instance.
137    revision: u64,
138    pending: Option<Confirmation>,
139    /// Latest presentation facts the target reported for this instance.
140    facts: Option<WindowFacts>,
141    fact_version: u64,
142    fact_source: Option<FactSource>,
143}
144
145/// Window instances and drafts of one UI session.
146#[derive(Debug)]
147pub struct UiSession {
148    session_id: String,
149    spec: Arc<SessionSpec>,
150    windows: Vec<WindowState>,
151    revision: u64,
152    confirmations: u64,
153}
154
155fn stale() -> CommandError {
156    CommandError::new(
157        "stale_window",
158        "the window instance was closed; use the current instance",
159    )
160}
161
162fn stale_confirmation(message: &str) -> CommandError {
163    CommandError::new("stale_confirmation", message)
164}
165
166impl UiSession {
167    pub fn new(session_id: impl Into<String>, spec: Arc<SessionSpec>) -> Self {
168        let windows = spec
169            .windows
170            .iter()
171            .map(|_| WindowState {
172                generation: 0,
173                open: false,
174                drafts: Vec::new(),
175                revision: 0,
176                pending: None,
177                facts: None,
178                fact_version: 0,
179                fact_source: None,
180            })
181            .collect();
182        Self {
183            session_id: session_id.into(),
184            spec,
185            windows,
186            revision: 0,
187            confirmations: 0,
188        }
189    }
190
191    pub fn session_id(&self) -> &str {
192        &self.session_id
193    }
194
195    pub fn spec(&self) -> &SessionSpec {
196        &self.spec
197    }
198
199    /// Revision of the session state, bumped by every accepted write.
200    pub fn revision(&self) -> u64 {
201        self.revision
202    }
203
204    fn initial_drafts(&self, kind: usize) -> Vec<Vec<Value>> {
205        self.spec.windows[kind]
206            .sessions
207            .iter()
208            .map(|session| {
209                self.spec.sessions[*session]
210                    .fields
211                    .iter()
212                    .map(|(_, _, initial)| initial.clone())
213                    .collect()
214            })
215            .collect()
216    }
217
218    fn instance(&self, kind: usize) -> WindowInstance {
219        let state = &self.windows[kind];
220        WindowInstance {
221            kind,
222            generation: state.generation,
223            id: format!("{}#{}", self.spec.windows[kind].id, state.generation),
224            drafts: state.drafts.clone(),
225        }
226    }
227
228    /// The adapter observed that a window of `kind` is shown. Reporting an
229    /// already open window returns the same instance; a reopened window
230    /// starts a new generation with fresh drafts.
231    pub fn window_opened(&mut self, kind: usize) -> Result<WindowInstance, CommandError> {
232        if kind >= self.windows.len() {
233            return Err(CommandError::new("target_not_found", "unknown window kind"));
234        }
235        if !self.windows[kind].open {
236            let drafts = self.initial_drafts(kind);
237            let state = &mut self.windows[kind];
238            state.generation += 1;
239            state.open = true;
240            state.drafts = drafts;
241            state.revision = 0;
242            state.pending = None;
243            state.facts = None;
244            state.fact_version = 0;
245            state.fact_source = None;
246            self.revision += 1;
247        }
248        Ok(self.instance(kind))
249    }
250
251    /// The target reports what the platform shows for a window instance.
252    /// Facts for a closed or replaced instance are rejected as stale;
253    /// unchanged facts keep the version. Returns the fact version.
254    pub fn window_facts(
255        &mut self,
256        kind: usize,
257        generation: u64,
258        facts: WindowFacts,
259        source: FactSource,
260    ) -> Result<u64, CommandError> {
261        let state = self.open_state(kind, generation)?;
262        if state.facts.as_ref() != Some(&facts) {
263            state.facts = Some(facts);
264            state.fact_version += 1;
265            state.fact_source = Some(source);
266        }
267        Ok(state.fact_version)
268    }
269
270    /// Presentation of an open window instance.
271    pub fn window_info(&self, kind: usize) -> Option<WindowInfo> {
272        let state = self.windows.get(kind).filter(|state| state.open)?;
273        let lifecycle = if state.pending.is_some() {
274            Lifecycle::Closing
275        } else if state.facts.is_none() {
276            Lifecycle::Opening
277        } else {
278            Lifecycle::Open
279        };
280        Some(WindowInfo {
281            window: self.instance(kind).id,
282            kind: self.spec.windows[kind].id.clone(),
283            presentation: self.spec.windows[kind].presentation,
284            lifecycle,
285            facts: state.facts.clone(),
286            version: state.fact_version,
287            changed_by: state.fact_source,
288        })
289    }
290
291    /// Presentation of every open window instance.
292    pub fn window_infos(&self) -> Vec<WindowInfo> {
293        (0..self.windows.len())
294            .filter_map(|kind| self.window_info(kind))
295            .collect()
296    }
297
298    /// Check that `action` is available on the open instance `window`
299    /// according to the latest facts, before the target performs it.
300    pub fn check_action(
301        &self,
302        window: &str,
303        action: WindowAction,
304    ) -> Result<WindowInstance, CommandError> {
305        let instance = self.find_window(window).ok_or_else(|| {
306            CommandError::new("target_not_found", format!("no open window `{window}`"))
307        })?;
308        let Some(facts) = &self.windows[instance.kind].facts else {
309            return Err(CommandError::new(
310                "target_unavailable",
311                format!("`{window}` is still opening"),
312            ));
313        };
314        if !facts.actions.contains(&action) {
315            let available: Vec<_> = facts.actions.iter().map(|action| action.name()).collect();
316            return Err(CommandError::new(
317                "target_unavailable",
318                format!(
319                    "`{window}` cannot {} now; available: {}",
320                    action.name(),
321                    if available.is_empty() {
322                        "none".to_string()
323                    } else {
324                        available.join(", ")
325                    }
326                ),
327            ));
328        }
329        Ok(instance)
330    }
331
332    /// The adapter observed that a window instance closed. Window-scoped
333    /// drafts are released; a stale generation is ignored.
334    pub fn window_closed(&mut self, kind: usize, generation: u64) -> bool {
335        match self.windows.get_mut(kind) {
336            Some(state) if state.open && state.generation == generation => {
337                state.open = false;
338                state.drafts.clear();
339                state.pending = None;
340                self.revision += 1;
341                true
342            }
343            _ => false,
344        }
345    }
346
347    pub fn open_windows(&self) -> Vec<WindowInstance> {
348        (0..self.windows.len())
349            .filter(|kind| self.windows[*kind].open)
350            .map(|kind| self.instance(kind))
351            .collect()
352    }
353
354    /// Find the open instance with an external id such as `settings#2`.
355    pub fn find_window(&self, id: &str) -> Option<WindowInstance> {
356        self.open_windows()
357            .into_iter()
358            .find(|window| window.id == id)
359    }
360
361    pub fn window_kind(&self, id: &str) -> Option<usize> {
362        self.spec.windows.iter().position(|kind| kind.id == id)
363    }
364
365    fn slot(&self, kind: usize, generation: u64, session: usize) -> Result<usize, CommandError> {
366        let state = self
367            .windows
368            .get(kind)
369            .ok_or_else(|| CommandError::new("target_not_found", "unknown window kind"))?;
370        if !state.open || state.generation != generation {
371            return Err(stale());
372        }
373        self.spec.windows[kind]
374            .sessions
375            .iter()
376            .position(|candidate| *candidate == session)
377            .ok_or_else(|| {
378                CommandError::new(
379                    "target_not_found",
380                    "this window does not own that session state",
381                )
382            })
383    }
384
385    /// Write one session field after checking it against the schema.
386    pub fn set(
387        &mut self,
388        kind: usize,
389        generation: u64,
390        session: usize,
391        field: usize,
392        value: Value,
393    ) -> Result<u64, CommandError> {
394        let slot = self.slot(kind, generation, session)?;
395        let session_type = &self.spec.sessions[session];
396        let (name, ty, _) = session_type
397            .fields
398            .get(field)
399            .ok_or_else(|| CommandError::new("target_not_found", "unknown session field"))?;
400        value.check(ty, &self.spec.types).map_err(|error| {
401            CommandError::invalid_params(format!("{}.{name}: {error}", session_type.name))
402        })?;
403        let state = &mut self.windows[kind];
404        let current = &mut state.drafts[slot][field];
405        if *current != value {
406            *current = value;
407            // A pending confirmation keeps its revision and becomes stale.
408            state.revision += 1;
409            self.revision += 1;
410        }
411        Ok(self.revision)
412    }
413
414    fn open_state(
415        &mut self,
416        kind: usize,
417        generation: u64,
418    ) -> Result<&mut WindowState, CommandError> {
419        let state = self
420            .windows
421            .get_mut(kind)
422            .ok_or_else(|| CommandError::new("target_not_found", "unknown window kind"))?;
423        if !state.open || state.generation != generation {
424            return Err(stale());
425        }
426        Ok(state)
427    }
428
429    /// Draft revision of an open window instance.
430    pub fn draft_revision(&self, kind: usize) -> Option<u64> {
431        self.windows
432            .get(kind)
433            .filter(|state| state.open)
434            .map(|state| state.revision)
435    }
436
437    /// The pending close confirmation of a window, which blocks its
438    /// controls until it is decided.
439    pub fn pending_close(&self, kind: usize) -> Option<&Confirmation> {
440        self.windows
441            .get(kind)
442            .filter(|state| state.open)
443            .and_then(|state| state.pending.as_ref())
444    }
445
446    /// Request closing a window instance. The target evaluated the
447    /// window's declared close check into `needs_confirmation`; `can_save`
448    /// tells whether the window declares a save command.
449    pub fn request_close(
450        &mut self,
451        kind: usize,
452        generation: u64,
453        needs_confirmation: bool,
454        can_save: bool,
455    ) -> Result<CloseStep, CommandError> {
456        let window = format!(
457            "{}#{generation}",
458            self.spec
459                .windows
460                .get(kind)
461                .map(|kind| kind.id.as_str())
462                .unwrap_or_default()
463        );
464        let state = self.open_state(kind, generation)?;
465        if let Some(pending) = &state.pending {
466            if pending.draft_revision == state.revision {
467                return Ok(CloseStep::Confirm(pending.clone()));
468            }
469            // Drafts changed since the confirmation; it no longer applies.
470            state.pending = None;
471        }
472        if !needs_confirmation {
473            return Ok(CloseStep::Close);
474        }
475        let revision = state.revision;
476        self.confirmations += 1;
477        let confirmation = Confirmation {
478            id: format!("{window}:c{}", self.confirmations),
479            window,
480            generation,
481            draft_revision: revision,
482            can_save,
483        };
484        self.windows[kind].pending = Some(confirmation.clone());
485        Ok(CloseStep::Confirm(confirmation))
486    }
487
488    /// Decide a pending confirmation. A decision for another confirmation,
489    /// a closed or reopened window, or drafts edited after the confirmation
490    /// is rejected as stale.
491    pub fn confirm_close(
492        &mut self,
493        kind: usize,
494        generation: u64,
495        confirmation: &str,
496        decision: CloseDecision,
497    ) -> Result<DecisionStep, CommandError> {
498        let state = self.open_state(kind, generation)?;
499        let Some(pending) = state.pending.clone() else {
500            return Err(stale_confirmation(
501                "no close confirmation is pending for this window; request closing again",
502            ));
503        };
504        if pending.id != confirmation {
505            return Err(stale_confirmation(
506                "this is not the window's current close confirmation",
507            ));
508        }
509        if pending.draft_revision != state.revision {
510            state.pending = None;
511            return Err(stale_confirmation(
512                "the window's drafts changed after this confirmation; request closing again",
513            ));
514        }
515        if decision == CloseDecision::Save && !pending.can_save {
516            return Err(CommandError::invalid_params(
517                "this window declares no save command; decide Discard or Cancel",
518            ));
519        }
520        state.pending = None;
521        Ok(match decision {
522            CloseDecision::Save => DecisionStep::Save,
523            CloseDecision::Discard => DecisionStep::Close,
524            CloseDecision::Cancel => DecisionStep::Cancel,
525        })
526    }
527
528    pub fn get(
529        &self,
530        kind: usize,
531        generation: u64,
532        session: usize,
533        field: usize,
534    ) -> Result<Value, CommandError> {
535        let slot = self.slot(kind, generation, session)?;
536        self.windows[kind].drafts[slot]
537            .get(field)
538            .cloned()
539            .ok_or_else(|| CommandError::new("target_not_found", "unknown session field"))
540    }
541}
542
543/// Executes framework session commands for one live UI session. Targets
544/// implement it so external entries reach the real control bindings.
545pub trait SessionHandler: Send + Sync {
546    fn session_id(&self) -> String;
547    /// Open window instance ids, for discovery.
548    fn windows(&self) -> Vec<String>;
549    /// Run a session command and wait for its result.
550    fn handle(&self, command: SessionCommand, params: Vec<Value>) -> Result<Value, CommandError>;
551}
552
553/// Live UI sessions that external entries may address by `session_id`.
554#[derive(Clone, Default)]
555pub struct Sessions {
556    inner: Arc<Mutex<BTreeMap<String, Arc<dyn SessionHandler>>>>,
557}
558
559impl Sessions {
560    pub fn new() -> Self {
561        Self::default()
562    }
563
564    pub fn register(&self, handler: Arc<dyn SessionHandler>) {
565        self.inner
566            .lock()
567            .expect("sessions lock")
568            .insert(handler.session_id(), handler);
569    }
570
571    pub fn unregister(&self, session_id: &str) {
572        self.inner.lock().expect("sessions lock").remove(session_id);
573    }
574
575    pub fn list(&self) -> Vec<Arc<dyn SessionHandler>> {
576        self.inner
577            .lock()
578            .expect("sessions lock")
579            .values()
580            .cloned()
581            .collect()
582    }
583
584    /// Route a session command. Without a session id the command goes to
585    /// the only live session; none or several sessions are explicit errors.
586    pub fn handle(
587        &self,
588        session_id: Option<&str>,
589        command: SessionCommand,
590        params: Vec<Value>,
591    ) -> Result<Value, CommandError> {
592        let handler = {
593            let sessions = self.inner.lock().expect("sessions lock");
594            match session_id {
595                Some(id) => sessions.get(id).cloned().ok_or_else(|| {
596                    CommandError::new("session_not_found", format!("no live UI session `{id}`"))
597                })?,
598                None => match sessions.len() {
599                    0 => {
600                        return Err(CommandError::new(
601                            "requires_ui_session",
602                            format!("`{}` needs a live UI session", command.id()),
603                        ));
604                    }
605                    1 => sessions.values().next().unwrap().clone(),
606                    _ => {
607                        return Err(CommandError::new(
608                            "session_required",
609                            "several UI sessions are live; name one with session_id",
610                        ));
611                    }
612                },
613            }
614        };
615        handler.handle(command, params)
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    fn spec() -> Arc<SessionSpec> {
624        Arc::new(SessionSpec {
625            types: TypeTable::default(),
626            sessions: vec![SessionType {
627                name: "Draft".into(),
628                fields: vec![
629                    ("name".into(), Type::Text, Value::Text(String::new())),
630                    ("volume".into(), Type::Float, Value::Float(50.0)),
631                ],
632            }],
633            windows: vec![
634                WindowKind {
635                    id: "main".into(),
636                    sessions: vec![0],
637                    presentation: Presentation::Native,
638                },
639                WindowKind {
640                    id: "settings".into(),
641                    sessions: vec![],
642                    presentation: Presentation::Native,
643                },
644            ],
645        })
646    }
647
648    #[test]
649    fn drafts_are_window_scoped_and_reset_on_reopen() {
650        let mut session = UiSession::new("s", spec());
651        let first = session.window_opened(0).unwrap();
652        assert_eq!(first.id, "main#1");
653        assert_eq!(first.drafts[0][1], Value::Float(50.0));
654        session.set(0, 1, 0, 0, Value::Text("Ada".into())).unwrap();
655        // Reporting the same open window keeps the instance and its drafts.
656        assert_eq!(
657            session.window_opened(0).unwrap().drafts[0][0],
658            Value::Text("Ada".into())
659        );
660        assert!(session.window_closed(0, 1));
661        let second = session.window_opened(0).unwrap();
662        assert_eq!(second.id, "main#2");
663        assert_eq!(second.drafts[0][0], Value::Text(String::new()));
664    }
665
666    #[test]
667    fn writes_are_schema_checked_and_stale_windows_rejected() {
668        let mut session = UiSession::new("s", spec());
669        session.window_opened(0).unwrap();
670        let error = session
671            .set(0, 1, 0, 1, Value::Text("loud".into()))
672            .unwrap_err();
673        assert_eq!(error.code, "invalid_params");
674        session.window_closed(0, 1);
675        session.window_opened(0).unwrap();
676        assert_eq!(
677            session
678                .set(0, 1, 0, 0, Value::Text("x".into()))
679                .unwrap_err()
680                .code,
681            "stale_window"
682        );
683        assert!(!session.window_closed(0, 1));
684        assert_eq!(
685            session
686                .set(1, 0, 0, 0, Value::Text("x".into()))
687                .unwrap_err()
688                .code,
689            "stale_window"
690        );
691    }
692
693    fn facts(visibility: Visibility, actions: &[WindowAction]) -> WindowFacts {
694        WindowFacts {
695            visibility,
696            size: SizeState::Normal,
697            frame: Frame {
698                x: 10.0,
699                y: 40.0,
700                width: 920.0,
701                height: 640.0,
702            },
703            focused: true,
704            actions: actions.to_vec(),
705        }
706    }
707
708    #[test]
709    fn window_facts_are_versioned_per_instance_and_reject_stale_generations() {
710        let mut session = UiSession::new("s", spec());
711        session.window_opened(0).unwrap();
712        let info = session.window_info(0).unwrap();
713        assert_eq!(info.lifecycle, Lifecycle::Opening);
714        assert_eq!(info.version, 0);
715        let visible = facts(Visibility::Visible, &[WindowAction::Minimize]);
716        assert_eq!(
717            session.window_facts(0, 1, visible.clone(), FactSource::System),
718            Ok(1)
719        );
720        // Unchanged facts keep the version and the source.
721        assert_eq!(
722            session.window_facts(0, 1, visible, FactSource::Command),
723            Ok(1)
724        );
725        let minimized = facts(Visibility::Minimized, &[WindowAction::Restore]);
726        assert_eq!(
727            session.window_facts(0, 1, minimized.clone(), FactSource::Command),
728            Ok(2)
729        );
730        let info = session.window_info(0).unwrap();
731        assert_eq!(info.lifecycle, Lifecycle::Open);
732        assert_eq!(info.changed_by, Some(FactSource::Command));
733        assert_eq!(
734            info.facts.as_ref().unwrap().visibility,
735            Visibility::Minimized
736        );
737        // A late fact of a closed instance cannot touch the next one.
738        session.window_closed(0, 1);
739        session.window_opened(0).unwrap();
740        assert_eq!(
741            session
742                .window_facts(0, 1, minimized, FactSource::System)
743                .unwrap_err()
744                .code,
745            "stale_window"
746        );
747        let info = session.window_info(0).unwrap();
748        assert_eq!((info.window.as_str(), info.version), ("main#2", 0));
749        assert!(info.facts.is_none());
750    }
751
752    #[test]
753    fn window_actions_follow_the_latest_facts() {
754        let mut session = UiSession::new("s", spec());
755        session.window_opened(1).unwrap();
756        assert_eq!(
757            session
758                .check_action("settings#1", WindowAction::Move)
759                .unwrap_err()
760                .message,
761            "`settings#1` is still opening"
762        );
763        session
764            .window_facts(
765                1,
766                1,
767                facts(
768                    Visibility::Visible,
769                    &[WindowAction::Activate, WindowAction::Move],
770                ),
771                FactSource::System,
772            )
773            .unwrap();
774        assert_eq!(
775            session
776                .check_action("settings#1", WindowAction::Move)
777                .unwrap()
778                .id,
779            "settings#1"
780        );
781        let error = session
782            .check_action("settings#1", WindowAction::Minimize)
783            .unwrap_err();
784        assert_eq!(error.code, "target_unavailable");
785        assert_eq!(
786            error.message,
787            "`settings#1` cannot minimize now; available: activate, move"
788        );
789        assert_eq!(
790            session
791                .check_action("main#1", WindowAction::Minimize)
792                .unwrap_err()
793                .code,
794            "target_not_found"
795        );
796        // A pending close confirmation shows as closing.
797        session.request_close(1, 1, true, false).unwrap();
798        assert_eq!(
799            session.window_info(1).unwrap().lifecycle,
800            Lifecycle::Closing
801        );
802    }
803
804    #[test]
805    fn window_info_matches_the_session_catalog() {
806        let (types, specs) = bern_protocol::session_catalog();
807        let result = &specs
808            .iter()
809            .find(|spec| spec.id == "window.list")
810            .unwrap()
811            .result;
812        let mut session = UiSession::new("s", spec());
813        session.window_opened(0).unwrap();
814        session.window_opened(1).unwrap();
815        session
816            .window_facts(
817                0,
818                1,
819                facts(Visibility::Visible, &WindowAction::ALL),
820                FactSource::System,
821            )
822            .unwrap();
823        let list = Value::List(
824            session
825                .window_infos()
826                .iter()
827                .map(WindowInfo::to_value)
828                .collect(),
829        );
830        list.check(result, &types).unwrap();
831    }
832
833    #[test]
834    fn closing_without_a_failing_check_closes_at_once() {
835        let mut session = UiSession::new("s", spec());
836        session.window_opened(1).unwrap();
837        assert_eq!(
838            session.request_close(1, 1, false, false),
839            Ok(CloseStep::Close)
840        );
841        assert_eq!(
842            session.request_close(1, 7, false, false).unwrap_err().code,
843            "stale_window"
844        );
845    }
846
847    #[test]
848    fn confirmations_merge_and_bind_to_generation_and_revision() {
849        let mut session = UiSession::new("s", spec());
850        session.window_opened(0).unwrap();
851        session
852            .set(0, 1, 0, 0, Value::Text("draft".into()))
853            .unwrap();
854        let CloseStep::Confirm(first) = session.request_close(0, 1, true, true).unwrap() else {
855            panic!("expected a confirmation")
856        };
857        assert_eq!(first.window, "main#1");
858        assert_eq!(first.draft_revision, 1);
859        assert_eq!(
860            first.decisions(),
861            vec![
862                CloseDecision::Save,
863                CloseDecision::Discard,
864                CloseDecision::Cancel
865            ]
866        );
867        // A repeated request returns the same confirmation: one dialog.
868        assert_eq!(
869            session.request_close(0, 1, true, true),
870            Ok(CloseStep::Confirm(first.clone()))
871        );
872        assert!(session.pending_close(0).is_some());
873        // Cancel keeps the window; the old confirmation cannot be reused.
874        assert_eq!(
875            session.confirm_close(0, 1, &first.id, CloseDecision::Cancel),
876            Ok(DecisionStep::Cancel)
877        );
878        assert!(session.pending_close(0).is_none());
879        assert_eq!(
880            session
881                .confirm_close(0, 1, &first.id, CloseDecision::Discard)
882                .unwrap_err()
883                .code,
884            "stale_confirmation"
885        );
886        // Edits made after a confirmation invalidate it, so it cannot
887        // discard them.
888        let CloseStep::Confirm(second) = session.request_close(0, 1, true, true).unwrap() else {
889            panic!()
890        };
891        assert_ne!(second.id, first.id);
892        session.set(0, 1, 0, 0, Value::Text("more".into())).unwrap();
893        assert_eq!(
894            session
895                .confirm_close(0, 1, &second.id, CloseDecision::Discard)
896                .unwrap_err()
897                .code,
898            "stale_confirmation"
899        );
900        let CloseStep::Confirm(third) = session.request_close(0, 1, true, true).unwrap() else {
901            panic!()
902        };
903        assert_eq!(third.draft_revision, 2);
904        assert_eq!(
905            session.confirm_close(0, 1, &third.id, CloseDecision::Save),
906            Ok(DecisionStep::Save)
907        );
908    }
909
910    #[test]
911    fn confirmations_end_with_their_window_instance() {
912        let mut session = UiSession::new("s", spec());
913        session.window_opened(0).unwrap();
914        let CloseStep::Confirm(old) = session.request_close(0, 1, true, false).unwrap() else {
915            panic!()
916        };
917        assert_eq!(
918            session
919                .confirm_close(0, 1, &old.id, CloseDecision::Save)
920                .unwrap_err()
921                .code,
922            "invalid_params"
923        );
924        assert_eq!(
925            session.confirm_close(0, 1, &old.id, CloseDecision::Discard),
926            Ok(DecisionStep::Close)
927        );
928        assert!(session.window_closed(0, 1));
929        session.window_opened(0).unwrap();
930        // A decision for the closed instance cannot touch the new one.
931        assert_eq!(
932            session
933                .confirm_close(0, 1, &old.id, CloseDecision::Discard)
934                .unwrap_err()
935                .code,
936            "stale_window"
937        );
938        assert_eq!(
939            session
940                .confirm_close(0, 2, &old.id, CloseDecision::Discard)
941                .unwrap_err()
942                .code,
943            "stale_confirmation"
944        );
945    }
946
947    struct Echo(String);
948
949    impl SessionHandler for Echo {
950        fn session_id(&self) -> String {
951            self.0.clone()
952        }
953        fn windows(&self) -> Vec<String> {
954            vec![]
955        }
956        fn handle(&self, command: SessionCommand, _: Vec<Value>) -> Result<Value, CommandError> {
957            Ok(Value::Text(format!("{}:{}", self.0, command.id())))
958        }
959    }
960
961    #[test]
962    fn routing_requires_an_unambiguous_live_session() {
963        let sessions = Sessions::new();
964        assert_eq!(
965            sessions
966                .handle(None, SessionCommand::Inspect, vec![])
967                .unwrap_err()
968                .code,
969            "requires_ui_session"
970        );
971        sessions.register(Arc::new(Echo("a".into())));
972        assert_eq!(
973            sessions
974                .handle(None, SessionCommand::Inspect, vec![])
975                .unwrap(),
976            Value::Text("a:ui.inspect".into())
977        );
978        sessions.register(Arc::new(Echo("b".into())));
979        assert_eq!(
980            sessions
981                .handle(None, SessionCommand::Inspect, vec![])
982                .unwrap_err()
983                .code,
984            "session_required"
985        );
986        assert!(
987            sessions
988                .handle(Some("b"), SessionCommand::Back, vec![])
989                .is_ok()
990        );
991        assert_eq!(
992            sessions
993                .handle(Some("z"), SessionCommand::Back, vec![])
994                .unwrap_err()
995                .code,
996            "session_not_found"
997        );
998    }
999}