Files
rdbms-playground/tests/iteration6_resume_history.rs
T
claude@clouddev1 720511ef29 ADR-0019 §9 sweep (2/2): help blocks + modals + system notes
Final pass of the i18n migration sweep. Every user-visible
string in `src/` now flows through the catalog via `t!()`.

## Categories migrated in this commit

- **help.cli_banner** — the entire `cli::HELP_TEXT` const,
  formerly a 40-line `&'static str`, is now a YAML block in
  the catalog. The const is replaced by a thin
  `cli::help_text() -> String` wrapper that performs the
  catalog lookup. `main.rs` calls `help_text()` for both
  `--help` output and the args-parse error path. The two
  integration tests that referenced `HELP_TEXT` directly are
  updated.
- **help.in_app_body** — the in-app `help` command's body is
  one YAML block; `note_help` becomes 5 lines that iterate
  the lines and emit each as its own output row (preserving
  the renderer's "one logical line = one display row"
  invariant for accurate scroll math).
- **modal.*** — load picker, rebuild confirm, and save-as
  path-entry strings: rebuild_cancelled, load_cancelled,
  generic_cancelled, load_picker_nothing,
  path_entry_empty_name, path_entry_empty_path.
- **dsl.failed** — the `"<verb> <subject>" failed: <rendered>`
  wrapper around the friendly-error layer's translated
  message.
- **dsl.running** — the `running: <input>` echo line shown
  above each command's response. (Note: the en-US prefix
  "running: " is hardcoded in the parse-error caret-padding
  calculation. Translators changing the prefix must keep the
  width consistent — documented inline.)
- **advanced_mode.not_implemented** — the placeholder echo
  shown when SQL hits the unimplemented advanced-mode path
  (Q1 territory).
- **fatal.persistence** — the FATAL banner for
  PersistenceFatal events (ADR-0015 §8).
- **project.{load_path_missing,saveas_target_exists,**
  **import_zip_missing}** — runtime-side project-switch
  validation errors that surface via ProjectSwitchFailed.

## Catalog start-up ordering

`main.rs` now calls `friendly::catalog()` at the very top
(before args parsing) so `help_text()` works in both the
success path and the args-error path. A corrupted build
artefact still fails loudly with a useful panic; the
practical risk is essentially zero since the catalog is
`include_str!`'d at compile time and validated by the unit
test before shipping.

## Remaining literals

The only `note_*` calls in `src/` that still pass plain
strings are inside `#[cfg(test)]` modules — synthetic test
fixtures, not user-visible. The codebase passes the "every
user-visible string flows through the catalog" bar.

## Tally

610 tests passing (no change in count — pure refactor).
Clippy clean with nursery lints.

## What this closes

ADR-0019 §9 (migration sweep) — done.

ADR-0019 itself is now fully implemented:
- §1-§5: catalog + translator + voice + verbosity ✓ (`eac7e5b`)
- §6: row pinpointing + schema enrichment ✓ (`431645a`)
- §9: migration sweep ✓ (this + `aff528a`)
- §10: anchor phrases preserved throughout ✓
- The five "Out of scope" items remain explicitly bounded
  to future ADRs (advanced-mode SQL, settings persistence,
  pluralisation, runtime locale, value formatting,
  constraint management).
2026-05-09 22:29:28 +00:00

215 lines
7.1 KiB
Rust

//! Iteration-6 integration tests: `--resume` + persistent
//! input history + migration framework scaffold (ADR-0015 §7,
//! §9, §12).
//!
//! Boots no Tokio runtime and no terminal — these tests
//! exercise the persistent state behind `--resume` (the
//! `last_project` file under the data root) and the input
//! history hydration off `history.log`.
use std::fs;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App;
use rdbms_playground::cli::{Args, ArgsError};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, LAST_PROJECT_FILE, Project, read_last_project, write_last_project,
};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
// --- Args parsing for --resume ---------------------------------
#[test]
fn args_parses_resume_flag() {
let a = Args::parse(["--resume"]).unwrap();
assert!(a.resume);
assert!(a.project_path.is_none());
}
#[test]
fn args_resume_with_positional_path_is_an_error() {
let err = Args::parse(["--resume", "/tmp/foo"]).unwrap_err();
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
#[test]
fn args_resume_after_positional_path_also_errors() {
let err = Args::parse(["/tmp/foo", "--resume"]).unwrap_err();
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
#[test]
fn args_help_listing_mentions_resume() {
assert!(rdbms_playground::cli::help_text().contains("--resume"));
}
// --- last_project read/write ----------------------------------
#[test]
fn last_project_round_trips_through_disk() {
let tmp = tempdir();
let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap();
write_last_project(tmp.path(), &target).unwrap();
let on_disk = fs::read_to_string(tmp.path().join(LAST_PROJECT_FILE)).unwrap();
assert!(on_disk.contains("MyProject"));
assert_eq!(read_last_project(tmp.path()).unwrap(), Some(target));
}
#[test]
fn last_project_is_overwritten_each_call() {
let tmp = tempdir();
let a = tmp.path().join("A");
let b = tmp.path().join("B");
fs::create_dir(&a).unwrap();
fs::create_dir(&b).unwrap();
write_last_project(tmp.path(), &a).unwrap();
write_last_project(tmp.path(), &b).unwrap();
assert_eq!(read_last_project(tmp.path()).unwrap(), Some(b));
}
#[test]
fn last_project_create_temp_path_resolves_to_existing_dir() {
// Sanity: the path we record is in fact something that
// exists when --resume tries to reopen it. This protects
// against future refactors that might write a placeholder.
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
write_last_project(tmp.path(), project.path()).unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back.as_deref(), Some(project.path()));
assert!(read_back.unwrap().exists());
}
#[test]
fn read_last_project_handles_missing_data_root_directory() {
let tmp = tempdir();
let nested = tmp.path().join("does/not/exist/yet");
// Reading from a directory that hasn't been created at
// all should be Ok(None), not an error — the runtime's
// first launch lands here.
assert!(read_last_project(&nested).unwrap().is_none());
}
// --- Stale path on resume: read returns Some(path) but the
// path does not exist. The runtime is responsible for
// surfacing this; we verify the building block here.
#[test]
fn last_project_returns_stale_path_verbatim_for_runtime_to_detect() {
let tmp = tempdir();
let stale = tmp.path().join("Vanished");
write_last_project(tmp.path(), &stale).unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back.as_deref(), Some(stale.as_path()));
assert!(!stale.exists());
}
// --- Project lifecycle writes last_project ---------------------
// (Smoke test: launching open_or_create then opening again
// should be the same as write_last_project + reopen.)
// --- History hydration on project open ----------------------
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
#[test]
fn read_recent_history_returns_empty_when_log_missing() {
let tmp = tempdir();
let p = Persistence::new(tmp.path().to_path_buf());
let entries = p.read_recent_history(10).unwrap();
assert!(entries.is_empty());
}
#[test]
fn read_recent_history_returns_appended_entries_in_order() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap();
p.append_history("create table B with pk").unwrap();
p.append_history("create table C with pk").unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
vec![
"create table A with pk".to_string(),
"create table B with pk".to_string(),
"create table C with pk".to_string(),
]
);
}
#[test]
fn seed_history_replaces_in_memory_history() {
let mut app = App::new();
// Pre-existing in-session entries — should be replaced.
for c in "abc".chars() {
app.update(key(KeyCode::Char(c)));
}
app.update(key(KeyCode::Enter));
assert_eq!(app.history, vec!["abc".to_string()]);
app.seed_history(vec!["x".to_string(), "y".to_string()]);
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
}
#[test]
fn seed_history_preserves_chronological_order_for_navigation() {
let mut app = App::new();
app.seed_history(vec![
"old".to_string(),
"middle".to_string(),
"newest".to_string(),
]);
// Up should recall "newest" first (the most recent
// entry, which is at the back of the vec by convention).
app.update(key(KeyCode::Up));
assert_eq!(app.input, "newest");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "middle");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "old");
}
#[test]
fn project_switched_event_seeds_history_from_payload() {
let mut app = App::new();
app.update(AppEvent::ProjectSwitched {
display_name: "Foo".to_string(),
is_temp: false,
history_entries: vec!["aa".to_string(), "bb".to_string()],
});
assert_eq!(app.history, vec!["aa".to_string(), "bb".to_string()]);
// Up navigates within the seeded entries.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "bb");
}
#[test]
fn data_root_with_no_last_project_is_resume_safe() {
let tmp = tempdir();
// Fresh data root with no projects, no last_project.
let _project = project::open_or_create(None, Some(tmp.path())).unwrap();
// open_or_create itself doesn't write last_project (the
// runtime does, after a successful open). That's fine —
// the runtime test would write it. Verify that
// read_last_project here returns None as expected.
assert!(read_last_project(tmp.path()).unwrap().is_none());
}