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

nordisk/facett

facett-form/src/tests.rs

//! Unit + headless tests for facett-form. A small "Case" admin record exercises
//! validation rejection, dirty-tracking, the JSON copy round-trip, and headless
//! rendering through the facett harness (`state_json` dirty/valid/errors).

use super::*;
use facett_core::{Facet, harness};
use serde::{Deserialize, Serialize};

/// A representative admin record: a surveillance "Case".
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, garde::Validate)]
struct Case {
    #[garde(length(min = 1))]
    title: String,
    #[garde(skip)]
    status: String, // open | closed | escalated
    #[garde(length(min = 1))]
    assignee: String,
    #[garde(custom(crate::valid_ymd))]
    due: String,
    #[garde(skip)]
    notes: String,
    #[garde(range(min = 0, max = 5))]
    priority: i64,
    #[garde(skip)]
    open: bool,
}

impl Default for Case {
    fn default() -> Self {
        Self {
            title: "Investigate vessel".into(),
            status: "open".into(),
            assignee: "hugin".into(),
            due: "2026-06-30".into(),
            notes: String::new(),
            priority: 2,
            open: true,
        }
    }
}

impl FormModel for Case {
    fn fields(&mut self, f: &mut FormUi<'_>) {
        f.section("Case", |f| {
            f.text("title", "Title", &mut self.title);
            f.select("status", "Status", &mut self.status, &["open", "closed", "escalated"]);
            f.text("assignee", "Assignee", &mut self.assignee);
            f.date("due", "Due", &mut self.due);
            f.number_i64("priority", "Priority", &mut self.priority, Some(0..=5));
            f.checkbox("open", "Open", &mut self.open);
        });
        f.section("Notes", |f| {
            f.multiline("notes", "Notes", &mut self.notes);
        });
    }
    fn validate(&self) -> ValidationReport {
        ValidationReport::from_garde(garde::Validate::validate(self))
    }
    fn to_clip(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_default()
    }
    fn from_clip(&mut self, text: &str) -> bool {
        match serde_json::from_str::<Case>(text) {
            Ok(m) => {
                *self = m;
                true
            }
            Err(_) => false,
        }
    }
}

#[test]
fn valid_default_model_passes() {
    let r = Case::default().validate();
    assert!(r.is_empty(), "default Case should validate clean, got {:?}", r.to_json());
}

#[test]
fn validation_rejects_bad_input() {
    let c = Case {
        title: String::new(), // length(min=1) fails
        priority: 99, // range(0..=5) fails
        due: "30-06-2026".into(), // not YYYY-MM-DD
        ..Default::default()
    };
    let r = c.validate();
    assert!(!r.is_empty(), "bad model must produce errors");
    assert!(r.field_error("title").is_some(), "empty title flagged");
    assert!(r.field_error("priority").is_some(), "out-of-range priority flagged");
    assert!(r.field_error("due").is_some(), "malformed date flagged");
    // The errors map surfaces the offending field ids for headless assertions.
    let j = r.to_json();
    assert!(j.get("title").is_some());
    assert!(j.get("priority").is_some());
    assert!(j.get("due").is_some());
}

#[test]
fn valid_ymd_accepts_and_rejects() {
    assert!(valid_ymd("2026-06-12", &()).is_ok());
    assert!(valid_ymd("2026-13-01", &()).is_err(), "month 13 invalid");
    assert!(valid_ymd("2026-06-40", &()).is_err(), "day 40 invalid");
    assert!(valid_ymd("2026/06/12", &()).is_err(), "wrong separators");
    assert!(valid_ymd("bad", &()).is_err());
}

#[test]
fn dirty_tracking_flips_on_edit() {
    let mut form = FormView::new("New Case", Case::default());
    assert!(!form.is_dirty(), "fresh form is clean");
    form.model_mut().title = "Changed".into();
    assert!(form.is_dirty(), "editing the model marks it dirty");
    form.reset();
    assert!(!form.is_dirty(), "reset returns to baseline");
}

#[test]
fn mark_clean_after_external_persist() {
    let mut form = FormView::new("New Case", Case::default());
    form.model_mut().assignee = "munin".into();
    assert!(form.is_dirty());
    form.mark_clean();
    assert!(!form.is_dirty(), "mark_clean adopts the current model as baseline");
}

#[test]
fn copy_emits_model_json() {
    let mut form = FormView::new("New Case", Case::default());
    let text = form.copy().expect("a non-empty model copies");
    let v: serde_json::Value = serde_json::from_str(&text).expect("copy() emits valid JSON");
    assert_eq!(v["title"], "Investigate vessel");
    assert_eq!(v["assignee"], "hugin");
    assert_eq!(v["priority"], 2);
}

#[test]
fn paste_round_trips_the_model() {
    let mut form = FormView::new("New Case", Case::default());
    // Build a different model, serialize it, paste it in.
    let other = Case {
        title: "Audit ledger".into(),
        assignee: "munin".into(),
        priority: 4,
        ..Default::default()
    };
    let blob = other.to_clip();

    assert!(form.paste(&blob), "valid JSON paste is consumed");
    assert_eq!(form.model().title, "Audit ledger");
    assert_eq!(form.model().assignee, "munin");
    assert_eq!(form.model().priority, 4);
    // Round-trip: copy of the pasted model equals what we pasted.
    let recopied = form.copy().unwrap();
    assert_eq!(
        serde_json::from_str::<serde_json::Value>(&recopied).unwrap(),
        serde_json::from_str::<serde_json::Value>(&blob).unwrap(),
    );
}

#[test]
fn paste_rejects_garbage() {
    let mut form = FormView::new("New Case", Case::default());
    assert!(!form.paste("this is not json"), "garbage paste is rejected");
    assert_eq!(form.model().title, "Investigate vessel", "model unchanged after bad paste");
}

#[test]
fn caps_declares_form_surface() {
    let form = FormView::new("New Case", Case::default());
    let c = form.caps();
    assert!(c.copyable && c.pasteable && c.cuttable, "form is copy/cut/paste-able");
    assert!(c.selectable && c.searchable && c.themeable);
    assert!(!c.scalable, "form does not declare scaling");
}

#[test]
fn cut_does_not_clear_the_form() {
    let mut form = FormView::new("New Case", Case::default());
    let before = form.model().clone();
    let cut = form.cut().expect("cut delegates to copy");
    assert!(!cut.is_empty());
    assert_eq!(form.model(), &before, "cut must not clear a form");
}

#[test]
fn take_submitted_only_after_save() {
    let mut form = FormView::new("New Case", Case::default());
    assert!(form.take_submitted().is_none(), "nothing submitted yet");
}

#[test]
fn headless_render_reports_clean_state() {
    let mut form = FormView::new("New Case", Case::default());
    let r = harness::headless_render(&mut form);
    assert_eq!(r.title, "New Case");
    assert!(r.drew(), "the form tessellates to vertices");
    assert_eq!(r.state["dirty"], false);
    assert_eq!(r.state["valid"], true);
    assert_eq!(r.state["errors"].as_object().unwrap().len(), 0);
    assert_eq!(r.state["submitted"], false);
}

#[test]
fn headless_render_surfaces_validation_errors() {
    let bad = Case {
        title: String::new(),
        priority: 42,
        ..Default::default()
    };
    let mut form = FormView::new("Bad Case", bad);
    let r = harness::headless_render(&mut form);
    assert_eq!(r.state["valid"], false);
    let errs = r.state["errors"].as_object().unwrap();
    assert!(errs.contains_key("title"), "title error in state_json");
    assert!(errs.contains_key("priority"), "priority error in state_json");
}

#[test]
fn headless_render_after_paste_marks_dirty() {
    let mut form = FormView::new("New Case", Case::default());
    let edited = Case { title: "Edited".into(), ..Default::default() };
    assert!(form.paste(&edited.to_clip()));
    let r = harness::headless_render(&mut form);
    assert_eq!(r.state["dirty"], true, "a pasted-in different model is dirty vs baseline");
}

#[test]
fn snapshot_captures_every_field_with_its_section() {
    let form = FormView::new("New Case", Case::default());
    let snap = form.snapshot();
    assert_eq!(snap.title, "New Case");
    assert!(snap.valid, "the default Case is valid");
    // Every declared field is captured, in layout order, tagged with its section.
    let ids: Vec<&str> = snap.fields.iter().map(|f| f.id.as_str()).collect();
    assert_eq!(ids, ["title", "status", "assignee", "due", "priority", "open", "notes"]);
    let title = snap.fields.iter().find(|f| f.id == "title").unwrap();
    assert_eq!(title.value, "Investigate vessel");
    assert_eq!(title.section.as_deref(), Some("Case"));
    let notes = snap.fields.iter().find(|f| f.id == "notes").unwrap();
    assert_eq!(notes.section.as_deref(), Some("Notes"));
    // The bool checkbox flattens to yes/no; the number stringifies.
    assert_eq!(snap.fields.iter().find(|f| f.id == "open").unwrap().value, "yes");
    assert_eq!(snap.fields.iter().find(|f| f.id == "priority").unwrap().value, "2");
}

#[test]
fn to_pdf_produces_pdf_bytes_for_the_form_state() {
    let form = FormView::new("New Case", Case::default());
    let bytes = form.to_pdf();
    assert!(!bytes.is_empty(), "the form renders to non-empty PDF bytes");
    assert!(bytes.starts_with(b"%PDF-"), "the bytes are a PDF document");
    assert!(bytes.windows(5).any(|w| w == b"%%EOF"), "a complete PDF ends with %%EOF");
    // The snapshot the PDF is built from does not disturb the live model.
    assert_eq!(form.model().title, "Investigate vessel");
}

#[test]
fn typed_copy_and_paste_round_trip_the_model_json() {
    use facett_core::clip::{ClipKind, CopySource, PasteTarget};
    let mut src = FormView::new("Case", Case::default());
    src.model_mut().title = "Edited".into();
    let p = src.copy_payload().expect("model copies");
    assert_eq!(p.kind(), ClipKind::Text);
    // Paste into a fresh form -> it adopts the source model.
    let mut dst = FormView::new("Other", Case::default());
    assert!(dst.accepts(ClipKind::Text));
    dst.paste_payload(&p);
    assert_eq!(dst.model().title, "Edited", "pasted model adopts the source title");
}