facett-card/src/tests.rs
//! Unit + headless tests for facett-card. Builds a surveillance "issue" card with
//! a typed field grid and nested sub-task cards, then asserts: the read render
//! produces the expected `state_json`; sub-cards nest (children/descendant
//! counts); `copy()` emits JSON containing the nested children; scale changes
//! propagate; and edit mode reuses facett-form.
use super::*;
use facett_core::{Elm, Facet, Theme, harness};
/// A representative parent issue: status enum + assignee + priority + dates +
/// tags + progress, with three nested sub-task cards.
fn issue_card() -> Card {
let sub = |id: &str, title: &str, state: &str, prog: f32| {
Card::new(title)
.with_id(id)
.field(CardField::new(
"Status",
FieldValue::Enum {
state: state.into(),
options: vec!["open".into(), "in_progress".into(), "done".into()],
},
))
.field(CardField::new("Progress", FieldValue::Progress(prog)))
};
Card::new("Investigate vessel Nordkapp")
.with_id("CASE-42")
.field(CardField::new(
"Status",
FieldValue::Enum {
state: "escalated".into(),
options: vec!["open".into(), "escalated".into(), "closed".into()],
},
))
.field(CardField::new("Assignee", FieldValue::User("hugin".into())))
.field(CardField::new("Priority", FieldValue::Number(2.0)))
.field(CardField::new("Opened", FieldValue::Date("2026-06-01".into())))
.field(CardField::new("Due", FieldValue::Date("2026-06-30".into())))
.field(CardField::new(
"Tags",
FieldValue::Tags(vec!["ais-gap".into(), "port-call".into()]),
))
.field(CardField::new("Progress", FieldValue::Progress(0.4)))
.field(CardField::new(
"Ticket",
FieldValue::Link { label: "JIRA-42".into(), url: "https://example.test/CASE-42".into() },
))
.child(sub("CASE-42-1", "Pull AIS track", "done", 1.0))
.child(sub("CASE-42-2", "Cross-ref port calls", "in_progress", 0.5))
.child(sub("CASE-42-3", "Draft report", "open", 0.0))
}
#[test]
fn field_kind_is_inferred_from_value() {
assert_eq!(CardField::new("x", FieldValue::Text("a".into())).kind, FieldKind::Text);
assert_eq!(CardField::new("x", FieldValue::Number(1.0)).kind, FieldKind::Number);
assert_eq!(CardField::new("x", FieldValue::User("u".into())).kind, FieldKind::User);
assert_eq!(CardField::new("x", FieldValue::Progress(0.5)).kind, FieldKind::Progress);
assert_eq!(
CardField::new("x", FieldValue::Enum { state: "s".into(), options: vec![] }).kind,
FieldKind::Enum
);
}
#[test]
fn sub_cards_nest_with_counts() {
let card = issue_card();
assert_eq!(card.children.len(), 3, "three direct sub-tasks");
assert_eq!(card.descendant_count(), 3, "no grandchildren");
// Add a grandchild and re-check the recursive count.
let mut deep = card.clone();
deep.children[0].children.push(Card::new("nested step").with_id("CASE-42-1-a"));
assert_eq!(deep.children.len(), 3);
assert_eq!(deep.descendant_count(), 4, "one grandchild added");
}
#[test]
fn read_render_produces_expected_state_json() {
let mut view = CardView::new("Issue", issue_card());
let r = harness::headless_render(&mut view);
assert_eq!(r.title, "Issue");
assert!(r.drew(), "the card tessellates to vertices");
assert_eq!(r.state["editing"], false, "starts in read mode");
assert_eq!(r.state["scale"], 1.0);
let card = &r.state["card"];
assert_eq!(card["id"], "CASE-42");
assert_eq!(card["title"], "Investigate vessel Nordkapp");
assert_eq!(card["children_count"], 3);
assert_eq!(card["descendant_count"], 3);
// The field grid is reflected: status enum + its display value.
let fields = card["fields"].as_array().unwrap();
assert_eq!(fields.len(), 8, "eight fields on the parent");
let status = fields.iter().find(|f| f["label"] == "Status").unwrap();
assert_eq!(status["kind"], "enum");
assert_eq!(status["value"], "escalated");
let progress = fields.iter().find(|f| f["label"] == "Progress").unwrap();
assert_eq!(progress["kind"], "progress");
assert_eq!(progress["value"], "40%");
}
#[test]
fn copy_emits_json_with_nested_children() {
let mut view = CardView::new("Issue", issue_card());
let text = view.copy().expect("a card copies");
let v: serde_json::Value = serde_json::from_str(&text).expect("copy() emits valid JSON");
assert_eq!(v["id"], "CASE-42");
// Nested children are present in the copied payload, with their own fields.
let kids = v["children"].as_array().expect("children array in copy");
assert_eq!(kids.len(), 3);
assert_eq!(kids[0]["id"], "CASE-42-1");
assert_eq!(kids[0]["title"], "Pull AIS track");
// The sub-card's status field round-trips its typed value.
let sub_status = kids[0]["fields"].as_array().unwrap()[0].clone();
assert_eq!(sub_status["label"], "Status");
assert_eq!(sub_status["value"]["type"], "enum");
assert_eq!(sub_status["value"]["value"]["state"], "done");
}
#[test]
fn copy_round_trips_through_paste() {
let mut view = CardView::new("Issue", issue_card());
let blob = view.copy().unwrap();
let mut other = CardView::new("Other", Card::new("placeholder"));
assert!(other.paste(&blob), "valid card JSON paste is consumed");
assert_eq!(other.card().id.as_deref(), Some("CASE-42"));
assert_eq!(other.card().children.len(), 3);
// A re-copy equals the original payload.
assert_eq!(
serde_json::from_str::<serde_json::Value>(&other.copy().unwrap()).unwrap(),
serde_json::from_str::<serde_json::Value>(&blob).unwrap(),
);
}
#[test]
fn paste_rejects_garbage() {
let mut view = CardView::new("Issue", issue_card());
assert!(!view.paste("not json at all"), "garbage paste rejected");
assert_eq!(view.card().id.as_deref(), Some("CASE-42"), "card unchanged");
}
#[test]
fn scale_changes_propagate() {
let mut view = CardView::new("Issue", issue_card());
assert_eq!(view.scale(), 1.0);
view.set_scale(1.5);
assert_eq!(view.scale(), 1.5);
// Clamped to the sane range.
view.set_scale(99.0);
assert_eq!(view.scale(), 4.0);
view.set_scale(0.01);
assert_eq!(view.scale(), 0.25);
// The scale is reflected in state_json after a render.
view.set_scale(2.0);
let r = harness::headless_render(&mut view);
assert_eq!(r.state["scale"], 2.0);
// …and it reaches the PIXELS. Everything above is a state round-trip: it passes
// unchanged if `font.size *= self.state.scale` is deleted from the painter
// outright (measured — the whole facett-card suite stayed green under exactly
// that mutation, as did facett-console/-filedialog/-git/-security/-table).
// `drew()` cannot see it either: a glyph is four vertices at 6pt and at 60pt.
// So measure the glyph AREA the painter really emitted.
let ink = |s: f32| {
let mut v = CardView::new("Issue", issue_card());
v.set_scale(s);
harness::headless_render(&mut v).textured_ink_area
};
let (small, big) = (ink(1.0), ink(2.0));
assert!(small > 0.0, "the card draws text at 1.0× ({small})");
assert!(
big > small * 2.0,
"doubling the scale must roughly QUADRUPLE the glyph ink (each glyph quad \
doubles in both axes), got {small:.0} → {big:.0} pt² ({:.2}×). A ratio near \
1.0 means the scale knob is stored, clamped and serialised but never applied \
to anything drawn.",
big / small,
);
}
#[test]
fn caps_declare_card_surface() {
let view = CardView::new("Issue", issue_card());
let c = view.caps();
assert!(c.selectable && c.copyable && c.pasteable && c.themeable && c.scalable);
assert!(!c.cuttable, "a card is not a deletable selection");
}
#[test]
fn edit_mode_renders_form_and_toggles() {
let mut view = CardView::new("Issue", issue_card());
assert!(!view.is_editing());
view.set_editing(true);
assert!(view.is_editing());
// In edit mode the card renders through the facett-form driver and still draws.
let r = harness::headless_render(&mut view);
assert_eq!(r.state["editing"], true);
assert!(r.drew(), "the edit form tessellates to vertices");
}
#[test]
fn themed_read_render_draws() {
let mut view = CardView::new("Issue", issue_card());
let r = harness::render_themed(&mut view, Theme::hugin_noir());
assert!(r.drew(), "themed card draws (chips/avatars/bars use the palette)");
assert_eq!(r.state["card"]["children_count"], 3);
// The name claims the palette reaches the chips/avatars/bars, but `drew()` cannot
// see colour — with `set_theme` deleted this test still passed. Assert the theme
// actually changed the ink.
let mut plain = CardView::new("Issue", issue_card());
let base = harness::headless_render(&mut plain);
assert_ne!(
r.ink_signature(),
base.ink_signature(),
"hugin_noir painted the card exactly like the default palette ({:?} vs {:?}) — \
the theme never reached the paint",
r.ink_colors,
base.ink_colors,
);
}
#[test]
fn empty_card_renders_without_panic() {
let mut view = CardView::new("Empty", Card::default());
let r = harness::headless_render(&mut view);
assert_eq!(r.state["card"]["children_count"], 0);
assert_eq!(r.state["card"]["fields"].as_array().unwrap().len(), 0);
}
#[test]
fn typed_copy_and_paste_round_trip_the_card_json() {
use facett_core::clip::{ClipKind, CopySource, PasteTarget};
let src = CardView::new("Issue", issue_card());
let p = src.copy_payload().expect("a card copies as JSON");
assert_eq!(p.kind(), ClipKind::Text);
// Paste the typed payload into a different card view -> it adopts the JSON.
let mut dst = CardView::new("Other", Card::new("placeholder"));
assert!(dst.accepts(ClipKind::Text));
dst.paste_payload(&p);
assert_eq!(dst.card().title, src.card().title, "pasted card adopts the source title");
assert_eq!(dst.card().fields.len(), src.card().fields.len());
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the Elm contract, driven headlessly ────────────
#[test]
fn msgs_drive_editing_and_scale_transitions() {
// FC-2 → FC-3: apply a Vec<Msg> with no egui/GPU, snapshot the Model.
let mut view = CardView::new("Issue", issue_card());
// ToggleEdit flips read⇄edit; a second toggle returns to read.
let snap = harness::snapshot(&mut view, [Msg::ToggleEdit]);
assert!(snap.editing, "one ToggleEdit enters edit mode");
let snap = harness::snapshot(&mut view, [Msg::ToggleEdit, Msg::SetEditing(true)]);
assert!(snap.editing, "SetEditing(true) is explicit and idempotent");
// SetScale is clamped to the sane 0.25..=4.0 range in `update`.
let snap = harness::snapshot(&mut view, [Msg::SetScale(2.5)]);
assert_eq!(snap.scale, 2.5);
let snap = harness::snapshot(&mut view, [Msg::SetScale(99.0)]);
assert_eq!(snap.scale, 4.0, "clamped at the top");
let snap = harness::snapshot(&mut view, [Msg::SetScale(0.0)]);
assert_eq!(snap.scale, 0.25, "clamped at the bottom");
}
#[test]
fn edit_card_and_save_move_card_and_baseline() {
// EditCard mutates the live card but preserves the clean baseline (so the
// form's dirty-marker keeps working); Save then commits baseline = card.
let mut view = CardView::new("Issue", issue_card());
let mut edited = issue_card();
edited.title = Some("Renamed case".into());
let snap = harness::snapshot(&mut view, [Msg::EditCard(edited.clone())]);
assert_eq!(snap.card.title.as_deref(), Some("Renamed case"), "live card took the edit");
assert_eq!(snap.baseline.title.as_deref(), Some("Investigate vessel Nordkapp"), "baseline stays clean → still dirty");
assert_ne!(snap.card, snap.baseline, "the edit is unsaved");
let snap = harness::snapshot(&mut view, [Msg::Save]);
assert_eq!(snap.card, snap.baseline, "Save commits: baseline == card");
}
#[test]
fn set_card_replaces_and_starts_clean() {
// Paste-style wholesale replace: card AND baseline adopt the new card.
let mut view = CardView::new("Issue", issue_card());
let fresh = Card::new("Fresh").with_id("NEW-1");
let snap = harness::snapshot(&mut view, [Msg::SetCard(fresh.clone())]);
assert_eq!(snap.card.id.as_deref(), Some("NEW-1"));
assert_eq!(snap.card, snap.baseline, "a set/pasted card starts clean (not dirty)");
}
#[test]
fn drive_produces_no_effects() {
// FC-8: a card does no I/O — `Effect` is uninhabited, so `drive` is always empty.
let mut view = CardView::new("Issue", issue_card());
let effects: Vec<Effect> = harness::drive(
&mut view,
[Msg::ToggleEdit, Msg::SetScale(1.5), Msg::EditCard(Card::new("x")), Msg::Save],
);
assert!(effects.is_empty(), "no effects: a card is pure state");
}
#[test]
fn model_serde_round_trips() {
// FC-3: the Model is Serialize + DeserializeOwned + Clone + PartialEq.
let mut view = CardView::new("Issue", issue_card());
let _ = view.update(Msg::ToggleEdit);
let _ = view.update(Msg::SetScale(1.75));
let model = view.state().clone();
let json = serde_json::to_string(&model).expect("Model serializes");
let back: CardState = serde_json::from_str(&json).expect("Model round-trips");
assert_eq!(model, back, "the Model survives a JSON round-trip byte-for-byte");
assert!(back.editing);
assert_eq!(back.scale, 1.75);
}
#[test]
fn snapshot_json_serializes_the_model() {
// The plain-serde Model snapshot (distinct from the richer Facet::state_json).
let mut view = CardView::new("Issue", issue_card());
let js = harness::snapshot_json(&mut view, [Msg::SetEditing(true)]);
assert_eq!(js["editing"], true);
assert_eq!(js["card"]["id"], "CASE-42");
}