TUI walking skeleton (Phase 4)
First implementation milestone: Cargo project, dependencies,
and a minimal but functional TUI shell built on Ratatui +
Crossterm + Tokio in the Elm-style update/view pattern
(Candidate A from Phase 2/3 selection).
Includes:
- Three-region layout: items list (left), output + input + hint
(right), bottom status bar with mode-aware shortcuts.
- Two themes (light, dark) plus COLORFGBG auto-detect, per
NFR-7. CLI: --theme {light,dark}, --log-file <path>.
- Input modes per ADR-0003: simple (default), advanced, with
the `:` one-shot escape including immediate prompt reaction
("Advanced:" label, advanced border) and auto-inserted space
after a leading `:` in simple mode.
- App-level commands: `quit`/`q`, `mode simple`/`mode advanced`
(canonical list per ADR-0003 — remaining commands land in
later iterations).
- File logging via tracing, defaulting to ~/.rdbms-playground/
playground.log so the TUI is not corrupted by stdio.
Testing per ADR-0008:
- Tier 1: 29 unit tests covering input handling, mode switch,
one-shot escape, auto-space, output buffering, CLI parsing.
- Tier 2: 4 insta snapshots (default simple/advanced/light,
one-shot active) of TestBackend frames.
- Tier 3: 7 integration tests driving synthetic events through
App::update + render path.
All green: 36 tests, 0 failures, 0 skips. Clippy clean with
nursery lints enabled.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
//! Tier 3 integration tests for the walking skeleton (per ADR-0008).
|
||||
//!
|
||||
//! These tests drive synthetic crossterm events through `App::update`
|
||||
//! and assert on the resulting state and rendered buffer. They
|
||||
//! exercise the full input → state → render path without a real
|
||||
//! terminal, so they run on every commit and catch regressions in
|
||||
//! the wiring between modules.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::{App, OutputKind};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
use rdbms_playground::theme::Theme;
|
||||
use rdbms_playground::ui;
|
||||
|
||||
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) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
for c in s.chars() {
|
||||
actions.extend(app.update(key(KeyCode::Char(c))));
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn submit(app: &mut App) -> Vec<Action> {
|
||||
app.update(key(KeyCode::Enter))
|
||||
}
|
||||
|
||||
fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| ui::render(app, theme, f))
|
||||
.expect("draw frame");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buffer.area.height {
|
||||
for x in 0..buffer.area.width {
|
||||
out.push_str(buffer[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_then_submitting_produces_an_echo_in_the_output_panel() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
type_str(&mut app, "hello world");
|
||||
let pre_render = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
pre_render.contains("hello world"),
|
||||
"input field should display the typed text:\n{pre_render}"
|
||||
);
|
||||
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
assert!(app.input.is_empty(), "input buffer cleared on submit");
|
||||
assert_eq!(app.output.len(), 1);
|
||||
|
||||
let post_render = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
post_render.contains("hello world"),
|
||||
"output panel should display the echoed line:\n{post_render}"
|
||||
);
|
||||
assert!(
|
||||
post_render.contains("[simple]"),
|
||||
"echo should be tagged with the submission mode:\n{post_render}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switch_changes_label_and_subsequent_echoes() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
let initial = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(initial.contains("SIMPLE"));
|
||||
assert!(!initial.contains("ADVANCED"));
|
||||
|
||||
type_str(&mut app, "mode advanced");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.mode, Mode::Advanced);
|
||||
|
||||
let after_switch = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(after_switch.contains("ADVANCED"));
|
||||
|
||||
type_str(&mut app, "select 1");
|
||||
submit(&mut app);
|
||||
let last = app.output.back().expect("output present");
|
||||
assert_eq!(last.mode_at_submission, Mode::Advanced);
|
||||
assert_eq!(last.kind, OutputKind::Echo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_escape_in_simple_mode_is_one_shot() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, ":select 1");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.mode, Mode::Simple);
|
||||
assert_eq!(app.output[0].mode_at_submission, Mode::Advanced);
|
||||
assert_eq!(app.output[0].text, "select 1");
|
||||
|
||||
type_str(&mut app, "another line");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_command_returns_quit_action() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "quit");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(actions, vec![Action::Quit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_works_at_minimum_useful_size() {
|
||||
// Sanity check that the layout does not panic at small sizes.
|
||||
let app = App::new();
|
||||
let theme = Theme::dark();
|
||||
let _ = rendered_text(&app, &theme, 40, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
// No `:` yet — prompt shows SIMPLE.
|
||||
type_str(&mut app, "sel");
|
||||
let before = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(before.contains("SIMPLE"));
|
||||
assert!(!before.contains("Advanced:"));
|
||||
|
||||
// Reset and type `:` first — prompt should flip immediately.
|
||||
app.input.clear();
|
||||
type_str(&mut app, ":");
|
||||
let after_colon = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
after_colon.contains("Advanced:"),
|
||||
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
|
||||
);
|
||||
assert!(!after_colon.contains("SIMPLE"));
|
||||
|
||||
// Backspace through both the auto-inserted space and the `:`
|
||||
// itself reverts the prompt.
|
||||
while !app.input.is_empty() {
|
||||
app.update(key(KeyCode::Backspace));
|
||||
}
|
||||
let after_revert = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(after_revert.contains("SIMPLE"));
|
||||
assert!(!after_revert.contains("Advanced:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_lists_quit_and_submit_in_all_modes() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
let simple = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(simple.contains("Enter"), "status bar lists Enter");
|
||||
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
||||
assert!(simple.contains("mode advanced"));
|
||||
|
||||
type_str(&mut app, "mode advanced");
|
||||
submit(&mut app);
|
||||
let advanced = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(advanced.contains("Enter"));
|
||||
assert!(advanced.contains("Ctrl-C"));
|
||||
assert!(advanced.contains("mode simple"));
|
||||
}
|
||||
Reference in New Issue
Block a user