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