Skip to main content

adam_web_ui/graph/
bridge.rs

1//! Container-scoped lifecycle and approval protocol for the graph view.
2//!
3//! Start a fresh monotonically numbered generation before submitting requests.
4//! Disposing a generation is terminal; unknown generations never create models.
5//!
6//! ```
7//! use adam_web_ui::graph::BridgeState;
8//! let mut bridge = BridgeState::default();
9//! bridge.start(1)?;
10//! bridge.dispose(1);
11//! # Ok::<(), anyhow::Error>(())
12//! ```
13
14use anyhow::{Result, ensure};
15use cel_euclid::{Point2D, Rect, Size2D};
16use serde::{Deserialize, Serialize};
17
18use super::{ViewAction, ViewConstraints, ViewInput, ViewOutput};
19
20/// A geometry snapshot and source action with a generation-local ordering key.
21///
22/// Bounds use ordered finite minimum/maximum endpoints; viewport sizes are positive.
23///
24/// # Examples
25/// ```
26/// # use adam_web_ui::graph::{ViewRequest, ViewAction};
27/// let request = ViewRequest { generation: 1, revision: 1, sequence: 1,
28///     bounds: [0.0, 0.0, 200.0, 100.0], viewport: [400.0, 300.0],
29///     action: ViewAction::Fit };
30/// assert_eq!(request.sequence, 1);
31/// ```
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33pub struct ViewRequest {
34    /// The explicitly started lifecycle.
35    pub generation: u32,
36    /// The geometry revision measured by the renderer.
37    pub revision: u32,
38    /// The strictly increasing generation-local submission number.
39    pub sequence: u32,
40    /// Minimum x/y followed by maximum x/y.
41    pub bounds: [f64; 4],
42    /// Positive viewport width and height.
43    pub viewport: [f64; 2],
44    /// The requested source operation.
45    pub action: ViewAction,
46}
47
48/// Lifecycle and update messages for one mounted container channel.
49///
50/// # Examples
51/// ```
52/// use adam_web_ui::graph::ViewMessage;
53/// assert_eq!(serde_json::to_string(&ViewMessage::Start { generation: 1 })?,
54///     r#"{"kind":"start","generation":1}"#);
55/// # Ok::<(), serde_json::Error>(())
56/// ```
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum ViewMessage {
60    /// Explicitly creates the generation's single cached model.
61    Start {
62        /// The new strictly greater generation.
63        generation: u32,
64    },
65    /// Evaluates one complete snapshot.
66    Update {
67        /// The geometry, action and ordering identity.
68        request: ViewRequest,
69    },
70    /// Ends the matching active generation without affecting another generation.
71    Dispose {
72        /// The generation to retire.
73        generation: u32,
74    },
75}
76
77/// An approval or explicit current-request failure carrying the original identity.
78///
79/// # Examples
80/// ```
81/// use adam_web_ui::graph::ViewReply;
82/// let reply = ViewReply::Failed { generation: 1, revision: 1, sequence: 1,
83///     message: "invalid viewport".into() };
84/// assert!(serde_json::to_string(&reply)?.contains(r#""kind":"failed""#));
85/// # Ok::<(), serde_json::Error>(())
86/// ```
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "kind", rename_all = "snake_case")]
89pub enum ViewReply {
90    /// Contains only fully evaluated model output.
91    Accepted {
92        /// The request generation.
93        generation: u32,
94        /// The request geometry revision.
95        revision: u32,
96        /// The request submission sequence.
97        sequence: u32,
98        /// The approved view and model-owned gesture limits.
99        output: ViewOutput,
100    },
101    /// Reports invalid current geometry or a model propagation failure.
102    Failed {
103        /// The request generation.
104        generation: u32,
105        /// The request geometry revision.
106        revision: u32,
107        /// The request submission sequence.
108        sequence: u32,
109        /// The diagnostic to report visibly and through the host error channel.
110        message: String,
111    },
112}
113
114/// Owns one cached model and lifecycle/order watermarks for one container.
115///
116/// # Examples
117/// ```
118/// let mut bridge = adam_web_ui::graph::BridgeState::default();
119/// bridge.start(1)?;
120/// assert!(bridge.start(1).is_err());
121/// # Ok::<(), anyhow::Error>(())
122/// ```
123#[derive(Default)]
124pub struct BridgeState {
125    /// The greatest successfully claimed generation, including disposed generations.
126    generation: Option<u32>,
127    /// The active generation's model, absent after disposal or failed startup.
128    model: Option<ViewConstraints>,
129    /// The last consumed sequence and geometry revision.
130    processed: Option<(u32, u32)>,
131}
132
133impl BridgeState {
134    /// Decodes a wire message or returns a JavaScript diagnostic as an error.
135    ///
136    /// # Errors
137    /// Returns errors for diagnostic strings, malformed messages, or startup failure.
138    ///
139    /// - Complexity: O(n + p) time and space in message size `n` and model work `p`.
140    pub(crate) fn receive(&mut self, payload: serde_json::Value) -> Result<Option<ViewReply>> {
141        if let Some(message) = payload.as_str() {
142            anyhow::bail!("{message}");
143        }
144        self.process(serde_json::from_value(payload)?)
145    }
146
147    /// Dispatches a typed lifecycle message without framework-specific branching.
148    ///
149    /// # Errors
150    /// Returns startup errors; current update failures are explicit failed replies.
151    ///
152    /// - Complexity: O(p) time and space in model construction/propagation work.
153    ///
154    /// # Examples
155    /// ```
156    /// use adam_web_ui::graph::{BridgeState, ViewMessage};
157    /// assert!(BridgeState::default().process(ViewMessage::Start { generation: 1 })?.is_none());
158    /// # Ok::<(), anyhow::Error>(())
159    /// ```
160    pub fn process(&mut self, message: ViewMessage) -> Result<Option<ViewReply>> {
161        match message {
162            ViewMessage::Start { generation } => {
163                self.start(generation)?;
164                Ok(None)
165            }
166            ViewMessage::Update { request } => self.handle(request),
167            ViewMessage::Dispose { generation } => {
168                self.dispose(generation);
169                Ok(None)
170            }
171        }
172    }
173    /// Claims a strictly newer generation and parses its model exactly once.
174    ///
175    /// # Errors
176    /// Returns an error for a repeated/older generation or model initialization failure.
177    /// A failed initialization still retires the claimed generation.
178    ///
179    /// - Complexity: O(p) time and space for model initialization work `p`.
180    ///
181    /// # Examples
182    /// ```
183    /// let mut bridge = adam_web_ui::graph::BridgeState::default();
184    /// bridge.start(1)?;
185    /// # Ok::<(), anyhow::Error>(())
186    /// ```
187    pub fn start(&mut self, generation: u32) -> Result<()> {
188        ensure!(
189            self.generation.is_none_or(|last| generation > last),
190            "graph generation must increase monotonically"
191        );
192        self.generation = Some(generation);
193        self.processed = None;
194        self.model = None;
195        self.model = Some(ViewConstraints::new()?);
196        Ok(())
197    }
198
199    /// Evaluates current ordered work without implicitly starting a generation.
200    ///
201    /// Returns `None` only for inactive/unknown generations, consumed sequences,
202    /// or older geometry. Invalid current inputs produce a `Failed` reply.
203    /// Failed current requests consume their ordering identity too.
204    ///
205    /// # Errors
206    /// Reserves transport-level errors for the channel owner; model/input errors
207    /// are represented by `ViewReply::Failed`.
208    ///
209    /// - Complexity: O(p) time and space in cached model propagation work `p`.
210    ///
211    /// # Examples
212    /// ```
213    /// # use adam_web_ui::graph::{BridgeState, ViewAction, ViewRequest};
214    /// let mut bridge = BridgeState::default();
215    /// assert!(bridge.handle(ViewRequest { generation: 1, revision: 1, sequence: 1,
216    ///     bounds: [0.0, 0.0, 1.0, 1.0], viewport: [100.0, 100.0],
217    ///     action: ViewAction::Fit })?.is_none());
218    /// # Ok::<(), anyhow::Error>(())
219    /// ```
220    pub fn handle(&mut self, request: ViewRequest) -> Result<Option<ViewReply>> {
221        if self.generation != Some(request.generation) {
222            return Ok(None);
223        }
224        let Some(model) = self.model.as_mut() else {
225            return Ok(None);
226        };
227        if self.processed.is_some_and(|(sequence, revision)| {
228            request.sequence <= sequence || request.revision < revision
229        }) {
230            return Ok(None);
231        }
232        self.processed = Some((request.sequence, request.revision));
233        let [min_x, min_y, max_x, max_y] = request.bounds;
234        let result = (|| {
235            ensure!(
236                request.bounds.iter().all(|v| v.is_finite()) && min_x <= max_x && min_y <= max_y,
237                "invalid graph bounds"
238            );
239            model.update(ViewInput {
240                bounds: Rect::new(
241                    Point2D::new(min_x, min_y),
242                    Size2D::new(max_x - min_x, max_y - min_y),
243                ),
244                viewport: Size2D::new(request.viewport[0], request.viewport[1]),
245                action: request.action,
246            })
247        })();
248        Ok(Some(match result {
249            Ok(output) => ViewReply::Accepted {
250                generation: request.generation,
251                revision: request.revision,
252                sequence: request.sequence,
253                output,
254            },
255            Err(error) => ViewReply::Failed {
256                generation: request.generation,
257                revision: request.revision,
258                sequence: request.sequence,
259                message: error.to_string(),
260            },
261        }))
262    }
263
264    /// Retires only the matching generation, retaining its terminal watermark.
265    ///
266    /// # Examples
267    /// ```
268    /// let mut bridge = adam_web_ui::graph::BridgeState::default();
269    /// bridge.start(1)?;
270    /// bridge.dispose(1);
271    /// assert!(bridge.start(1).is_err());
272    /// # Ok::<(), anyhow::Error>(())
273    /// ```
274    pub fn dispose(&mut self, generation: u32) {
275        if self.generation == Some(generation) {
276            self.model = None;
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::graph::{ViewAction, ViewTransform};
285
286    /// Builds a valid absolute proposal with fixed geometry.
287    fn request(generation: u32, sequence: u32) -> ViewRequest {
288        ViewRequest {
289            generation,
290            revision: 1,
291            sequence,
292            bounds: [0.0, 0.0, 200.0, 100.0],
293            viewport: [400.0, 300.0],
294            action: ViewAction::Propose {
295                view: ViewTransform {
296                    x: 0.0,
297                    y: 50.0,
298                    k: 2.0,
299                },
300            },
301        }
302    }
303
304    /// Unknown generations never implicitly start a model.
305    #[test]
306    fn stale_generation_is_ignored() {
307        let mut state = BridgeState::default();
308        assert!(state.handle(request(1, 1)).unwrap().is_none());
309        state.start(2).unwrap();
310        assert!(state.handle(request(1, 1)).unwrap().is_none());
311        assert!(state.handle(request(3, 1)).unwrap().is_none());
312    }
313
314    /// Disposal is terminal even when a delayed Start arrives.
315    #[test]
316    fn disposed_generation_cannot_restart() {
317        let mut state = BridgeState::default();
318        state.start(1).unwrap();
319        state.dispose(1);
320        assert!(state.start(1).is_err());
321        assert!(state.handle(request(1, 1)).unwrap().is_none());
322        state.start(2).unwrap();
323        state.dispose(1);
324        assert!(state.handle(request(2, 1)).unwrap().is_some());
325    }
326
327    /// Old sequences and geometry never edit the retained source aspiration.
328    #[test]
329    fn stale_sequence_does_not_mutate_model() {
330        let mut state = BridgeState::default();
331        state.start(1).unwrap();
332        let first = state.handle(request(1, 2)).unwrap().unwrap();
333        let mut old = request(1, 1);
334        old.action = ViewAction::Fit;
335        assert!(state.handle(old).unwrap().is_none());
336        let mut old_geometry = request(1, 3);
337        old_geometry.revision = 0;
338        assert!(state.handle(old_geometry).unwrap().is_none());
339        let mut refresh = request(1, 4);
340        refresh.action = ViewAction::Refresh;
341        let Some(ViewReply::Accepted { output: actual, .. }) = state.handle(refresh).unwrap()
342        else {
343            panic!("refresh must be approved");
344        };
345        let ViewReply::Accepted {
346            output: expected, ..
347        } = first
348        else {
349            panic!("initial proposal must be approved");
350        };
351        assert_eq!(expected, actual);
352    }
353
354    /// Invalid current geometry returns a failure with the request identity.
355    #[test]
356    fn current_failure_is_a_failed_reply() {
357        let mut state = BridgeState::default();
358        state.start(1).unwrap();
359        let mut invalid = request(1, 1);
360        invalid.viewport[0] = 0.0;
361        assert!(matches!(
362            state.handle(invalid).unwrap(),
363            Some(ViewReply::Failed {
364                generation: 1,
365                sequence: 1,
366                ..
367            })
368        ));
369    }
370
371    /// Serde validates u32 fields rather than truncating hostile numbers.
372    #[test]
373    fn message_numbers_are_validated() {
374        for number in ["-1", "1.5", "4294967296"] {
375            assert!(
376                serde_json::from_str::<ViewMessage>(&format!(
377                    r#"{{"kind":"start","generation":{number}}}"#
378                ))
379                .is_err()
380            );
381        }
382    }
383
384    /// Malformed current messages and JS diagnostics are errors, never stale success.
385    #[test]
386    fn wire_errors_are_explicit_and_do_not_consume_valid_work() {
387        let mut state = BridgeState::default();
388        state.start(1).unwrap();
389        assert!(
390            state
391                .receive(serde_json::json!({
392                    "kind": "update", "request": { "generation": 1, "sequence": -1 }
393                }))
394                .is_err()
395        );
396        assert_eq!(
397            state
398                .receive(serde_json::json!("channel failed"))
399                .unwrap_err()
400                .to_string(),
401            "channel failed"
402        );
403        assert!(state.handle(request(1, 1)).unwrap().is_some());
404    }
405
406    /// Container owners retain independent models and sequence watermarks.
407    #[test]
408    fn two_containers_are_independent() {
409        let mut a = BridgeState::default();
410        let mut b = BridgeState::default();
411        a.start(1).unwrap();
412        b.start(1).unwrap();
413        a.dispose(1);
414        assert!(b.handle(request(1, 1)).unwrap().is_some());
415        assert!(a.handle(request(1, 1)).unwrap().is_none());
416    }
417
418    /// Shared fixtures encode the exact tags, field names, and action shapes.
419    #[test]
420    fn wire_fixtures_round_trip() {
421        let fixture: serde_json::Value =
422            serde_json::from_str(include_str!("../../../begin/js/graph-protocol.json")).unwrap();
423        let mut state = BridgeState::default();
424        for message in fixture["messages"].as_array().unwrap() {
425            let parsed: ViewMessage = serde_json::from_value(message.clone()).unwrap();
426            assert_eq!(serde_json::to_value(&parsed).unwrap(), *message);
427            assert!(state.process(parsed).is_ok());
428        }
429        for reply in fixture["replies"].as_array().unwrap() {
430            let parsed: ViewReply = serde_json::from_value(reply.clone()).unwrap();
431            assert_eq!(serde_json::to_value(parsed).unwrap(), *reply);
432        }
433    }
434}