use crate::Facet;
#[derive(Debug, Clone)]
pub struct RenderReport {
pub title: String,
pub state: serde_json::Value,
pub vertices: usize,
}
impl RenderReport {
pub fn drew(&self) -> bool {
self.vertices > 0
}
}
#[allow(deprecated)] fn capture(ctx: &egui::Context, facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
let title = facet.title().to_string();
crate::trace::emit_in(
"facet.render",
&serde_json::json!({ "title": title, "size": [size.0, size.1] }),
);
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(size.0, size.1))),
..Default::default()
};
let output = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| facet.ui(ui));
});
let prims = ctx.tessellate(output.shapes, output.pixels_per_point);
let vertices = prims
.iter()
.map(|p| match &p.primitive {
egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
_ => 0,
})
.sum();
let report = RenderReport { title, state: facet.state_json(), vertices };
log(&report);
trail(Kind::Render, format!("{} size={}x{} → {} verts", report.title, size.0 as i32, size.1 as i32, vertices));
dump_state(&report);
crate::trace::emit_out(
"facet.render",
&serde_json::json!({
"title": report.title,
"vertices": report.vertices,
"drew": report.drew(),
"state": report.state,
}),
);
report
}
pub fn render_sized(facet: &mut dyn Facet, size: (f32, f32)) -> RenderReport {
capture(&egui::Context::default(), facet, size)
}
pub fn headless_render(facet: &mut dyn Facet) -> RenderReport {
render_sized(facet, (800.0, 600.0))
}
pub fn render_themed(facet: &mut dyn Facet, theme: crate::Theme) -> RenderReport {
let ctx = egui::Context::default();
crate::set_theme(&ctx, theme);
capture(&ctx, facet, (800.0, 600.0))
}
pub fn render_themed_sized(facet: &mut dyn Facet, theme: crate::Theme, size: (f32, f32)) -> RenderReport {
let ctx = egui::Context::default();
crate::set_theme(&ctx, theme);
capture(&ctx, facet, size)
}
pub fn log(r: &RenderReport) {
let full = r.state.to_string();
let shown: String = if full.chars().count() > 160 {
full.chars().take(159).chain(std::iter::once('…')).collect()
} else {
full
};
eprintln!("facett: {:<14} {:>7} verts · {}", r.title, r.vertices, shown);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Render,
State,
Case,
}
impl Kind {
pub fn tag(self) -> &'static str {
match self {
Kind::Render => "RENDER",
Kind::State => "STATE",
Kind::Case => "CASE",
}
}
}
fn next_seq() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
SEQ.fetch_add(1, Ordering::Relaxed) + 1
}
fn now_stamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_ms = now.as_millis();
let ms = (total_ms % 1000) as u64;
let secs = (total_ms / 1000) as u64;
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
}
pub fn trail(kind: Kind, detail: impl AsRef<str>) {
let stamp = now_stamp();
let seq = next_seq();
let detail = detail.as_ref();
let line = format!("facett ACTION {stamp} {seq:>5} [{}] {detail}", kind.tag());
eprintln!("{line}");
if let Ok(path) = std::env::var("FACETT_TRAIL") {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{line}");
}
}
}
pub fn dump_state(r: &RenderReport) {
eprintln!("facett STATE {} = {}", r.title, r.state);
trail(Kind::State, format!("{} state={}", r.title, r.state));
}
pub fn case_summary(component: &str, axis: &str, r: &RenderReport) {
eprintln!(
"facett CASE {:<14} {:<16} → {:>8} verts drew={} state={}",
component,
axis,
r.vertices,
r.drew(),
r.state,
);
trail(Kind::Case, format!("{component} {axis} verts={} drew={}", r.vertices, r.drew()));
}
pub fn drive<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> Vec<C::Effect> {
let mut effects = Vec::new();
for m in msgs {
effects.extend(component.update(m));
}
effects
}
pub fn snapshot<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> C::Model {
drive(component, msgs);
component.state().clone()
}
pub fn snapshot_json<C: crate::Elm>(component: &mut C, msgs: impl IntoIterator<Item = C::Msg>) -> serde_json::Value {
drive(component, msgs);
state_json(component)
}
pub fn state_json<C: crate::Elm>(component: &C) -> serde_json::Value {
serde_json::to_value(component.state()).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Scene, hash_color};
struct Tiny(Scene);
impl Facet for Tiny {
fn title(&self) -> &str {
"tiny"
}
fn ui(&mut self, ui: &mut egui::Ui) {
crate::draw(ui, &self.0, crate::Layout::Circular, "empty");
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "nodes": self.0.nodes.len() })
}
}
#[test]
fn now_stamp_is_hms_millis_shaped() {
let s = now_stamp();
assert_eq!(s.len(), 12, "stamp `{s}` should be HH:MM:SS.mmm");
assert_eq!(s.matches(':').count(), 2, "stamp `{s}` needs two colons");
assert_eq!(s.matches('.').count(), 1, "stamp `{s}` needs one dot");
}
#[test]
fn seq_is_monotonic() {
let a = next_seq();
let b = next_seq();
assert!(b > a, "seq must strictly increase: {a} then {b}");
}
#[test]
fn kind_tags_are_distinct() {
let tags = [Kind::Render.tag(), Kind::State.tag(), Kind::Case.tag()];
for (i, t) in tags.iter().enumerate() {
assert!(!t.is_empty());
assert!(!tags[..i].contains(t), "duplicate tag {t}");
}
}
#[test]
fn headless_render_captures_state_and_draws() {
let mut scene = Scene::new();
let a = scene.node("a", hash_color("a"));
let b = scene.node("b", hash_color("b"));
scene.edge(a, b);
let mut t = Tiny(scene);
let r = headless_render(&mut t);
assert_eq!(r.title, "tiny");
assert_eq!(r.state["nodes"], 2);
assert!(r.drew(), "a 2-node graph should tessellate to vertices");
}
use crate::Elm;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct CounterState {
count: i64,
selected: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
enum CounterMsg {
Inc,
Dec,
Add(i64),
Select(Option<String>),
}
#[derive(Debug)]
enum CounterEffect {}
struct Counter {
title: String,
state: CounterState,
}
impl Counter {
fn new() -> Self {
Self { title: "counter".into(), state: CounterState { count: 0, selected: None } }
}
}
impl Elm for Counter {
type Model = CounterState;
type Msg = CounterMsg;
type Effect = CounterEffect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &CounterState {
&self.state
}
fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
match msg {
CounterMsg::Inc => self.state.count += 1,
CounterMsg::Dec => self.state.count -= 1,
CounterMsg::Add(n) => self.state.count += n,
CounterMsg::Select(id) => {
self.state.selected = if self.state.selected == id { None } else { id };
}
}
Vec::new()
}
fn view(&self, ui: &mut egui::Ui) -> Vec<CounterMsg> {
ui.label(format!("count = {}", self.state.count));
Vec::new()
}
}
crate::impl_facet_via_elm!(Counter);
struct RichCounter {
state: CounterState,
}
impl Elm for RichCounter {
type Model = CounterState;
type Msg = CounterMsg;
type Effect = CounterEffect;
fn title(&self) -> &str {
"rich"
}
fn state(&self) -> &CounterState {
&self.state
}
fn update(&mut self, msg: CounterMsg) -> Vec<CounterEffect> {
if let CounterMsg::Add(n) = msg {
self.state.count += n;
}
Vec::new()
}
fn view(&self, _ui: &mut egui::Ui) -> Vec<CounterMsg> {
Vec::new()
}
}
crate::impl_facet_via_elm!(RichCounter, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "count": self.state.count, "parity": self.state.count % 2 })
}
});
#[test]
fn form3_custom_state_json_overrides_the_default() {
use crate::Facet;
let mut c = RichCounter { state: CounterState { count: 0, selected: None } };
let _ = c.update(CounterMsg::Add(7));
let j = Facet::state_json(&c);
assert_eq!(j["count"], 7, "custom state_json is emitted");
assert_eq!(j["parity"], 1, "the DERIVED key the default serde dump omits is present");
assert_eq!(Facet::title(&c), "rich");
}
#[test]
fn harness_drives_msgs_and_snapshots_state() {
let mut c = Counter::new();
let snap = snapshot(
&mut c,
[CounterMsg::Inc, CounterMsg::Inc, CounterMsg::Add(5), CounterMsg::Dec],
);
assert_eq!(snap.count, 6, "1+1+5-1 = 6");
assert_eq!(c.state().count, 6, "the component holds the driven state");
let snap = snapshot(&mut c, [CounterMsg::Select(Some("a".into())), CounterMsg::Select(Some("a".into()))]);
assert_eq!(snap.selected, None, "toggle clears the re-selected id");
}
#[test]
fn drive_returns_effects_and_snapshot_json_serializes_state() {
let mut c = Counter::new();
let effects = drive(&mut c, [CounterMsg::Inc, CounterMsg::Add(3)]);
assert!(effects.is_empty(), "FC-8: this component does no I/O");
let js = snapshot_json(&mut c, [CounterMsg::Inc]);
assert_eq!(js["count"], 5, "1+3+1 = 5, observable as JSON");
assert_eq!(js["selected"], serde_json::Value::Null);
}
#[test]
fn macro_bridges_elm_to_facet() {
let mut c = Counter::new();
drive(&mut c, [CounterMsg::Add(9)]);
assert_eq!(Facet::title(&c), "counter");
assert_eq!(Facet::state_json(&c), state_json(&c), "macro state_json == serde(state())");
assert_eq!(Facet::state_json(&c)["count"], 9);
let r = headless_render(&mut c);
assert_eq!(r.title, "counter");
assert_eq!(r.state["count"], 9);
assert!(r.drew(), "the label tessellates to vertices");
}
#[test]
fn proptest_style_msg_sequences_keep_state_wellformed() {
let mut rng: u64 = 0x9E3779B97F4A7C15;
let mut next = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for _ in 0..500 {
let mut c = Counter::new();
c.state.count = (next() % 21) as i64 - 10; let mut expected = c.state.count;
let mut expect_sel: Option<String> = None;
c.state.selected = None;
let len = (next() % 12) as usize;
let msgs: Vec<CounterMsg> = (0..len)
.map(|_| match next() % 4 {
0 => {
expected += 1;
CounterMsg::Inc
}
1 => {
expected -= 1;
CounterMsg::Dec
}
2 => {
let n = (next() % 7) as i64 - 3;
expected += n;
CounterMsg::Add(n)
}
_ => {
let id = format!("id{}", next() % 3);
let new = Some(id.clone());
expect_sel = if expect_sel == new { None } else { new };
CounterMsg::Select(Some(id))
}
})
.collect();
let snap = snapshot(&mut c, msgs);
assert_eq!(snap.count, expected);
assert_eq!(snap.selected, expect_sel);
let json = serde_json::to_string(&snap).unwrap();
let back: CounterState = serde_json::from_str(&json).unwrap();
assert_eq!(back, snap);
}
}
}