snapshots_test.rs raw

//! IR snapshots over the component corpus, and rendered-diagnostic
//! snapshots for representative errors. `cargo insta review` to inspect
//! changes.

use guiduck_component_core::{ImportResolver, compile, compile_with, diagnostics};

fn compile_or_render_errors(source: &str, name: &str) -> String {
    match compile(source) {
        Ok(ir) => format!("{ir:#?}"),
        Err(diags) => diagnostics::render(&diags, source, name),
    }
}

/// An in-memory resolver over `(name, source)` pairs.
struct MapResolver(&'static [(&'static str, &'static str)]);

impl ImportResolver for MapResolver {
    fn resolve(&mut self, name: &str) -> Result<(String, String), String> {
        self.0
            .iter()
            .find(|(n, _)| *n == name)
            .map(|(n, source)| (format!("{n}.gdc"), source.to_string()))
            .ok_or_else(|| "no such file".to_owned())
    }
}

fn compile_nested_or_render_errors(
    source: &str,
    name: &str,
    imports: &'static [(&'static str, &'static str)],
) -> String {
    match compile_with(source, &mut MapResolver(imports)) {
        Ok(compiled) => format!("{compiled:#?}"),
        Err(diags) => diagnostics::render(&diags, source, name),
    }
}

#[test]
fn counter_ir() {
    let source = include_str!("../../guiduck/components/Counter.gdc");
    insta::assert_snapshot!(compile_or_render_errors(source, "Counter.gdc"));
}

#[test]
fn kitchen_sink_ir() {
    let source = include_str!("corpus/kitchen-sink.gdc");
    insta::assert_snapshot!(compile_or_render_errors(source, "kitchen-sink.gdc"));
}

#[test]
fn diagnostics_unknown_property() {
    let source = r##"
(component Broken
  (handler go)
  (container :colour "#fff" :on-click go
    (text :text "hi")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_unresolved_names_and_bad_handler() {
    let source = r##"
(component Broken
  (state count i32 :init 0)
  (container :on-click missing
    (text :text "{typo} and {count}")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_reactive_layout_prop() {
    let source = r##"
(component Broken
  (state wide bool :init false)
  (container :width (if wide 200 100)
    (text :text "hi")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_bad_color_and_duplicate() {
    let source = r##"
(component Broken
  (state x i32 :init 0)
  (state x i32 :init 1)
  (container :background "#notacolor"
    (text :text "hi")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn text_input_ir() {
    let source = r##"
(component EchoForm
  (state current String :init "")
  (output changed String)
  (handler edited String)

  (container :direction column :padding 16 :gap 12 :align-items flex-start
    (text-input :font-size 14 :font-family "DejaVu Sans" :width 260
                :autofocus true :on-change edited)
    (text :font-size 14 :text "You typed: {current}")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "EchoForm.gdc"));
}

#[test]
fn diagnostics_on_change_payload_mismatches() {
    // Three ways to get the payload contract wrong: a payload-less handler
    // on :on-change, a payload handler on :on-click, and :on-change on a
    // widget that has no such event.
    let source = r##"
(component Broken
  (handler plain)
  (handler with-payload String)
  (container :direction column :on-click with-payload
    (text-input :on-change plain)
    (text :text "hi" :on-change plain)))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_autofocus_misuse() {
    let source = r##"
(component Broken
  (state on bool :init true)
  (container :direction column
    (text-input :autofocus on)
    (text-input :autofocus true)
    (text-input :autofocus true)))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_enabled_misuse() {
    let source = r##"
(component Broken
  (state busy bool :init false)
  (container :direction column
    (text :text "a" :enabled "yes")
    (text :text "b" :enabled 3)
    (text :text "c" :enabled missing)))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn dynamic_structure_ir() {
    let source = r##"
(component Dynamic
  (state todos (List String) :init (list "a" "b"))
  (state busy bool :init false)
  (container :direction column
    (if (not busy)
      (text :text "ready")
      (text :text "working"))
    (for todo todos :key todo :index i
      (text :text "{i}: {todo}"))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "Dynamic.gdc"));
}

#[test]
fn diagnostics_dynamic_misuse() {
    let source = r##"
(component Broken
  (prop title String)
  (state todos (List String) :init (list))
  (state count i32 :init 0)
  (container :direction column
    (if "nope" (text :text "a"))
    (for title todos (text :text "shadowed"))
    (for x count (text :text "not a list"))
    (for y missing (text :text "unknown"))
    (for z todos
      (text-input :autofocus true))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_list_payloads_rejected() {
    let source = r##"
(component Broken
  (output picked (List String))
  (handler chose (List i32))
  (container (text :text "x")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_invocation_misuse() {
    let source = r##"
(component Broken
  (state count i32 :init 0)
  (handler two i64 i64)
  (handler plain)
  (handler edited String)
  (container :direction column
    (container :on-click (two count))
    (container :on-click (missing count))
    (container :on-click (count count))
    (container :on-click two)
    (text-input :on-change (edited count))
    (text :text (plain))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn records_ir() {
    let source = include_str!("../../guiduck/components/TodoList.gdc");
    insta::assert_snapshot!(compile_or_render_errors(source, "TodoList.gdc"));
}

#[test]
fn diagnostics_record_misuse() {
    let source = r##"
(component Broken
  (record Todo :id i64 :label String)
  (record Todo :id i64)
  (record Nested :inner Todo)
  (record Listy :items (List i64))
  (prop wrong Todo)
  (output bad Todo)
  (state single Todo :init (Todo :id 1 :label "x"))
  (state todos (List Todo) :init (list (Todo :id 1)
                                       (Todo :id 2 :label "b" :extra 3)
                                       (Todo :id "nope" :label "c")))
  (state strs (List String) :init (list "a"))
  (container :direction column
    (for s strs :key s.id
      (text :text "{s.id}"))
    (for todo todos :key todo.missing
      (text :text "{todo.label.deep}"))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_payload_misuse() {
    let source = r##"
(component Broken
  (state payload i32 :init 0)
  (handler save String)
  (handler plain)
  (container :direction column
    (container :on-click (plain))
    (container :on-click (save payload))
    (text-input :on-change (save (+ payload "!")))
    (text :text "{payload}")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn counter_panel_nested_ir() {
    let source = include_str!("../../guiduck/components/CounterPanel.gdc");
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "CounterPanel.gdc",
        &[(
            "CounterButton",
            include_str!("../../guiduck/components/CounterButton.gdc"),
        )],
    ));
}

#[test]
fn diagnostics_component_instance_misuse() {
    // Missing required prop, unknown prop, unknown output, an
    // interior-layout property, children under a slotless instance, and a
    // duplicate prop — all checked against the child's compiled interface.
    // (`:class` and the item-layout properties are *legal* on instances.)
    let source = r##"
(component Broken
  (handler tally i32)
  (container :direction column
    (Child :on-stepped tally)
    (Child :name "a" :name "b" :size 3 :on-missed tally :class "x"
           :grow 1 :direction row
      (text :text "no slots"))))
"##;
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "broken.gdc",
        &[(
            "Child",
            r#"(component Child
  (prop name String)
  (output stepped i32)
  (container (text :text "{name}")))"#,
        )],
    ));
}

#[test]
fn diagnostics_component_wiring_mismatches() {
    // Output-wire payload contract violations and a handler colliding with
    // the generated logic-factory method name.
    let source = r##"
(component Broken
  (handler plain)
  (handler wrong-type String)
  (handler the-child)
  (container :direction column
    (TheChild :name "a" :on-stepped plain)
    (TheChild :name "b" :on-stepped wrong-type)))
"##;
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "broken.gdc",
        &[(
            "TheChild",
            r#"(component TheChild
  (prop name String)
  (output stepped i32)
  (container (text :text "{name}")))"#,
        )],
    ));
}

#[test]
fn diagnostics_component_cycle_and_unresolvable() {
    let source = r##"
(component Broken
  (container
    (Alpha)
    (Nowhere)))
"##;
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "broken.gdc",
        &[
            ("Alpha", r#"(component Alpha (container (Beta)))"#),
            ("Beta", r#"(component Beta (container (Alpha)))"#),
        ],
    ));
}

#[test]
fn diagnostics_autofocus_across_composition() {
    // The single-autofocus rule spans the composed tree: this file's own
    // `:autofocus` plus each instantiated component that carries one.
    let source = r##"
(component Broken
  (container :direction column
    (text-input :autofocus true)
    (Focusy)
    (Focusy)))
"##;
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "broken.gdc",
        &[(
            "Focusy",
            r#"(component Focusy (container (text-input :autofocus true)))"#,
        )],
    ));
}

#[test]
fn diagnostics_reserved_prop_names() {
    let source = r##"
(component Broken
  (prop class String)
  (prop on-fire bool :default false)
  (container (text :text "{class}")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn widgets_ir() {
    let source = include_str!("../../guiduck/components/Settings.gdc");
    insta::assert_snapshot!(compile_or_render_errors(source, "Settings.gdc"));
}

#[test]
fn diagnostics_widget_misuse() {
    // A button has no `:checked`; a checkbox's `:on-toggle` must be wired to
    // a `(handler … bool)`; `:checked` takes a bool, not a string.
    let source = r##"
(component Broken
  (state flag bool :init false)
  (handler wrong String)
  (container :direction column
    (button :label "go" :checked true)
    (checkbox :checked "yes" :on-toggle (wrong flag))
    (checkbox :on-toggle unknown-handler)))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_event_misuse() {
    // Where `event` may and may not be read, and what it offers.
    let source = r##"
(component Broken
  (state n i32 :init 0)
  (handler pick i32)
  (handler edited String)
  (container :direction column
    ; not a field it has
    (container :on-click (pick event.button))
    ; not a value in its own right
    (container :on-click (pick event))
    ; not a pointer wire
    (text-input :text "x" :on-change (pick event.click-count))
    ; not readable outside a wire's arguments
    (text :text "{event.x}")))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_graphic_misuse() {
    // Where a graphic may and may not go. (What an asset *path* may say is a
    // property of the form itself, unit-tested in `asset::tests`; those are
    // read-time errors, so they surface one at a time like any syntax error.)
    let source = r##"
(component Broken
  (state name String :init "logo")
  (container :direction column
    ; a graphic property needs a graphic, not a bare string…
    (image :source "icons/logo.png")
    ; …and not an expression: the bytes are found at build time
    (image :source name)
    ; a graphic is not a string
    (text :text (asset "icons/logo.png"))
    ; nor a boolean
    (text :text "x" :enabled (asset "icons/logo.png"))
    ; nor something to compute with
    (text :text "x" :enabled (not (path (move 0 0))))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_slot_misuse() {
    // Two slots, a slot with props and children, and a slot at the root.
    let source = r##"
(component Broken
  (container :direction column
    (slot)
    (slot :grow 1
      (text :text "no"))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_slot_at_root() {
    let source = r##"
(component Broken
  (slot))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_key_misuse() {
    // A duplicate key on the same type, a reserved `#` prefix, and a
    // non-literal value.
    let source = r##"
(component Broken
  (state which String :init "a")
  (container :direction column
    (Child :key "left")
    (Child :key "left")
    (Child :key "#0")
    (Child :key which)))
"##;
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "broken.gdc",
        &[("Child", r#"(component Child (container (text :text "x")))"#)],
    ));
}

/// The widget manifest a `.gdc` in these tests is compiled against.
const WIDGET_MANIFEST: &str = r##"
(widget markdown
  (type "::guiduck_markdown::MarkdownView")
  (prop source String)
  (prop wrap bool :setter set_wrapping)
  (prop gutter f32)
  (event on-link :payload String)
  (token link-color Brush))
"##;

fn widget_registry() -> guiduck_component_core::registry::Registry {
    let mut registry = guiduck_component_core::registry::Registry::default();
    for widget in guiduck_component_core::manifest::compile_manifest(WIDGET_MANIFEST)
        .expect("the manifest compiles")
    {
        registry.insert(widget).expect("fresh names");
    }
    registry
}

fn compile_with_widgets_or_render_errors(source: &str, name: &str) -> String {
    struct NoImports;
    impl ImportResolver for NoImports {
        fn resolve(&mut self, _name: &str) -> Result<(String, String), String> {
            Err("no components here".into())
        }
    }
    match guiduck_component_core::compile_with_widgets(source, &mut NoImports, &widget_registry()) {
        Ok(compiled) => format!("{:#?}", compiled.root),
        Err(diags) => diagnostics::render(&diags, source, name),
    }
}

/// A manifest-declared widget written in a `.gdc`: its own props static and
/// bound, its own event wired, and the universal vocabulary — layout,
/// `:class`, a pointer event — reaching it exactly as it reaches a builtin.
#[test]
fn user_widget_ir() {
    let source = r##"
(component Page
  (state body String :init "# Hi")
  (handler navigate String)
  (handler clicked)

  (container :direction column :padding 8
    (markdown :source body :wrap true :gutter 12
              :width 400 :class "doc" :tooltip "The rendered page"
              :on-link (navigate payload)
              :on-click clicked)))
"##;
    insta::assert_snapshot!(compile_with_widgets_or_render_errors(source, "Page.gdc"));
}

/// Every way to get a manifest-declared widget wrong from the `.gdc` side.
#[test]
fn diagnostics_user_widget_misuse() {
    let source = r##"
(component Broken
  (state count i32 :init 0)
  (handler plain)

  (container :direction column
    (markdown :source 42)
    (markdown :source "ok" :nonesuch 1)
    (markdown :source "ok" :on-link plain)
    (markdown :source count)
    (markdown :source "ok" :gutter (asset "x.png"))
    (markdow :source "ok")))
"##;
    insta::assert_snapshot!(compile_with_widgets_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_focus_and_key_misuse() {
    // `:focused`, `:on-key`, and what `event` offers on each kind of wire.
    let source = r##"
(component Broken
  (state n i32 :init 0)
  (state flag bool :init false)
  (handler pick i32)
  (handler note String)
  (container :direction column
    ; `:focused` is a boolean expression, not a string
    (text-input :text "x" :focused "yes")
    ; a key wire has no position
    (container :on-key (pick event.x))
    ; a pointer wire has no keystroke
    (container :on-click (note event.key))
    ; a semantic wire has neither
    (checkbox :checked flag :on-toggle (note event.key))))
"##;
    insta::assert_snapshot!(compile_or_render_errors(source, "broken.gdc"));
}

#[test]
fn diagnostics_mount_hook_collisions() {
    // The framework calls one logic method the file does not declare, so a
    // handler — or a child component whose generated factory method takes the
    // same name — is named rather than silently shadowing the hook.
    let source = r##"
(component Broken
  (handler mounted)
  (container :direction column
    (Mounted)))
"##;
    insta::assert_snapshot!(compile_nested_or_render_errors(
        source,
        "broken.gdc",
        &[("Mounted", "(component Mounted (text :text \"hi\"))")],
    ));
}