1use crate::Facet;
8
9#[derive(Debug, Clone)]
11pub struct RenderReport {
12 pub title: String,
13 pub state: serde_json::Value,
15 pub vertices: usize,
17}
18
19impl RenderReport {
20 pub fn drew(&self) -> bool {
21 self.vertices > 0
22 }
23}
24
25#[allow(deprecated)] fn capture(ctx: &egui::Context, facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
29 let title = facet.title().to_string();
30 crate::trace::emit_in(
33 "facet.render",
34 &serde_json::json!({ "title": title, "size": [size.0, size.1] }),
35 );
36 let input = egui::RawInput {
37 screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(size.0, size.1))),
38 ..Default::default()
39 };
40 let output = ctx.run(input, |ctx| {
41 egui::CentralPanel::default().show(ctx, |ui| facet.ui(ui));
42 });
43 let prims = ctx.tessellate(output.shapes, output.pixels_per_point);
44 let vertices = prims
45 .iter()
46 .map(|p| match &p.primitive {
47 egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
48 _ => 0,
49 })
50 .sum();
51 let report = RenderReport { title, state: facet.state_json(), vertices };
52 log(&report);
53 trail(Kind::Render, format!("{} size={}x{} → {} verts", report.title, size.0 as i32, size.1 as i32, vertices));
54 dump_state(&report);
55 crate::trace::emit_out(
59 "facet.render",
60 &serde_json::json!({
61 "title": report.title,
62 "vertices": report.vertices,
63 "drew": report.drew(),
64 "state": report.state,
65 }),
66 );
67 report
68}
69
70pub fn render_sized(facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
72 capture(&egui::Context::default(), facet, size)
73}
74
75pub fn headless_render(facet: &mut dyn Facet) -> RenderReport {
77 render_sized(facet, (800.0, 600.0))
78}
79
80pub fn render_themed(facet: &mut dyn Facet, theme: crate::Theme) -> RenderReport {
82 let ctx = egui::Context::default();
83 crate::set_theme(&ctx, theme);
84 capture(&ctx, facet, (800.0, 600.0))
85}
86
87pub fn render_themed_sized(facet: &mut dyn Facet, theme: crate::Theme, size: (f32, f32)) -> RenderReport {
93 let ctx = egui::Context::default();
94 crate::set_theme(&ctx, theme);
95 capture(&ctx, facet, size)
96}
97
98pub fn log(r: &RenderReport) {
101 let full = r.state.to_string();
102 let shown: String = if full.chars().count() > 160 {
103 full.chars().take(159).chain(std::iter::once('…')).collect()
104 } else {
105 full
106 };
107 eprintln!("facett: {:<14} {:>7} verts · {}", r.title, r.vertices, shown);
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Kind {
122 Render,
124 State,
126 Case,
128}
129
130impl Kind {
131 pub fn tag(self) -> &'static str {
132 match self {
133 Kind::Render => "RENDER",
134 Kind::State => "STATE",
135 Kind::Case => "CASE",
136 }
137 }
138}
139
140fn next_seq() -> u64 {
142 use std::sync::atomic::{AtomicU64, Ordering};
143 static SEQ: AtomicU64 = AtomicU64::new(0);
144 SEQ.fetch_add(1, Ordering::Relaxed) + 1
145}
146
147fn now_stamp() -> String {
151 let now = std::time::SystemTime::now()
152 .duration_since(std::time::UNIX_EPOCH)
153 .unwrap_or_default();
154 let total_ms = now.as_millis();
155 let ms = (total_ms % 1000) as u64;
156 let secs = (total_ms / 1000) as u64;
157 let h = (secs / 3600) % 24;
158 let m = (secs / 60) % 60;
159 let s = secs % 60;
160 format!("{h:02}:{m:02}:{s:02}.{ms:03}")
161}
162
163pub fn trail(kind: Kind, detail: impl AsRef<str>) {
169 let stamp = now_stamp();
170 let seq = next_seq();
171 let detail = detail.as_ref();
172 let line = format!("facett ACTION {stamp} {seq:>5} [{}] {detail}", kind.tag());
173 eprintln!("{line}");
174 if let Ok(path) = std::env::var("FACETT_TRAIL") {
175 use std::io::Write;
176 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
177 let _ = writeln!(f, "{line}");
178 }
179 }
180}
181
182pub fn dump_state(r: &RenderReport) {
187 eprintln!("facett STATE {} = {}", r.title, r.state);
188 trail(Kind::State, format!("{} state={}", r.title, r.state));
189}
190
191pub fn case_summary(component: &str, axis: &str, r: &RenderReport) {
196 eprintln!(
197 "facett CASE {:<14} {:<16} → {:>8} verts drew={} state={}",
198 component,
199 axis,
200 r.vertices,
201 r.drew(),
202 r.state,
203 );
204 trail(Kind::Case, format!("{component} {axis} verts={} drew={}", r.vertices, r.drew()));
205}
206
207pub fn drive<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> Vec<C::Effect> {
220 let mut effects = Vec::new();
221 for m in msgs {
222 effects.extend(component.update(m));
223 }
224 effects
225}
226
227pub fn snapshot<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> C::Model {
230 drive(component, msgs);
231 component.state().clone()
232}
233
234pub fn snapshot_json<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> serde_json::Value {
238 drive(component, msgs);
239 state_json(component)
240}
241
242pub fn state_json<C: crate::Elm>(component: &C) -> serde_json::Value {
245 serde_json::to_value(component.state()).unwrap_or(serde_json::Value::Null)
246}
247
248#[derive(Debug, Clone)]
266pub struct ProbeStep {
267 pub msg: String,
269 pub vertices: usize,
271 pub changed: bool,
273 pub state: serde_json::Value,
275}
276
277#[derive(Debug, Clone)]
279pub struct FacetProbe {
280 pub title: String,
281 pub vertices: usize,
283 pub initial_state: serde_json::Value,
285 pub final_state: serde_json::Value,
288 pub steps: Vec<ProbeStep>,
290}
291
292impl FacetProbe {
293 pub fn drew(&self) -> bool {
295 self.vertices > 0
296 }
297 pub fn responded(&self) -> bool {
301 self.steps.iter().any(|s| s.changed)
302 }
303 pub fn cardinality(&self) -> usize {
310 json_cardinality(&self.final_state)
311 }
312}
313
314pub fn json_cardinality(v: &serde_json::Value) -> usize {
317 match v {
318 serde_json::Value::Array(a) => a
319 .iter()
320 .map(json_cardinality)
321 .max()
322 .unwrap_or(0)
323 .max(a.len()),
324 serde_json::Value::Object(o) => {
325 let deepest = o.values().map(json_cardinality).max().unwrap_or(0);
326 deepest.max(o.len())
327 }
328 _ => 0,
329 }
330}
331
332pub fn probe_facet(facet: &mut dyn Facet, msgs: &[&str]) -> FacetProbe {
338 let ctx = egui::Context::default();
339 let first = capture(&ctx, facet, (800.0, 600.0));
340 let initial_state = first.state.clone();
341 let mut prev = initial_state.clone();
342 let mut steps = Vec::with_capacity(msgs.len());
343 for msg in msgs {
344 facet.update_json(msg);
345 let r = capture(&ctx, facet, (800.0, 600.0));
346 let changed = r.state != prev;
347 trail(
348 Kind::Case,
349 format!("{} update_json {} → changed={}", first.title, msg, changed),
350 );
351 prev = r.state.clone();
352 steps.push(ProbeStep { msg: (*msg).to_string(), vertices: r.vertices, changed, state: r.state });
353 }
354 let final_state = steps.last().map(|s| s.state.clone()).unwrap_or_else(|| initial_state.clone());
355 FacetProbe { title: first.title, vertices: first.vertices, initial_state, final_state, steps }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::{Scene, hash_color};
362
363 struct Tiny(Scene);
364 impl Facet for Tiny {
365 fn title(&self) -> &str {
366 "tiny"
367 }
368 fn ui(&mut self, ui: &mut egui::Ui) {
369 crate::draw(ui, &self.0, crate::Layout::Circular, "empty");
370 }
371 fn state_json(&self) -> serde_json::Value {
372 serde_json::json!({ "nodes": self.0.nodes.len() })
373 }
374 }
375
376 #[test]
377 fn now_stamp_is_hms_millis_shaped() {
378 let s = now_stamp();
379 assert_eq!(s.len(), 12, "stamp `{s}` should be HH:MM:SS.mmm");
381 assert_eq!(s.matches(':').count(), 2, "stamp `{s}` needs two colons");
382 assert_eq!(s.matches('.').count(), 1, "stamp `{s}` needs one dot");
383 }
384
385 #[test]
386 fn seq_is_monotonic() {
387 let a = next_seq();
388 let b = next_seq();
389 assert!(b > a, "seq must strictly increase: {a} then {b}");
390 }
391
392 #[test]
393 fn kind_tags_are_distinct() {
394 let tags = [Kind::Render.tag(), Kind::State.tag(), Kind::Case.tag()];
395 for (i, t) in tags.iter().enumerate() {
396 assert!(!t.is_empty());
397 assert!(!tags[..i].contains(t), "duplicate tag {t}");
398 }
399 }
400
401 #[test]
402 fn headless_render_captures_state_and_draws() {
403 let mut scene = Scene::new();
404 let a = scene.node("a", hash_color("a"));
405 let b = scene.node("b", hash_color("b"));
406 scene.edge(a, b);
407 let mut t = Tiny(scene);
408 let r = headless_render(&mut t);
409 assert_eq!(r.title, "tiny");
410 assert_eq!(r.state["nodes"], 2);
411 assert!(r.drew(), "a 2-node graph should tessellate to vertices");
412 }
413
414 use crate::Elm;
423 use serde::{Deserialize, Serialize};
424
425 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
427 struct CounterState {
428 count: i64,
429 selected: Option<String>,
430 }
431
432 #[derive(Clone, Debug, PartialEq)]
434 enum CounterMsg {
435 Inc,
436 Dec,
437 Add(i64),
438 Select(Option<String>),
439 }
440
441 #[derive(Debug)]
443 enum CounterEffect {}
444
445 struct Counter {
446 title: String,
447 state: CounterState,
448 }
449
450 impl Counter {
451 fn new() -> Self {
452 Self { title: "counter".into(), state: CounterState { count: 0, selected: None } }
453 }
454 }
455
456 impl Elm for Counter {
457 type Model = CounterState;
458 type Msg = CounterMsg;
459 type Effect = CounterEffect;
460
461 fn title(&self) -> &str {
462 &self.title
463 }
464 fn state(&self) -> &CounterState {
465 &self.state
466 }
467 fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
468 match msg {
469 CounterMsg::Inc => self.state.count += 1,
470 CounterMsg::Dec => self.state.count -= 1,
471 CounterMsg::Add(n) => self.state.count += n,
472 CounterMsg::Select(id) => {
474 self.state.selected = if self.state.selected == id { None } else { id };
475 }
476 }
477 Vec::new()
478 }
479 fn view(&self, ui: &mut egui::Ui) -> Vec<CounterMsg> {
480 ui.label(format!("count = {}", self.state.count));
482 Vec::new()
483 }
484 }
485
486 crate::impl_facet_via_elm!(Counter);
488
489 struct RichCounter {
493 state: CounterState,
494 }
495 impl Elm for RichCounter {
496 type Model = CounterState;
497 type Msg = CounterMsg;
498 type Effect = CounterEffect;
499 fn title(&self) -> &str {
500 "rich"
501 }
502 fn state(&self) -> &CounterState {
503 &self.state
504 }
505 fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
506 if let CounterMsg::Add(n) = msg {
507 self.state.count += n;
508 }
509 Vec::new()
510 }
511 fn view(&self, _ui: &mut egui::Ui) -> Vec<CounterMsg> {
512 Vec::new()
513 }
514 }
515 crate::impl_facet_via_elm!(RichCounter, custom_state_json, {
518 fn state_json(&self) -> serde_json::Value {
519 serde_json::json!({ "count": self.state.count, "parity": self.state.count % 2 })
520 }
521 });
522
523 #[test]
526 fn form3_custom_state_json_overrides_the_default() {
527 use crate::Facet;
528 let mut c = RichCounter { state: CounterState { count: 0, selected: None } };
529 let _ = c.update(CounterMsg::Add(7));
530 let j = Facet::state_json(&c);
531 assert_eq!(j["count"], 7, "custom state_json is emitted");
532 assert_eq!(j["parity"], 1, "the DERIVED key the default serde dump omits is present");
533 assert_eq!(Facet::title(&c), "rich");
534 }
535
536 #[test]
537 fn harness_drives_msgs_and_snapshots_state() {
538 let mut c = Counter::new();
539 let snap = snapshot(
541 &mut c,
542 [CounterMsg::Inc, CounterMsg::Inc, CounterMsg::Add(5), CounterMsg::Dec],
543 );
544 assert_eq!(snap.count, 6, "1+1+5-1 = 6");
545 assert_eq!(c.state().count, 6, "the component holds the driven state");
546
547 let snap = snapshot(&mut c, [CounterMsg::Select(Some("a".into())), CounterMsg::Select(Some("a".into()))]);
549 assert_eq!(snap.selected, None, "toggle clears the re-selected id");
550 }
551
552 #[test]
553 fn drive_returns_effects_and_snapshot_json_serializes_state() {
554 let mut c = Counter::new();
555 let effects = drive(&mut c, [CounterMsg::Inc, CounterMsg::Add(3)]);
556 assert!(effects.is_empty(), "FC-8: this component does no I/O");
557 let js = snapshot_json(&mut c, [CounterMsg::Inc]);
558 assert_eq!(js["count"], 5, "1+3+1 = 5, observable as JSON");
559 assert_eq!(js["selected"], serde_json::Value::Null);
560 }
561
562 #[test]
563 fn macro_bridges_elm_to_facet() {
564 let mut c = Counter::new();
566 drive(&mut c, [CounterMsg::Add(9)]);
567 assert_eq!(Facet::title(&c), "counter");
568 assert_eq!(Facet::state_json(&c), state_json(&c), "macro state_json == serde(state())");
569 assert_eq!(Facet::state_json(&c)["count"], 9);
570 let r = headless_render(&mut c);
572 assert_eq!(r.title, "counter");
573 assert_eq!(r.state["count"], 9);
574 assert!(r.drew(), "the label tessellates to vertices");
575 }
576
577 #[test]
578 fn proptest_style_msg_sequences_keep_state_wellformed() {
579 let mut rng: u64 = 0x9E3779B97F4A7C15;
584 let mut next = || {
585 rng ^= rng << 13;
586 rng ^= rng >> 7;
587 rng ^= rng << 17;
588 rng
589 };
590 for _ in 0..500 {
591 let mut c = Counter::new();
592 c.state.count = (next() % 21) as i64 - 10; let mut expected = c.state.count;
594 let mut expect_sel: Option<String> = None;
595 c.state.selected = None;
596 let len = (next() % 12) as usize;
597 let msgs: Vec<CounterMsg> = (0..len)
598 .map(|_| match next() % 4 {
599 0 => {
600 expected += 1;
601 CounterMsg::Inc
602 }
603 1 => {
604 expected -= 1;
605 CounterMsg::Dec
606 }
607 2 => {
608 let n = (next() % 7) as i64 - 3;
609 expected += n;
610 CounterMsg::Add(n)
611 }
612 _ => {
613 let id = format!("id{}", next() % 3);
614 let new = Some(id.clone());
615 expect_sel = if expect_sel == new { None } else { new };
616 CounterMsg::Select(Some(id))
617 }
618 })
619 .collect();
620
621 let snap = snapshot(&mut c, msgs);
622 assert_eq!(snap.count, expected);
624 assert_eq!(snap.selected, expect_sel);
626 let json = serde_json::to_string(&snap).unwrap();
628 let back: CounterState = serde_json::from_str(&json).unwrap();
629 assert_eq!(back, snap);
630 }
631 }
632
633 struct ListPane {
638 items: Vec<String>,
639 }
640 impl Facet for ListPane {
641 fn title(&self) -> &str {
642 "listpane"
643 }
644 fn ui(&mut self, ui: &mut egui::Ui) {
645 for it in &self.items {
646 ui.label(it);
647 }
648 }
649 fn state_json(&self) -> serde_json::Value {
650 serde_json::json!({ "items": self.items })
651 }
652 fn update_json(&mut self, msg_json: &str) {
653 let v: serde_json::Value = match serde_json::from_str(msg_json) {
654 Ok(v) => v,
655 Err(_) => return,
656 };
657 if let Some(s) = v.get("push").and_then(|x| x.as_str()) {
658 self.items.push(s.to_string());
659 }
660 if v.get("clear").and_then(|x| x.as_bool()) == Some(true) {
661 self.items.clear();
662 }
663 }
664 }
665
666 #[test]
667 fn probe_facet_captures_render_and_transitions() {
668 let mut pane = ListPane { items: vec!["seed".into()] };
669 let probe = probe_facet(&mut pane, &[r#"{"push":"a"}"#, r#"{"push":"b"}"#, r#"{"clear":true}"#]);
670 assert_eq!(probe.title, "listpane");
671 assert!(probe.drew(), "a label list should tessellate to vertices");
672 assert_eq!(probe.initial_state["items"].as_array().unwrap().len(), 1);
673 assert_eq!(probe.steps.len(), 3);
674 assert!(probe.steps.iter().all(|s| s.changed), "each push/clear mutates state");
676 assert!(probe.responded());
677 assert_eq!(probe.final_state["items"].as_array().unwrap().len(), 0);
679 }
680
681 #[test]
682 fn probe_facet_no_msgs_is_pure_render() {
683 let mut pane = ListPane { items: vec!["x".into(), "y".into()] };
684 let probe = probe_facet(&mut pane, &[]);
685 assert!(probe.steps.is_empty());
686 assert!(!probe.responded(), "no messages → nothing to respond to");
687 assert_eq!(probe.final_state, probe.initial_state);
688 assert_eq!(probe.cardinality(), 2, "two items → cardinality 2");
689 }
690
691 #[test]
692 fn probe_flags_a_dead_input_surface() {
693 let mut scene = Scene::new();
697 scene.node("n", hash_color("n"));
698 let mut t = Tiny(scene);
699 let probe = probe_facet(&mut t, &[r#"{"anything":1}"#]);
700 assert_eq!(probe.steps.len(), 1);
701 assert!(!probe.responded(), "a no-op update_json must not register as responsive");
702 }
703
704 #[test]
705 fn json_cardinality_finds_the_largest_collection() {
706 assert_eq!(json_cardinality(&serde_json::json!({"rows": [1, 2, 3, 4]})), 4);
707 assert_eq!(json_cardinality(&serde_json::json!([1, 2])), 2);
708 assert_eq!(json_cardinality(&serde_json::json!({"a": 1, "b": 2})), 2, "no arrays → key count");
709 assert_eq!(json_cardinality(&serde_json::json!(null)), 0);
710 assert_eq!(
711 json_cardinality(&serde_json::json!({"outer": {"inner": [1, 2, 3, 4, 5]}})),
712 5,
713 "reaches nested arrays"
714 );
715 }
716}