//! DECLARATIVE rewrite. Migration of `tests/e2e/margin.rs` — line-number
//! gutter, margin annotations, and gutter-width invariants.
//!
//! Every test in this file is a `LayoutScenario` data literal — no
//! harness calls, no per-step imperative `send_key` / `render` flow.
//! The runner drives the scenario; the test's job is to declare the
//! input, the actions, and the expected output as a single struct
//! value.
//!
//! Load-bearing claims preserved here:
//!
//!   1. With a file open and default config, the left margin
//!      shows " N │" line-number cells, and the buffer content is
//!      rendered alongside (issue #539 regression family).
//!   2. Empty buffer still shows line " 1 │" (2-digit gutter
//!      minimum applies even before any text).
//!   3. `config.editor.line_numbers = false` on startup suppresses
//!      the " │ " line-number separator (issue #539).
//!   4. `line_numbers = false` is respected when a second file is
//!      opened — the new `BufferViewState` must not hardcode
//!      `show_line_numbers = true` (issue #1181).
//!   5. Large file (1000 lines) jumps to end (Ctrl+End) and
//!      renders 4-digit line numbers ("1000 │").
//!   6. `Action::ToggleLineNumbers` hides the line-number
//!      separator while leaving content visible (the command palette
//!      and keymap both funnel through this action).
//!   7. Custom margin annotations round-trip: the symbol appears
//!      after `Event::AddMarginAnnotation`, and remove makes it
//!      disappear while line numbers stay.
//!   8. After typing 3 lines into a fresh buffer, line numbers
//!      " 1 │"–" 3 │" plus the typed content all appear.
//!   9. Cursor X position after "abc" equals the left-margin
//!      gutter width + 3 — exercises the cursor-positioning
//!      contract that accounts for margin width.
//!  10. Horizontal scrolling still leaves the line-1 gutter cell
//!      and content on screen.
//!  11. (`#[ignore]`d) Per-buffer margin state in split view —
//!      preserved as-ignored to match the e2e marker.
//!  12. After PageDown / multiple Down keys, the visible line
//!      numbers shift to reflect the new viewport top.
//!  13. PageUp/PageDown/Ctrl+Home/Ctrl+End navigation keeps the
//!      line-number column in sync with the viewport (200-line file).
//!
//! ## DSL extensions used
//!
//! - `LayoutScenario::initial_margin_annotations` /
//!   `LayoutScenario::remove_margin_annotations` — wraps
//!   `Event::AddMarginAnnotation` / `RemoveMarginAnnotation` so
//!   they can be expressed as scenario data instead of `apply_event`
//!   calls.
//! - `RenderSnapshotExpect::row_checks` with
//!   `RowMatch::AnyRowContains` / `NoRowContains` — already in the
//!   DSL; used for "row contains ' 1 │'" style assertions.
//! - `LayoutScenario::expected_cursor_col_equals_margin_plus` /
//!   `expected_cursor_row_equals_content_first` — projects the
//!   cursor-vs-gutter-width invariant into a declarative assertion
//!   (wraps the `EditorTestApi::margin_left_total_width` accessor).
//! - `LayoutScenario::config_overrides.line_numbers` — sets the
//!   line-numbers config flag declaratively (added for the issue
//!   #1181 second-file regression; the issue #539 initial-buffer
//!   scenario still uses `Action::ToggleLineNumbers` to also cover
//!   the toggle dispatch path).
//! - `LayoutScenario::open_files: Vec<PathBuf>` — opens additional
//!   on-disk files in order via the real `open_file` pipeline after
//!   the initial buffer, exercising the fresh-`BufferViewState`
//!   path that issue #1181 regressed on.

use crate::common::scenario::layout_scenario::{
    assert_layout_scenario, LayoutScenario, MarginAnnotationSpec,
};
use crate::common::scenario::render_snapshot::{RenderSnapshotExpect, RowMatch};
use fresh::test_api::Action;

/// Helper: long fixture text for the navigation scenarios.
fn lines(n: usize) -> String {
    (1..=n).map(|i| format!("Line {i}\n")).collect()
}

#[test]
fn migrated_margin_line_numbers_rendering() {
    // Original: `test_margin_line_numbers_rendering`. Default
    // config (line numbers on), 10-line file — gutter shows " 1 │"
    // .. " 3 │" plus content "Line 1" .. "Line 3" on the visible
    // rows.
    assert_layout_scenario(LayoutScenario {
        description: "default config + 10-line file: gutter and content render".into(),
        initial_text: lines(10),
        width: 80,
        height: 24,
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains(" 1 \u{2502}".into()),
                RowMatch::AnyRowContains(" 2 \u{2502}".into()),
                RowMatch::AnyRowContains(" 3 \u{2502}".into()),
                RowMatch::AnyRowContains("Line 1".into()),
                RowMatch::AnyRowContains("Line 2".into()),
                RowMatch::AnyRowContains("Line 3".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_margin_empty_buffer() {
    // Original: `test_margin_empty_buffer`. Empty buffer still
    // shows " 1 │" — the 2-digit-gutter minimum applies.
    assert_layout_scenario(LayoutScenario {
        description: "empty buffer: gutter still shows ' 1 │'".into(),
        initial_text: String::new(),
        width: 80,
        height: 24,
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContains(" 1 \u{2502}".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_initial_buffer_respects_line_numbers_config() {
    // Original: `test_initial_buffer_respects_line_numbers_config`
    // (issue #539). With line numbers disabled, the " │ " separator
    // must NOT appear, and typed content remains visible. The
    // declarative form starts from default config (line_numbers
    // on) and dispatches `Action::ToggleLineNumbers` to flip the
    // flag — same dispatch path the e2e palette flow funnels into.
    assert_layout_scenario(LayoutScenario {
        description: "issue #539: line_numbers=false ⇒ no ' │ ' separator; typing still visible"
            .into(),
        initial_text: String::new(),
        width: 80,
        height: 24,
        actions: vec![
            Action::ToggleLineNumbers,
            Action::InsertChar('H'),
            Action::InsertChar('e'),
            Action::InsertChar('l'),
            Action::InsertChar('l'),
            Action::InsertChar('o'),
        ],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::NoRowContains(" \u{2502} ".into()),
                RowMatch::AnyRowContains("Hello".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_opened_file_respects_line_numbers_disabled_config() {
    // Original: `test_opened_file_respects_line_numbers_disabled_config`
    // (issue #1181). With `line_numbers = false`, opening a SECOND
    // file must NOT show the line-number separator — the bug was
    // that `BufferViewState::new()` hardcoded show_line_numbers=true,
    // so a freshly-opened second buffer ignored the config.
    //
    // Declarative form: `config_overrides.line_numbers = false`,
    // `initial_file = file1` (replaces the empty buffer in-place),
    // `open_files = [file2]` (creates the NEW BufferViewState — the
    // regression surface). Final render asserts the separator is
    // absent and file2's content is visible.
    let file1 =
        crate::common::fixtures::TestFixture::new("file1.txt", "File 1 line 1\nFile 1 line 2\n")
            .expect("create file1");
    let file2 =
        crate::common::fixtures::TestFixture::new("file2.txt", "File 2 line 1\nFile 2 line 2\n")
            .expect("create file2");
    assert_layout_scenario(LayoutScenario {
        description: "issue #1181: second opened file respects line_numbers=false".into(),
        initial_text: String::new(),
        initial_file: Some(file1.path.clone()),
        open_files: vec![file2.path.clone()],
        width: 80,
        height: 24,
        config_overrides: crate::common::scenario::layout_scenario::ScenarioConfigOverrides {
            line_numbers: Some(false),
            ..Default::default()
        },
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                // Line-number separator must NOT appear on the
                // second buffer.
                RowMatch::NoRowContains(" \u{2502} ".into()),
                // The second file's content must be visible.
                RowMatch::AnyRowContains("File 2 line 1".into()),
                RowMatch::AnyRowContains("File 2 line 2".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

/// Anti-test: same two-file open, but WITHOUT disabling line
/// numbers (default config = on). The second buffer must then
/// show the ' │ ' separator — proving the positive test's
/// "no separator on the second file" claim is gated on the
/// `line_numbers = false` config being honored across opens.
#[test]
fn anti_opened_file_with_default_config_shows_line_number_separator() {
    let file1 = crate::common::fixtures::TestFixture::new(
        "anti_file1.txt",
        "File 1 line 1\nFile 1 line 2\n",
    )
    .expect("create file1");
    let file2 = crate::common::fixtures::TestFixture::new(
        "anti_file2.txt",
        "File 2 line 1\nFile 2 line 2\n",
    )
    .expect("create file2");
    assert_layout_scenario(LayoutScenario {
        description: "anti: default config ⇒ second opened file shows ' │ ' separator".into(),
        initial_text: String::new(),
        initial_file: Some(file1.path.clone()),
        open_files: vec![file2.path.clone()],
        width: 80,
        height: 24,
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains(" \u{2502} ".into()),
                RowMatch::AnyRowContains("File 2 line 1".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_margin_large_file_line_numbers() {
    // Original: `test_margin_large_file_line_numbers`. 1000-line
    // file, Ctrl+End jumps to end, gutter renders the 4-digit
    // "1000 │".
    assert_layout_scenario(LayoutScenario {
        description: "1000-line file: Ctrl+End shows '1000 │' in gutter".into(),
        initial_text: lines(1000),
        width: 80,
        height: 24,
        actions: vec![Action::MoveDocumentEnd],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContains("1000 \u{2502}".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_margin_disable_line_numbers_via_action() {
    // Original: `test_margin_disable_line_numbers` — the e2e
    // routed through Ctrl+P → palette → Enter to exercise the
    // dispatch path; the declarative rewrite calls
    // `Action::ToggleLineNumbers` directly. The same action
    // backs both the palette entry and the keybinding, so the
    // load-bearing claim ("toggle hides the separator while
    // content survives") is preserved verbatim.
    assert_layout_scenario(LayoutScenario {
        description: "Action::ToggleLineNumbers hides separator; content still visible".into(),
        initial_text: "Line 1\nLine 2\nLine 3\n".into(),
        width: 80,
        height: 24,
        actions: vec![Action::ToggleLineNumbers],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::NoRowContains(" \u{2502} ".into()),
                RowMatch::AnyRowContains("Line 1".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_margin_custom_annotations() {
    // Original: `test_margin_custom_annotations`. Add a left-gutter
    // breakpoint dot, render — the dot must appear. Then remove
    // it; line numbers still render. Multi-stage observation (dot
    // appears, then disappears) is collapsed into a single scenario
    // by relying on `initial_margin_annotations` + `remove_margin_annotations`:
    // we add and remove in the same scenario, then assert that the
    // line-number gutter still renders. The "dot was visible"
    // half is covered by the companion `_dot_visible_after_add`
    // scenario below.
    assert_layout_scenario(LayoutScenario {
        description: "AddMarginAnnotation then RemoveMarginAnnotation: line numbers survive".into(),
        initial_text: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n".into(),
        width: 80,
        height: 24,
        initial_margin_annotations: vec![MarginAnnotationSpec {
            line: 2,
            position: "left".into(),
            symbol: "\u{25CF}".into(),
            color: Some((255, 0, 0)),
            annotation_id: Some("breakpoint-1".into()),
        }],
        remove_margin_annotations: vec!["breakpoint-1".into()],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContains(" 3 \u{2502}".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_margin_custom_annotation_dot_visible_after_add() {
    // Companion of `migrated_margin_custom_annotations`: assert the
    // "dot visible after AddMarginAnnotation" half by NOT removing
    // it. This half is what the original e2e asserts immediately
    // after the add (before the remove).
    assert_layout_scenario(LayoutScenario {
        description: "AddMarginAnnotation: '\u{25CF}' visible in left gutter".into(),
        initial_text: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n".into(),
        width: 80,
        height: 24,
        initial_margin_annotations: vec![MarginAnnotationSpec {
            line: 2,
            position: "left".into(),
            symbol: "\u{25CF}".into(),
            color: Some((255, 0, 0)),
            annotation_id: Some("breakpoint-1".into()),
        }],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContains("\u{25CF}".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_margin_after_editing() {
    // Original: `test_margin_after_editing`. Type three lines with
    // Enter between each — gutter " 1 │"–" 3 │" plus the line text
    // appears.
    assert_layout_scenario(LayoutScenario {
        description: "after typing 3 lines: gutter 1-3 + line text all visible".into(),
        initial_text: String::new(),
        width: 80,
        height: 24,
        actions: vec![
            Action::InsertChar('F'),
            Action::InsertChar('i'),
            Action::InsertChar('r'),
            Action::InsertChar('s'),
            Action::InsertChar('t'),
            Action::InsertChar(' '),
            Action::InsertChar('l'),
            Action::InsertChar('i'),
            Action::InsertChar('n'),
            Action::InsertChar('e'),
            Action::InsertNewline,
            Action::InsertChar('S'),
            Action::InsertChar('e'),
            Action::InsertChar('c'),
            Action::InsertChar('o'),
            Action::InsertChar('n'),
            Action::InsertChar('d'),
            Action::InsertChar(' '),
            Action::InsertChar('l'),
            Action::InsertChar('i'),
            Action::InsertChar('n'),
            Action::InsertChar('e'),
            Action::InsertNewline,
            Action::InsertChar('T'),
            Action::InsertChar('h'),
            Action::InsertChar('i'),
            Action::InsertChar('r'),
            Action::InsertChar('d'),
            Action::InsertChar(' '),
            Action::InsertChar('l'),
            Action::InsertChar('i'),
            Action::InsertChar('n'),
            Action::InsertChar('e'),
        ],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains(" 1 \u{2502}".into()),
                RowMatch::AnyRowContains(" 2 \u{2502}".into()),
                RowMatch::AnyRowContains(" 3 \u{2502}".into()),
                RowMatch::AnyRowContains("First line".into()),
                RowMatch::AnyRowContains("Second line".into()),
                RowMatch::AnyRowContains("Third line".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_cursor_position_with_margin() {
    // Original: `test_cursor_position_with_margin`. After typing
    // "abc", the hardware cursor's column must equal gutter_width
    // + 3 — exercises the cursor-positioning contract that accounts
    // for margin width. Row must be the first content row.
    assert_layout_scenario(LayoutScenario {
        description: "after 'abc' typed: cursor X = gutter + 3, Y = first content row".into(),
        initial_text: String::new(),
        width: 80,
        height: 24,
        config_overrides: crate::common::scenario::layout_scenario::ScenarioConfigOverrides {
            line_wrap: Some(false),
            ..Default::default()
        },
        actions: vec![
            Action::InsertChar('a'),
            Action::InsertChar('b'),
            Action::InsertChar('c'),
        ],
        expected_cursor_col_equals_margin_plus: Some(3),
        expected_cursor_row_equals_content_first: true,
        ..Default::default()
    });
}

#[test]
fn migrated_margin_with_horizontal_scroll() {
    // Original: `test_margin_with_horizontal_scroll`. 200-char
    // line, 100 Right keys to scroll horizontally — line " 1 │"
    // and an 'X' content cell both still render. With
    // `line_wrap=false` so Right actually scrolls horizontally.
    assert_layout_scenario(LayoutScenario {
        description: "horizontal scroll: gutter ' 1 │' + 'X' content still visible".into(),
        initial_text: "X".repeat(200),
        width: 80,
        height: 24,
        config_overrides: crate::common::scenario::layout_scenario::ScenarioConfigOverrides {
            line_wrap: Some(false),
            ..Default::default()
        },
        actions: (0..100).map(|_| Action::MoveRight).collect(),
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains(" 1 \u{2502}".into()),
                RowMatch::AnyRowContains("X".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
#[ignore = "Splits currently share the same active buffer (architectural limitation). All splits display the currently active buffer, so this test's assumption of independent buffers per split doesn't match current behavior."]
fn migrated_margin_per_buffer_in_split_view() {
    // Original: `test_margin_per_buffer_in_split_view`. Preserved
    // `#[ignore]`d to match the e2e marker — flips to passing
    // when per-split-buffer architecture lands.
    //
    // Deferred: needs declarative "open a second file in a split"
    // scenario surface AND per-buffer margin state observation
    // (neither has a `LayoutScenario` projection today). The e2e's
    // ignore marker is preserved here so the architectural debt
    // stays grep-able.
}

#[test]
fn migrated_line_numbers_update_during_incremental_scroll() {
    // Original: `test_line_numbers_update_during_incremental_scroll`.
    // 100-line file; after PageDown the gutter shows lines in the
    // 20-25 range; after 5 more Down keys, 27-31 range; "   1 │"
    // no longer visible. The original e2e used "any line in
    // 20..=25" via an explicit any() over candidate strings;
    // declarative form uses `AnyRowContainsAny` (which is exactly
    // the DSL primitive for that shape).
    assert_layout_scenario(LayoutScenario {
        description: "PageDown then 5×Down: gutter shifts to ~20-31 range; line 1 gone".into(),
        initial_text: lines(100),
        width: 80,
        height: 24,
        actions: vec![
            Action::MovePageDown,
            Action::MoveDown,
            Action::MoveDown,
            Action::MoveDown,
            Action::MoveDown,
            Action::MoveDown,
        ],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContainsAny(vec![
                    "  27 \u{2502}".into(),
                    "  28 \u{2502}".into(),
                    "  29 \u{2502}".into(),
                    "  30 \u{2502}".into(),
                    "  31 \u{2502}".into(),
                ]),
                RowMatch::NoRowContains("   1 \u{2502}".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_update_during_incremental_scroll_after_page_down_only() {
    // Sub-claim split out for clarity: after JUST PageDown (no
    // extra Down keys), the gutter shows lines in the 20-25
    // range. The combined original e2e asserted this with
    // `any()` over candidate strings — same shape declaratively.
    assert_layout_scenario(LayoutScenario {
        description: "after PageDown on 100-line file: gutter ~20-25 range".into(),
        initial_text: lines(100),
        width: 80,
        height: 24,
        actions: vec![Action::MovePageDown],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContainsAny(vec![
                "  20 \u{2502}".into(),
                "  21 \u{2502}".into(),
                "  22 \u{2502}".into(),
                "  23 \u{2502}".into(),
                "  24 \u{2502}".into(),
                "  25 \u{2502}".into(),
            ])],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_initial_state_for_navigation_test() {
    // Original test 13 multi-step: this asserts the initial
    // state (line 1 + "Line 1" both visible) BEFORE any
    // navigation. The combined navigation flow is split into
    // single-step scenarios so each is genuinely declarative.
    assert_layout_scenario(LayoutScenario {
        description: "200-line file at top: '   1 │' and 'Line 1' visible".into(),
        initial_text: lines(200),
        width: 80,
        height: 24,
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains("   1 \u{2502}".into()),
                RowMatch::AnyRowContains("Line 1".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_after_three_page_downs() {
    // Original test 13 sub-claim: PageDown × 3 lands somewhere in
    // the 60-70 line range.
    assert_layout_scenario(LayoutScenario {
        description: "200-line file: 3×PageDown ⇒ gutter shows 60-70 range".into(),
        initial_text: lines(200),
        width: 80,
        height: 24,
        actions: vec![
            Action::MovePageDown,
            Action::MovePageDown,
            Action::MovePageDown,
        ],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContainsAny(
                (60u32..=70).map(|n| format!("  {n} \u{2502}")).collect(),
            )],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_after_three_page_downs_then_two_page_ups() {
    // Original test 13 sub-claim: 3×PageDown then 2×PageUp lands
    // in the 20-28 line range.
    assert_layout_scenario(LayoutScenario {
        description: "200-line file: 3×PageDown 2×PageUp ⇒ gutter shows 20-28 range".into(),
        initial_text: lines(200),
        width: 80,
        height: 24,
        actions: vec![
            Action::MovePageDown,
            Action::MovePageDown,
            Action::MovePageDown,
            Action::MovePageUp,
            Action::MovePageUp,
        ],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContainsAny(
                (20u32..=28).map(|n| format!("  {n} \u{2502}")).collect(),
            )],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_at_document_end() {
    // Original test 13 sub-claim: Ctrl+End on 200-line file
    // lands with ' 200 │' and 'Line 200' visible, '   1 │' gone,
    // and several rows in the 180-199 range showing.
    assert_layout_scenario(LayoutScenario {
        description: "200-line file: Ctrl+End ⇒ gutter shows 200, no longer shows 1".into(),
        initial_text: lines(200),
        width: 80,
        height: 24,
        actions: vec![Action::MoveDocumentEnd],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains(" 200 \u{2502}".into()),
                RowMatch::AnyRowContains("Line 200".into()),
                RowMatch::NoRowContains("   1 \u{2502}".into()),
                RowMatch::AnyRowContainsAny(vec![
                    " 180 \u{2502}".into(),
                    " 185 \u{2502}".into(),
                    " 190 \u{2502}".into(),
                    " 195 \u{2502}".into(),
                    " 199 \u{2502}".into(),
                ]),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_round_trip_end_then_home() {
    // Original test 13 sub-claim: after End-Home round-trip, the
    // gutter shows lines 1, 2, 3, 10, 20 — exhaustive expansion
    // of the e2e's per-row checks.
    assert_layout_scenario(LayoutScenario {
        description: "200-line: Ctrl+End then Ctrl+Home ⇒ gutter back to 1,2,3,10,20".into(),
        initial_text: lines(200),
        width: 80,
        height: 24,
        actions: vec![Action::MoveDocumentEnd, Action::MoveDocumentStart],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![
                RowMatch::AnyRowContains("   1 \u{2502}".into()),
                RowMatch::AnyRowContains("Line 1".into()),
                RowMatch::NoRowContains(" 200 \u{2502}".into()),
                RowMatch::AnyRowContains("   2 \u{2502}".into()),
                RowMatch::AnyRowContains("   3 \u{2502}".into()),
                RowMatch::AnyRowContains("  10 \u{2502}".into()),
                RowMatch::AnyRowContains("  20 \u{2502}".into()),
            ],
            ..Default::default()
        },
        ..Default::default()
    });
}

#[test]
fn migrated_line_numbers_round_trip_end_home_end() {
    // Final sub-claim of the navigation test: End → Home → End
    // lands back at line 200.
    assert_layout_scenario(LayoutScenario {
        description: "End → Home → End round trip ⇒ ' 200 │' visible".into(),
        initial_text: lines(200),
        width: 80,
        height: 24,
        actions: vec![
            Action::MoveDocumentEnd,
            Action::MoveDocumentStart,
            Action::MoveDocumentEnd,
        ],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContains(" 200 \u{2502}".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

// ── Anti-tests ────────────────────────────────────────────────────────

/// Anti: drop the `InsertChar('H'…'o')` sequence from the
/// initial-buffer line_numbers-off scenario. Without typing,
/// "Hello" must not appear — proves the positive test's
/// `AnyRowContains("Hello")` is gated on the typing sequence.
#[test]
fn anti_initial_buffer_without_typing_has_no_hello() {
    assert_layout_scenario(LayoutScenario {
        description: "anti: line_numbers off, no typing ⇒ 'Hello' not on screen".into(),
        initial_text: String::new(),
        width: 80,
        height: 24,
        actions: vec![Action::ToggleLineNumbers],
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::NoRowContains("Hello".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

/// Anti: drop the `Action::MoveDocumentEnd` from the large-file
/// test. Without the jump, "1000 │" must not appear.
#[test]
fn anti_large_file_without_jump_to_end_lacks_line_1000_marker() {
    assert_layout_scenario(LayoutScenario {
        description: "anti: 1000-line file, no Ctrl+End ⇒ '1000 │' absent".into(),
        initial_text: lines(1000),
        width: 80,
        height: 24,
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::NoRowContains("1000 \u{2502}".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}

/// Anti: drop the `Action::ToggleLineNumbers` action from the
/// disable-line-numbers scenario. Without the toggle, the " │ "
/// separator must STILL be present.
#[test]
fn anti_margin_without_toggle_keeps_line_number_separator() {
    assert_layout_scenario(LayoutScenario {
        description: "anti: no ToggleLineNumbers ⇒ ' │ ' separator still present".into(),
        initial_text: "Line 1\nLine 2\nLine 3\n".into(),
        width: 80,
        height: 24,
        expected_snapshot: RenderSnapshotExpect {
            row_checks: vec![RowMatch::AnyRowContains(" \u{2502} ".into())],
            ..Default::default()
        },
        ..Default::default()
    });
}
