Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
}