1use std::sync::Arc;
37
38use sim_codec_json::{JsonProjectionMode, project_expr_to_json, project_json_to_expr};
39use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Expr, Result as SimResult, Symbol};
40use sim_lib_view::{
41 LensRegistry, UNIVERSAL_EDITOR_ID, UNIVERSAL_VIEW_ID, register_universal_default,
42};
43use sim_lib_web_bridge::{FixtureTransport, SceneUpdate, Session};
44
45const INTENT_NAMESPACE: &str = "intent";
47
48pub const DEFAULT_PANE: &str = "pane-main";
51
52pub const DEFAULT_RESOURCE: &str = "demo";
54
55pub struct LiveSession {
63 session: Session<FixtureTransport>,
64 registry: LensRegistry,
65 cx: Cx,
66}
67
68impl LiveSession {
69 pub fn new() -> SimResult<Self> {
72 let mut transport = FixtureTransport::new();
73 transport.set(Symbol::new(DEFAULT_RESOURCE), demo_value());
74 let mut registry = LensRegistry::new();
75 register_universal_default(&mut registry, false);
76 let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory)); let mut session = Session::new(transport);
81 session.open(
82 &mut cx,
83 ®istry,
84 Symbol::new(DEFAULT_PANE),
85 Symbol::new(DEFAULT_RESOURCE),
86 Symbol::new(UNIVERSAL_VIEW_ID),
87 Symbol::new(UNIVERSAL_EDITOR_ID),
88 )?;
89 Ok(Self {
90 session,
91 registry,
92 cx,
93 })
94 }
95
96 pub fn open(&mut self, resource: &str, pane: &str) -> SimResult<Expr> {
99 self.session.open(
100 &mut self.cx,
101 &self.registry,
102 Symbol::new(pane),
103 Symbol::new(resource),
104 Symbol::new(UNIVERSAL_VIEW_ID),
105 Symbol::new(UNIVERSAL_EDITOR_ID),
106 )
107 }
108
109 pub fn submit(&mut self, pane: &str, intent: &Expr) -> SimResult<Vec<SceneUpdate>> {
112 self.session
113 .submit_intent(&mut self.cx, &self.registry, &Symbol::new(pane), intent)?;
114 self.session.pump(&mut self.cx, &self.registry)
115 }
116}
117
118fn demo_value() -> Expr {
120 Expr::Map(vec![
121 (
122 Expr::Symbol(Symbol::new("title")),
123 Expr::String("SIM live session".to_owned()),
124 ),
125 (
126 Expr::Symbol(Symbol::new("note")),
127 Expr::String("edit me".to_owned()),
128 ),
129 ])
130}
131
132pub fn decode_intent_body(body: &str) -> Result<Expr, String> {
136 let value: serde_json::Value =
137 serde_json::from_str(body).map_err(|err| format!("invalid JSON intent body: {err}"))?;
138 let expr = project_json_to_expr(&value, JsonProjectionMode::UntaggedInterop);
139 lift_intent(expr)
140}
141
142pub fn encode_patches(updates: &[SceneUpdate]) -> String {
145 let patches: Vec<serde_json::Value> = updates
146 .iter()
147 .map(|update| project_expr_to_json(&update.diff, JsonProjectionMode::UntaggedInterop))
148 .collect();
149 serde_json::json!({ "patches": patches }).to_string()
150}
151
152pub fn encode_scene(scene: &Expr) -> String {
155 serde_json::json!({ "scene": project_expr_to_json(scene, JsonProjectionMode::UntaggedInterop) })
156 .to_string()
157}
158
159pub fn error_json(message: &str) -> String {
161 serde_json::json!({ "error": message }).to_string()
162}
163
164fn lift_intent(expr: Expr) -> Result<Expr, String> {
166 let Expr::Map(entries) = expr else {
167 return Err("intent body must be a JSON object".to_owned());
168 };
169 let mut lifted = Vec::with_capacity(entries.len());
170 for (key, value) in entries {
171 let name = key_name(&key)?;
172 let value = match name.as_str() {
173 "kind" => lift_kind(value)?,
174 "origin" => lift_origin(value),
175 "path" => lift_path(value),
176 _ => value,
177 };
178 lifted.push((Expr::Symbol(Symbol::new(name)), value));
179 }
180 Ok(Expr::Map(lifted))
181}
182
183fn key_name(key: &Expr) -> Result<String, String> {
185 match key {
186 Expr::Symbol(symbol) => Ok(symbol.name.to_string()),
187 Expr::String(text) => Ok(text.clone()),
188 other => Err(format!("intent key must be a string, found {other:?}")),
189 }
190}
191
192fn lift_kind(value: Expr) -> Result<Expr, String> {
195 match value {
196 Expr::Symbol(symbol) => Ok(Expr::Symbol(symbol)),
197 Expr::String(text) => {
198 let local = text.strip_prefix("intent/").unwrap_or(&text);
199 Ok(Expr::Symbol(Symbol::qualified(INTENT_NAMESPACE, local)))
200 }
201 other => Err(format!("intent 'kind' must be a string, found {other:?}")),
202 }
203}
204
205fn lift_origin(value: Expr) -> Expr {
207 let Expr::Map(entries) = value else {
208 return value;
209 };
210 let lifted = entries
211 .into_iter()
212 .map(|(key, value)| {
213 let is_operator = matches!(&key, Expr::Symbol(symbol) if &*symbol.name == "operator")
214 || matches!(&key, Expr::String(text) if text == "operator");
215 let value = match value {
216 Expr::String(text) if is_operator => Expr::Symbol(Symbol::new(text)),
217 other => other,
218 };
219 (key, value)
220 })
221 .collect();
222 Expr::Map(lifted)
223}
224
225fn lift_path(value: Expr) -> Expr {
230 let segments = match value {
231 Expr::List(segments) | Expr::Vector(segments) => segments,
232 other => return other,
233 };
234 Expr::List(segments.into_iter().map(lift_segment).collect())
235}
236
237fn lift_segment(segment: Expr) -> Expr {
239 let items = match segment {
240 Expr::List(items) | Expr::Vector(items) => items,
241 other => return other,
242 };
243 let lifted = items
244 .into_iter()
245 .enumerate()
246 .map(|(index, item)| match item {
247 Expr::String(text) if index == 0 => Expr::Symbol(Symbol::new(text)),
248 other => other,
249 })
250 .collect();
251 Expr::Vector(lifted)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use sim_lib_intent::{Origin, intent};
258
259 fn key_path(key: &str) -> Expr {
260 Expr::List(vec![Expr::Vector(vec![
261 Expr::Symbol(Symbol::new("k")),
262 Expr::Symbol(Symbol::new(key)),
263 ])])
264 }
265
266 fn edit_intent(key: &str, value: &str) -> Expr {
267 intent(
268 "edit-field",
269 Origin::human(1),
270 vec![
271 ("target", demo_value()),
272 ("path", key_path(key)),
273 ("value", Expr::String(value.to_owned())),
274 ],
275 )
276 }
277
278 #[test]
279 fn submit_edit_returns_a_patch_that_reconstructs_the_scene() {
280 let mut live = LiveSession::new().unwrap();
281 let before = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
282 sim_lib_scene::validate_scene(&before).expect("initial scene is valid");
283
284 let updates = live
285 .submit(DEFAULT_PANE, &edit_intent("title", "changed"))
286 .unwrap();
287 assert_eq!(updates.len(), 1, "the subscribed pane updates exactly once");
288 let update = &updates[0];
289 assert_ne!(update.scene, before, "the Scene changed");
290 let rebuilt = sim_lib_scene::apply(&before, &update.diff).unwrap();
291 assert_eq!(
292 rebuilt, update.scene,
293 "the diff reconstructs the new Scene from the old one"
294 );
295 }
296
297 #[test]
298 fn open_returns_a_valid_scene() {
299 let mut live = LiveSession::new().unwrap();
300 let scene = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
301 sim_lib_scene::validate_scene(&scene).expect("open returns a valid Scene");
302 }
303
304 #[test]
305 fn a_browser_json_intent_decodes_and_drives_a_root_edit() {
306 let body = r#"{"kind":"intent/edit-field","origin":{"operator":"human","at-tick":2},"target":{},"path":[],"value":"hello"}"#;
309 let intent = decode_intent_body(body).unwrap();
310 let kind = match &intent {
311 Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
312 matches!(key, Expr::Symbol(symbol) if &*symbol.name == "kind").then_some(value)
313 }),
314 _ => None,
315 };
316 assert!(
317 matches!(kind, Some(Expr::Symbol(_))),
318 "the kind tag is lifted to a symbol"
319 );
320
321 let mut live = LiveSession::new().unwrap();
322 live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
323 let updates = live.submit(DEFAULT_PANE, &intent).unwrap();
324 assert_eq!(updates.len(), 1);
325 }
326
327 #[test]
328 fn a_malformed_body_is_an_error_not_a_panic() {
329 assert!(decode_intent_body("this is not json").is_err());
330 assert!(
331 decode_intent_body("[1, 2, 3]").is_err(),
332 "a non-object intent body is rejected"
333 );
334 }
335
336 #[test]
337 fn an_intent_without_a_kind_fails_closed_on_submit() {
338 let intent = decode_intent_body(r#"{"origin":{"operator":"human","at-tick":1}}"#).unwrap();
339 let mut live = LiveSession::new().unwrap();
340 assert!(
341 live.submit(DEFAULT_PANE, &intent).is_err(),
342 "an intent without a kind is rejected, not executed"
343 );
344 }
345
346 #[test]
347 fn patches_scenes_and_errors_encode_as_untagged_json() {
348 let mut live = LiveSession::new().unwrap();
349 live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
350 let updates = live
351 .submit(DEFAULT_PANE, &edit_intent("title", "x"))
352 .unwrap();
353
354 let patches = encode_patches(&updates);
355 assert!(patches.contains("\"patches\""), "carries a patches array");
356 assert!(patches.contains("scene/patch"), "patches are scene patches");
357
358 let scene = encode_scene(&live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap());
359 assert!(scene.contains("\"scene\""), "carries a scene field");
360
361 assert!(
362 error_json("boom").contains("boom"),
363 "errors carry a message"
364 );
365 }
366}