//! Tier-3 integration tests for ADR-0021 (per-command usage in
//! parse errors). Drives synthetic crossterm events through
//! `App::update` and asserts on the rendered output lines.
//!
//! Each test exercises the full input → parse → error-render
//! chain. The unit tests in `dsl::usage::tests` cover the
//! registry logic in isolation; these tests pin the user-visible
//! composition (caret + structural error + usage block, or the
//! available-commands fallback).

use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;

const fn key(code: KeyCode) -> AppEvent {
    AppEvent::Key(KeyEvent {
        code,
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Press,
        state: crossterm::event::KeyEventState::NONE,
    })
}

fn type_str(app: &mut App, s: &str) {
    for c in s.chars() {
        app.update(key(KeyCode::Char(c)));
    }
}

fn submit(app: &mut App) -> Vec<Action> {
    app.update(key(KeyCode::Enter))
}

/// Run `input` through the app and return every error-kind
/// output line. Asserts the submission parse-failed — which now
/// emits exactly a `JournalFailure` (ADR-0034: the failed line is
/// journalled `err`) and dispatches no command to the worker.
fn error_lines_for(input: &str) -> Vec<String> {
    let mut app = App::new();
    type_str(&mut app, input);
    let actions = submit(&mut app);
    assert!(
        matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
        "expected parse failure (only a JournalFailure) for {input:?}, got {actions:?}",
    );
    app.output
        .iter()
        .filter(|l| l.kind == OutputKind::Error)
        .map(|l| l.text.clone())
        .collect()
}

fn dump(input: &str, lines: &[String]) -> String {
    format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),)
}

/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a
/// near-correct input plus substrings that MUST appear across its
/// rendered error lines — the structural "name the missing
/// keyword/clause" message and the per-command usage template.
#[test]
fn near_miss_matrix_simple_mode() {
    // (input, required-substrings-across-error-lines)
    let matrix: &[(&str, &[&str])] = &[
        // app-lifecycle arg errors. The arg-less commands all reject
        // trailing junk with "expected end of input" + their usage
        // (audited 2026-06-05); locked here as regression insurance.
        (
            "quit now",
            &["after `quit`, expected end of input", "  quit"],
        ),
        // `help` now takes an optional single-word topic (H3), so
        // `help foo` parses (topic lookup); only a *multi-word*
        // topic is the near-miss that rejects trailing junk.
        (
            "help foo bar",
            &[
                "after `help foo`, expected end of input",
                "help [<command>]",
            ],
        ),
        (
            "rebuild now",
            &["after `rebuild`, expected end of input", "  rebuild"],
        ),
        ("new foo", &["after `new`, expected end of input", "  new"]),
        (
            "load foo",
            &["after `load`, expected end of input", "  load"],
        ),
        (
            "undo foo",
            &["after `undo`, expected end of input", "  undo"],
        ),
        (
            "redo foo",
            &["after `redo`, expected end of input", "  redo"],
        ),
        (
            "export foo bar",
            &[
                "after `export foo`, expected end of input",
                "export [<path>]",
            ],
        ),
        (
            "import a b c",
            &[
                "after `import a`, expected end of input",
                "import <zip-path>",
            ],
        ),
        (
            "save sideways",
            &["after `save`, expected end of input", "save  |  save as"],
        ),
        (
            "mode sideways",
            &["unknown mode 'sideways'", "mode simple  |  mode advanced"],
        ),
        (
            "messages louder",
            &["unknown messages mode 'louder'", "messages short"],
        ),
        (
            "copy everything",
            &["unknown copy target 'everything'", "copy all"],
        ),
        // DDL bare + missing-slot
        (
            "create",
            &[
                "after `create`, expected `table`",
                "create table <Name> with pk",
            ],
        ),
        (
            "create table",
            &[
                "after `create table`, expected identifier",
                "create table <Name> with pk",
            ],
        ),
        (
            "create table T",
            &["with pk", "create table <Name> with pk"],
        ),
        // G1: relationship cardinality reads as the named construct.
        (
            "add",
            &[
                "after `add`, expected `column`, `1:n relationship`",
                "add 1:n relationship",
            ],
        ),
        (
            "drop table",
            &[
                "after `drop table`, expected table name",
                "drop table <Name>",
            ],
        ),
        (
            "add column",
            &[
                "after `add column`, expected table name",
                "add column [to] [table]",
            ],
        ),
        (
            "rename",
            &[
                "after `rename`, expected `column`",
                "rename column [in] [table]",
            ],
        ),
        (
            "rename column",
            &[
                "after `rename column`, expected table name",
                "rename column [in] [table]",
            ],
        ),
        (
            "change",
            &[
                "after `change`, expected `column`",
                "change column [in] [table]",
            ],
        ),
        (
            "change column",
            &[
                "after `change column`, expected table name",
                "change column [in] [table]",
            ],
        ),
        // data bare + missing-clause
        (
            "insert",
            &["after `insert`, expected `into`", "insert into <Table>"],
        ),
        (
            "insert into",
            &[
                "after `insert into`, expected table name",
                "insert into <Table>",
            ],
        ),
        (
            "insert into T",
            &[
                "after `insert into T`, expected `values` or `(`",
                "insert into <Table>",
            ],
        ),
        (
            "update",
            &["after `update`, expected table name", "update <Table> set"],
        ),
        (
            "update T",
            &["after `update T`, expected `set`", "update <Table> set"],
        ),
        (
            "update T set x=1",
            &["expected `where` or `--all-rows`", "update <Table> set"],
        ),
        (
            "delete",
            &["after `delete`, expected `from`", "delete from <Table>"],
        ),
        (
            "delete from",
            &[
                "after `delete from`, expected table name",
                "delete from <Table>",
            ],
        ),
        (
            "delete from T",
            &["expected `where` or `--all-rows`", "delete from <Table>"],
        ),
        (
            "seed",
            &["after `seed`, expected table name", "seed <Table> [count]"],
        ),
        // Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
        (
            "seed T set",
            &[
                "after `seed T set`, expected column name",
                "seed <Table>.<col>",
            ],
        ),
        (
            "seed T set role",
            &[
                "after `seed T set role`, expected `=`, `in`, `between`, or `as`",
                "seed <Table>.<col>",
            ],
        ),
        (
            "seed T.",
            &[
                "after `seed T.`, expected column name",
                "seed <Table>.<col>",
            ],
        ),
        (
            "replay",
            &[
                "after `replay`, expected string literal or path",
                "replay <path>",
            ],
        ),
        (
            "explain",
            &[
                "after `explain`, expected `show`, `update`, or `delete`",
                "explain show data",
            ],
        ),
        // advanced-only entry word typed in simple mode → "this is SQL" rail
        ("select * from T", &["`select` is SQL", "mode advanced"]),
        (
            "alter table T add column c int",
            &["`alter` is SQL", "mode advanced"],
        ),
    ];
    for (input, needles) in matrix {
        let lines = error_lines_for(input);
        let dump_msg = dump(input, &lines);
        assert!(
            lines.iter().any(|l| l.starts_with("parse error")),
            "missing `parse error` line for {input:?}\n{dump_msg}",
        );
        let joined = lines.join("\n");
        for needle in *needles {
            assert!(
                joined.contains(needle),
                "near-miss {input:?} missing expected substring {needle:?}\n{dump_msg}",
            );
        }
    }
}

/// Helper: advanced-mode error lines for `input`.
fn advanced_error_lines_for(input: &str) -> Vec<String> {
    let mut app = App::new();
    app.mode = Mode::Advanced;
    type_str(&mut app, input);
    let _ = submit(&mut app);
    app.output
        .iter()
        .filter(|l| l.kind == OutputKind::Error)
        .map(|l| l.text.clone())
        .collect()
}

/// Committed multi-form near-misses (ADR-0042 §1). A user who has
/// already chosen a form (`add index …`, `drop constraint …`,
/// `alter table T add …`) must get that form's specific
/// missing-keyword/clause message and usage — not the whole family
/// generically. Audited 2026-06-05; these render well today and are
/// locked here as the residual systematic-pass tail.
#[test]
fn near_miss_matrix_committed_multiforms() {
    // (input, advanced?, required-substrings)
    let matrix: &[(&str, bool, &[&str])] = &[
        // add / drop multi-forms (simple)
        (
            "add index",
            false,
            &[
                "after `add index`, expected `on` or `as`",
                "add index [as <Name>] on",
            ],
        ),
        (
            "add index on T",
            false,
            &[
                "after `add index on T`, expected `(`",
                "add index [as <Name>] on",
            ],
        ),
        (
            "add constraint",
            false,
            &[
                "after `add constraint`, expected `not`, `unique`, `default`, or `check`",
                "add constraint not null to",
            ],
        ),
        (
            "add constraint not null",
            false,
            &[
                "after `add constraint not null`, expected `to`",
                "add constraint not null to",
            ],
        ),
        (
            "add 1:n relationship",
            false,
            &[
                "after `add 1:n relationship`, expected `from` or `as`",
                "add 1:n relationship",
            ],
        ),
        (
            "add 1:n relationship from",
            false,
            &[
                "after `add 1:n relationship from`, expected table name",
                "from <Parent>.<col>",
            ],
        ),
        (
            "drop constraint",
            false,
            &[
                "after `drop constraint`, expected `not`, `unique`, `default`, or `check`",
                "drop constraint (not null",
            ],
        ),
        (
            "drop constraint not null",
            false,
            &[
                "after `drop constraint not null`, expected `from`",
                "drop constraint (not null",
            ],
        ),
        (
            "drop index",
            false,
            &[
                "after `drop index`, expected `on` or index name",
                "drop index <Name>",
                "drop index on <Table>",
            ],
        ),
        (
            "drop index on T",
            false,
            &[
                "after `drop index on T`, expected `(`",
                "drop index on <Table>",
            ],
        ),
        (
            "drop relationship",
            false,
            &[
                "after `drop relationship`, expected `from` or relationship name",
                "drop relationship <Name>",
            ],
        ),
        (
            "show table",
            false,
            &[
                "after `show table`, expected table name",
                "show table <Table>",
            ],
        ),
        (
            "show relationship",
            false,
            &[
                "after `show relationship`, expected relationship name",
                "show relationship <name>",
            ],
        ),
        (
            "show index",
            false,
            &[
                "after `show index`, expected index name",
                "show index <name>",
            ],
        ),
        (
            "change column in table T: c",
            false,
            &[
                "after `change column in table T: c`, expected `(`",
                "change column [in] [table]",
            ],
        ),
        // advanced committed multi-forms
        (
            "create index on",
            true,
            &[
                "after `create index on`, expected table name",
                "create [unique] index",
            ],
        ),
        (
            "create unique index",
            true,
            &[
                "after `create unique index`, expected `on`, identifier, or `if`",
                "create [unique] index",
            ],
        ),
        (
            "alter table T add",
            true,
            &[
                "after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`",
                "alter table <Table> add column",
            ],
        ),
        (
            "alter table T drop",
            true,
            &[
                "after `alter table T drop`, expected `column` or `constraint`",
                "alter table <Table> drop column",
            ],
        ),
    ];
    for (input, advanced, needles) in matrix {
        let lines = if *advanced {
            advanced_error_lines_for(input)
        } else {
            error_lines_for(input)
        };
        let dump_msg = dump(input, &lines);
        assert!(
            lines.iter().any(|l| l.starts_with("parse error")),
            "missing `parse error` line for {input:?}\n{dump_msg}",
        );
        assert!(
            !lines.iter().any(|l| l.starts_with("available commands:")),
            "committed form {input:?} fell back to available-commands\n{dump_msg}",
        );
        let joined = lines.join("\n");
        for needle in *needles {
            assert!(
                joined.contains(needle),
                "committed form {input:?} missing expected substring {needle:?}\n{dump_msg}",
            );
        }
    }
}

#[test]
fn advanced_bare_select_collapses_projection_first_set() {
    // ADR-0042 G2: bare `select` dumped the full 14-item
    // expression first-set ("`not`, `-`, …, `case`, column name,
    // `distinct`, or `all`"). Collapse it to a learner-sized
    // projection gloss in the error MESSAGE only — completion
    // still expands the raw set (locked by the typing-surface
    // matrix).
    let lines = advanced_error_lines_for("select");
    let joined = lines.join("\n");
    let dump_msg = dump("select", &lines);
    assert!(
        joined.contains("a projection: `*`, a column, or an expression"),
        "bare `select` should collapse to the projection gloss\n{dump_msg}",
    );
    let err_line = lines
        .iter()
        .find(|l| l.starts_with("parse error"))
        .expect("parse error line");
    assert!(
        !err_line.contains("`exists`") && !err_line.contains("`case`"),
        "projection gloss should replace the raw expression first-set\n{dump_msg}",
    );
}

#[test]
fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
    // ADR-0042 G3: `render_usage_block` was mode-blind — it
    // resolved shared entry words to the first-registered (Simple)
    // node, so advanced-mode `create` showed ONLY the DSL `create
    // table … with pk …` template and none of the SQL forms.
    // Mode-aware selection shows every form valid in the mode,
    // SQL-primary first. In advanced mode the DSL forms remain
    // valid input (verified: `create table Foo with pk` parses and
    // runs in advanced mode), so they MUST still appear — a usage
    // hint never hides input that works.
    let lines = advanced_error_lines_for("create");
    let joined = lines.join("\n");
    let dump_msg = dump("create", &lines);
    assert!(
        joined.contains("create table [if not exists]"),
        "advanced `create` should show the SQL create-table usage\n{dump_msg}",
    );
    assert!(
        joined.contains("create [unique] index"),
        "advanced `create` should show the SQL create-index usage\n{dump_msg}",
    );
    assert!(
        joined.contains("create table <Name> with pk"),
        "advanced `create` should ALSO show the DSL `with pk` form (valid in advanced mode)\n{dump_msg}",
    );
    // Ordering: the SQL form is listed before the DSL form
    // (mode-primary first).
    let sql_at = joined.find("create table [if not exists]").unwrap();
    let dsl_at = joined.find("create table <Name> with pk").unwrap();
    assert!(
        sql_at < dsl_at,
        "SQL form should precede the DSL form\n{dump_msg}"
    );
}

#[test]
fn advanced_cross_join_with_on_teaches_no_on_clause() {
    // ADR-0042 §3: a CROSS JOIN has no ON clause. The grammar
    // rejects a following `on`, but the bare structural error
    // ("expected end of input") does not teach why. `on` is
    // unexpected here only because the most recent join is a CROSS
    // join — every other join flavour *requires* `on` — so the case
    // is precisely detectable and gets a teaching message.
    let input = "select * from a cross join b on x = y";
    let lines = advanced_error_lines_for(input);
    let joined = lines.join("\n");
    let dump_msg = dump(input, &lines);
    assert!(
        joined.contains("a CROSS JOIN has no ON clause"),
        "cross join + on should teach that CROSS JOIN takes no ON\n{dump_msg}",
    );
    // Misfire guard 1: a plain JOIN missing its ON still asks for `on`.
    let plain = advanced_error_lines_for("select * from a join b").join("\n");
    assert!(
        plain.contains("expected `on`") && !plain.contains("CROSS JOIN"),
        "a plain join must still ask for ON, not the cross-join message: {plain}",
    );
    // Misfire guard 2: a stray `on` with no join present must NOT
    // claim a CROSS JOIN.
    let stray = advanced_error_lines_for("select * from a on x = y").join("\n");
    assert!(
        !stray.contains("CROSS JOIN has no ON"),
        "no cross join present — must not fire: {stray}",
    );
}

/// The advanced-mode near-miss matrix (ADR-0042 §1/§3). Mirrors
/// the simple-mode matrix for the SQL surface. Every row must show
/// a per-command `usage:` block (never the available-commands
/// fallback — that is for unconsumed entry words only).
#[test]
fn near_miss_matrix_advanced_mode() {
    let matrix: &[(&str, &[&str])] = &[
        // SQL select / with (G2, G4)
        (
            "select",
            &[
                "expected a projection: `*`, a column, or an expression",
                "select (* |",
            ],
        ),
        (
            "select * from",
            &["after `select * from`, expected table name", "select (* |"],
        ),
        (
            "with",
            &[
                "after `with`, expected identifier or `recursive`",
                "with [recursive]",
                "as (",
            ],
        ),
        // create / drop / alter — SQL forms AND the still-valid DSL
        // fallback forms, SQL-primary first (G3).
        (
            "create",
            &[
                "after `create`, expected `table`",
                "create table [if not exists]",
                "create [unique] index",
                "create table <Name> with pk",
            ],
        ),
        (
            "create table",
            &[
                "after `create table`, expected identifier or `if`",
                "create table [if not exists]",
            ],
        ),
        (
            "create index",
            &[
                "after `create index`, expected `on`",
                "create [unique] index",
            ],
        ),
        (
            "drop",
            &[
                "after `drop`, expected `table`",
                "drop table [if exists]",
                "drop column [from]",
                "drop relationship",
            ],
        ),
        (
            "alter",
            &[
                "after `alter`, expected `table`",
                "alter table <Table> add column",
            ],
        ),
        (
            "alter table T",
            &[
                "expected `add`, `drop`, `rename`, or `alter`",
                "alter table <Table>",
            ],
        ),
        // shared insert/update/delete — must show usage, not the
        // available-commands fallback (regression guard for the
        // empty-usage_ids SQL nodes).
        (
            "insert into T",
            &[
                "after `insert into T`, expected `values`, `with`, `select`, or `(`",
                "insert into <Table>",
            ],
        ),
        (
            "update T",
            &["after `update T`, expected `set`", "update <Table> set"],
        ),
        (
            "delete from",
            &[
                "after `delete from`, expected table name",
                "delete from <Table>",
            ],
        ),
    ];
    for (input, needles) in matrix {
        let lines = advanced_error_lines_for(input);
        let dump_msg = dump(input, &lines);
        assert!(
            lines.iter().any(|l| l.starts_with("parse error")),
            "missing `parse error` line for {input:?}\n{dump_msg}",
        );
        // A consumed entry word must yield a usage block, never the
        // available-commands fallback.
        assert!(
            !lines.iter().any(|l| l.starts_with("available commands:")),
            "advanced {input:?} fell back to available-commands instead of a usage block\n{dump_msg}",
        );
        let joined = lines.join("\n");
        for needle in *needles {
            assert!(
                joined.contains(needle),
                "advanced near-miss {input:?} missing expected substring {needle:?}\n{dump_msg}",
            );
        }
    }
}

#[test]
fn with_alone_renders_cte_usage_not_select() {
    // ADR-0042 G4: `with` (advanced-only CTE entry word) borrowed
    // the `select` usage template, which never mentions the CTE
    // shape. It now carries its own `parse.usage.with`.
    let mut app = App::new();
    app.mode = Mode::Advanced;
    type_str(&mut app, "with");
    let _ = submit(&mut app);
    let lines: Vec<String> = app
        .output
        .iter()
        .filter(|l| l.kind == OutputKind::Error)
        .map(|l| l.text.clone())
        .collect();
    let dump_msg = dump("with", &lines);
    assert!(
        lines
            .iter()
            .any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
        "missing CTE-specific `with … as (…)` usage template\n{dump_msg}",
    );
}

#[test]
fn create_alone_renders_create_table_usage() {
    let lines = error_lines_for("create");
    let dump_msg = dump("create", &lines);
    assert!(
        lines.iter().any(|l| l.starts_with("parse error")),
        "{dump_msg}",
    );
    assert!(
        lines.iter().any(|l| l == "usage:"),
        "missing usage: header\n{dump_msg}",
    );
    assert!(
        lines
            .iter()
            .any(|l| l.contains("create table") && l.contains("with pk")),
        "missing create_table usage template\n{dump_msg}",
    );
}

#[test]
fn add_alone_renders_both_add_family_usages() {
    let lines = error_lines_for("add");
    let dump_msg = dump("add", &lines);
    // Aggregation across the top-level choice: the structural
    // error line lists every add-family branch. ADR-0042 G1: the
    // relationship branch renders as the friendly `1:n
    // relationship` rather than the cryptic bare `1` cardinality
    // literal.
    assert!(
        lines.iter().any(|l| {
            l.starts_with("parse error")
                && l.contains("`1:n relationship`")
                && l.contains("`column`")
        }),
        "expected aggregated `1:n relationship` and `column` in structural error\n{dump_msg}",
    );
    // Usage block (ADR-0021): both add-* templates surface.
    assert!(
        lines.iter().any(|l| l.contains("add column")),
        "missing add_column usage\n{dump_msg}",
    );
    assert!(
        lines.iter().any(|l| l.contains("add 1:n relationship")),
        "missing add_relationship usage\n{dump_msg}",
    );
}

#[test]
fn drop_alone_renders_all_three_drop_family_usages() {
    let lines = error_lines_for("drop");
    let dump_msg = dump("drop", &lines);
    assert!(
        lines.iter().any(|l| l.contains("drop table")),
        "missing drop_table usage\n{dump_msg}",
    );
    assert!(
        lines.iter().any(|l| l.contains("drop column")),
        "missing drop_column usage\n{dump_msg}",
    );
    assert!(
        lines.iter().any(|l| l.contains("drop relationship")),
        "missing drop_relationship usage\n{dump_msg}",
    );
}

#[test]
fn show_alone_renders_both_show_family_usages() {
    let lines = error_lines_for("show");
    let dump_msg = dump("show", &lines);
    assert!(
        lines.iter().any(|l| l.contains("show data")),
        "missing show_data usage\n{dump_msg}",
    );
    assert!(
        lines.iter().any(|l| l.contains("show table")),
        "missing show_table usage\n{dump_msg}",
    );
}

#[test]
fn unknown_command_falls_back_to_available_commands_list() {
    let lines = error_lines_for("frobulate Customers");
    let dump_msg = dump("frobulate Customers", &lines);
    // No "usage:" header — the no-prefix fallback path renders
    // the available-commands list instead.
    assert!(
        lines.iter().all(|l| l != "usage:"),
        "should not render usage: header for unknown command\n{dump_msg}",
    );
    let available = lines
        .iter()
        .find(|l| l.starts_with("available commands:"))
        .unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
    // The list must include all ten command-entry keywords.
    for cmd in [
        "add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update",
    ] {
        assert!(
            available.contains(&format!("`{cmd}`")),
            "available commands missing `{cmd}`: {available}",
        );
    }
}

#[test]
fn update_partial_renders_update_usage_template() {
    // `update Customers set Active=false` parses through to
    // end-of-input; the missing `where` / `--all-rows` clause
    // triggers the structural error. The entry keyword is
    // `update`, so the update usage template is shown.
    let lines = error_lines_for("update Customers set Active=false");
    let dump_msg = dump("update Customers set Active=false", &lines);
    assert!(
        lines.iter().any(|l| l.contains("update <Table> set")),
        "missing update usage template\n{dump_msg}",
    );
}

#[test]
fn create_table_without_pk_renders_create_table_usage() {
    // The custom `try_map` error fires after `create table
    // Customers` is fully consumed; failure position points at
    // the start of the matched range, but matched_entry's `<=`
    // condition still resolves the entry keyword.
    let lines = error_lines_for("create table Customers");
    let dump_msg = dump("create table Customers", &lines);
    // Custom error wording (not just structural) is preserved.
    assert!(
        lines
            .iter()
            .any(|l| l.starts_with("parse error") && l.contains("with pk")),
        "missing custom-error wording about with pk\n{dump_msg}",
    );
    // And the usage template surfaces as well.
    assert!(
        lines
            .iter()
            .any(|l| l.contains("create table") && l.contains("with pk")),
        "missing create_table usage template\n{dump_msg}",
    );
}

#[test]
fn insert_partial_renders_insert_usage_template() {
    // `insert into T` needs either column-list or value-list to
    // follow. Parser reports a structural error; usage template
    // surfaces.
    let lines = error_lines_for("insert into T");
    let dump_msg = dump("insert into T", &lines);
    assert!(
        lines.iter().any(|l| l.contains("insert into <Table>")),
        "missing insert usage template\n{dump_msg}",
    );
}

#[test]
fn caret_aligns_under_offending_token() {
    // The caret line is whitespace + `^`. After the "running: "
    // prefix (9 chars) plus the byte offset of the failure
    // position, the `^` should sit directly under the
    // offending character. For `frobulate Customers`, the
    // failure is at position 0, so the caret is at column 9.
    let lines = error_lines_for("frobulate Customers");
    let caret = lines
        .iter()
        .find(|l| l.trim_start_matches(' ').starts_with('^'))
        .expect("missing caret line");
    let leading_spaces = caret.chars().take_while(|c| *c == ' ').count();
    assert_eq!(
        leading_spaces, 9,
        "caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
    );
}
