feat(tui): add CLI/TUI parity tests (bd-wrw1)

10 parity tests verifying TUI and CLI query paths return consistent
results from the same SQLite database:
- Dashboard count parity (issues, MRs, discussions, notes)
- Issue list parity (IID ordering, state/author filters, ascending sort)
- MR list parity (IID ordering, state filter)
- Shared field parity (title, state, author, project_path)
- Empty database handling
- Terminal safety sanitization (dangerous sequences stripped)

Uses full-schema in-memory DB via create_connection + run_migrations.
Closes bd-wrw1, bd-2o49 (Phase 5.6 epic).
This commit is contained in:
teernisse
2026-02-19 07:49:59 -05:00
parent 8d24138655
commit 3e96f19a11
5 changed files with 1387 additions and 3 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
bd-1b6k
bd-2o49

View File

@@ -0,0 +1,710 @@
//! CLI/TUI parity tests (bd-wrw1).
//!
//! Verifies that the TUI action layer and CLI query layer return consistent
//! results when querying the same SQLite database. Both paths read the same
//! tables with different query strategies (TUI uses keyset pagination, CLI
//! uses LIMIT-based pagination), so given identical data and filters they
//! must agree on entity IIDs, ordering, and counts.
//!
//! Uses `lore::core::db::{create_connection, run_migrations}` for a
//! full-schema in-memory database with deterministic seed data.
use std::path::Path;
use rusqlite::Connection;
use lore::cli::commands::{ListFilters, MrListFilters, query_issues, query_mrs};
use lore::core::db::{create_connection, run_migrations};
use lore_tui::action::{fetch_dashboard, fetch_issue_list, fetch_mr_list};
use lore_tui::clock::FakeClock;
use lore_tui::state::issue_list::{IssueFilter, SortField, SortOrder};
use lore_tui::state::mr_list::{MrFilter, MrSortField, MrSortOrder};
use chrono::{TimeZone, Utc};
// ---------------------------------------------------------------------------
// Setup: in-memory database with full schema and seed data
// ---------------------------------------------------------------------------
fn test_conn() -> Connection {
let conn = create_connection(Path::new(":memory:")).expect("create in-memory connection");
run_migrations(&conn).expect("run migrations");
conn
}
fn frozen_clock() -> FakeClock {
FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap())
}
/// Insert a project and return its id.
fn insert_project(conn: &Connection, id: i64, path: &str) -> i64 {
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
VALUES (?1, ?1, ?2, ?3)",
rusqlite::params![id, path, format!("https://gitlab.com/{path}")],
)
.expect("insert project");
id
}
/// Test issue row for insertion.
struct TestIssue<'a> {
id: i64,
project_id: i64,
iid: i64,
title: &'a str,
state: &'a str,
author: &'a str,
created_at: i64,
updated_at: i64,
}
/// Insert a test issue.
fn insert_issue(conn: &Connection, issue: &TestIssue<'_>) {
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state,
author_username, created_at, updated_at, last_seen_at, web_url)
VALUES (?1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, NULL)",
rusqlite::params![
issue.id,
issue.project_id,
issue.iid,
issue.title,
issue.state,
issue.author,
issue.created_at,
issue.updated_at
],
)
.expect("insert issue");
}
/// Test MR row for insertion.
struct TestMr<'a> {
id: i64,
project_id: i64,
iid: i64,
title: &'a str,
state: &'a str,
draft: bool,
author: &'a str,
source_branch: &'a str,
target_branch: &'a str,
created_at: i64,
updated_at: i64,
}
/// Insert a test merge request.
fn insert_mr(conn: &Connection, mr: &TestMr<'_>) {
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state,
draft, author_username, source_branch, target_branch,
created_at, updated_at, last_seen_at, web_url)
VALUES (?1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, NULL)",
rusqlite::params![
mr.id,
mr.project_id,
mr.iid,
mr.title,
mr.state,
i64::from(mr.draft),
mr.author,
mr.source_branch,
mr.target_branch,
mr.created_at,
mr.updated_at
],
)
.expect("insert mr");
}
/// Insert a test discussion.
fn insert_discussion(conn: &Connection, id: i64, project_id: i64, issue_id: i64) {
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id,
noteable_type, last_seen_at)
VALUES (?1, ?1, ?2, ?3, 'Issue', 1000)",
rusqlite::params![id, project_id, issue_id],
)
.expect("insert discussion");
}
/// Insert a test note.
fn insert_note(conn: &Connection, id: i64, discussion_id: i64, project_id: i64, is_system: bool) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system,
author_username, body, created_at, updated_at, last_seen_at)
VALUES (?1, ?1, ?2, ?3, ?4, 'author', 'body', 1000, 1000, 1000)",
rusqlite::params![id, discussion_id, project_id, i64::from(is_system)],
)
.expect("insert note");
}
/// Seed the database with a deterministic fixture set.
///
/// Creates:
/// - 1 project
/// - 10 issues (5 opened, 5 closed, various authors/timestamps)
/// - 5 merge requests (3 opened, 1 merged, 1 closed)
/// - 3 discussions + 6 notes (2 system)
fn seed_fixture(conn: &Connection) {
let pid = insert_project(conn, 1, "group/repo");
// Issues: iid 1..=10, alternating state, varying timestamps.
let base_ts: i64 = 1_700_000_000_000; // ~Nov 2023
for i in 1..=10 {
let state = if i % 2 == 0 { "closed" } else { "opened" };
let author = if i <= 5 { "alice" } else { "bob" };
let created = base_ts + i * 60_000;
let updated = base_ts + i * 120_000; // Strictly increasing for deterministic sort.
let title = format!("Issue {i}");
insert_issue(
conn,
&TestIssue {
id: i,
project_id: pid,
iid: i,
title: &title,
state,
author,
created_at: created,
updated_at: updated,
},
);
}
// Merge requests: iid 1..=5.
for i in 1..=5 {
let (state, draft) = match i {
1..=3 => ("opened", i == 2),
4 => ("merged", false),
_ => ("closed", false),
};
let created = base_ts + i * 60_000;
let updated = base_ts + i * 120_000;
let title = format!("MR {i}");
let source = format!("feature-{i}");
insert_mr(
conn,
&TestMr {
id: 100 + i,
project_id: pid,
iid: i,
title: &title,
state,
draft,
author: "alice",
source_branch: &source,
target_branch: "main",
created_at: created,
updated_at: updated,
},
);
}
// Discussions + notes (for count parity).
for d in 1..=3 {
insert_discussion(conn, d, pid, d); // discussions on issues 1..3
// 2 notes per discussion.
let n1 = d * 10;
let n2 = d * 10 + 1;
insert_note(conn, n1, d, pid, false);
insert_note(conn, n2, d, pid, d == 1); // discussion 1 gets a system note
}
}
// ---------------------------------------------------------------------------
// Default CLI filters (no filtering, default sort)
// ---------------------------------------------------------------------------
fn default_issue_filters() -> ListFilters<'static> {
ListFilters {
limit: 100,
project: None,
state: None,
author: None,
assignee: None,
labels: None,
milestone: None,
since: None,
due_before: None,
has_due_date: false,
statuses: &[],
sort: "updated",
order: "desc",
}
}
fn default_mr_filters() -> MrListFilters<'static> {
MrListFilters {
limit: 100,
project: None,
state: None,
author: None,
assignee: None,
reviewer: None,
labels: None,
since: None,
draft: false,
no_draft: false,
target_branch: None,
source_branch: None,
sort: "updated",
order: "desc",
}
}
// ---------------------------------------------------------------------------
// Parity Tests
// ---------------------------------------------------------------------------
/// Count parity: TUI dashboard entity counts match direct SQL (CLI logic).
///
/// The TUI fetches counts via `fetch_dashboard().counts`, while the CLI uses
/// private `count_issues`/`count_mrs` with simple COUNT(*) queries. Since both
/// query the same tables, counts must agree.
#[test]
fn test_count_parity_dashboard_vs_sql() {
let conn = test_conn();
seed_fixture(&conn);
// TUI path: fetch_dashboard returns EntityCounts.
let clock = frozen_clock();
let dashboard = fetch_dashboard(&conn, &clock).expect("fetch_dashboard");
let counts = &dashboard.counts;
// CLI-equivalent: direct SQL matching the CLI's count logic.
let issues_total: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
.unwrap();
let issues_open: i64 = conn
.query_row(
"SELECT COUNT(*) FROM issues WHERE state = 'opened'",
[],
|r| r.get(0),
)
.unwrap();
let mrs_total: i64 = conn
.query_row("SELECT COUNT(*) FROM merge_requests", [], |r| r.get(0))
.unwrap();
let mrs_open: i64 = conn
.query_row(
"SELECT COUNT(*) FROM merge_requests WHERE state = 'opened'",
[],
|r| r.get(0),
)
.unwrap();
let discussions: i64 = conn
.query_row("SELECT COUNT(*) FROM discussions", [], |r| r.get(0))
.unwrap();
let notes_total: i64 = conn
.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))
.unwrap();
assert_eq!(counts.issues_total, issues_total as u64, "issues_total");
assert_eq!(counts.issues_open, issues_open as u64, "issues_open");
assert_eq!(counts.mrs_total, mrs_total as u64, "mrs_total");
assert_eq!(counts.mrs_open, mrs_open as u64, "mrs_open");
assert_eq!(counts.discussions, discussions as u64, "discussions");
assert_eq!(counts.notes_total, notes_total as u64, "notes_total");
// Verify known fixture counts.
assert_eq!(counts.issues_total, 10);
assert_eq!(counts.issues_open, 5); // odd IIDs are opened
assert_eq!(counts.mrs_total, 5);
assert_eq!(counts.mrs_open, 3); // iid 1,2,3 opened
assert_eq!(counts.discussions, 3);
assert_eq!(counts.notes_total, 6); // 2 per discussion
}
/// Issue list parity: TUI and CLI return the same IIDs in the same order.
///
/// TUI uses keyset pagination (`fetch_issue_list`), CLI uses LIMIT-based
/// (`query_issues`). Both sorted by updated_at DESC with no filters.
#[test]
fn test_issue_list_parity_iids_and_order() {
let conn = test_conn();
seed_fixture(&conn);
// CLI path.
let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI query_issues");
let cli_iids: Vec<i64> = cli_result.issues.iter().map(|r| r.iid).collect();
// TUI path: first page (no cursor, no fence — equivalent to CLI's initial query).
let tui_page = fetch_issue_list(
&conn,
&IssueFilter::default(),
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.expect("TUI fetch_issue_list");
let tui_iids: Vec<i64> = tui_page.rows.iter().map(|r| r.iid).collect();
// Both should see all 10 issues in the same descending updated_at order.
assert_eq!(cli_result.total_count, 10);
assert_eq!(tui_page.total_count, 10);
assert_eq!(
cli_iids, tui_iids,
"CLI and TUI issue IID order must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}"
);
// Verify descending order (iid 10 has highest updated_at).
assert_eq!(cli_iids[0], 10, "most recently updated should be iid 10");
assert_eq!(
*cli_iids.last().unwrap(),
1,
"oldest updated should be iid 1"
);
}
/// Issue list parity with state filter: both paths agree on filtered results.
#[test]
fn test_issue_list_parity_state_filter() {
let conn = test_conn();
seed_fixture(&conn);
// CLI: filter state = "opened".
let mut cli_filters = default_issue_filters();
cli_filters.state = Some("opened");
let cli_result = query_issues(&conn, &cli_filters).expect("CLI opened issues");
let cli_iids: Vec<i64> = cli_result.issues.iter().map(|r| r.iid).collect();
// TUI: filter state = "opened".
let tui_filter = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
let tui_page = fetch_issue_list(
&conn,
&tui_filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.expect("TUI opened issues");
let tui_iids: Vec<i64> = tui_page.rows.iter().map(|r| r.iid).collect();
assert_eq!(cli_result.total_count, 5, "CLI count for opened");
assert_eq!(tui_page.total_count, 5, "TUI count for opened");
assert_eq!(
cli_iids, tui_iids,
"Filtered IIDs must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}"
);
// All returned IIDs should be odd (our fixture alternates).
for iid in &cli_iids {
assert!(
iid % 2 == 1,
"opened issues should have odd IIDs, got {iid}"
);
}
}
/// Issue list parity with author filter.
#[test]
fn test_issue_list_parity_author_filter() {
let conn = test_conn();
seed_fixture(&conn);
// CLI: filter author = "alice" (issues 1..=5).
let mut cli_filters = default_issue_filters();
cli_filters.author = Some("alice");
let cli_result = query_issues(&conn, &cli_filters).expect("CLI alice issues");
let cli_iids: Vec<i64> = cli_result.issues.iter().map(|r| r.iid).collect();
// TUI: filter author = "alice".
let tui_filter = IssueFilter {
author: Some("alice".into()),
..Default::default()
};
let tui_page = fetch_issue_list(
&conn,
&tui_filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.expect("TUI alice issues");
let tui_iids: Vec<i64> = tui_page.rows.iter().map(|r| r.iid).collect();
assert_eq!(cli_result.total_count, 5, "CLI count for alice");
assert_eq!(tui_page.total_count, 5, "TUI count for alice");
assert_eq!(cli_iids, tui_iids, "Author-filtered IIDs must match");
// All returned IIDs should be <= 5 (alice authors issues 1-5).
for iid in &cli_iids {
assert!(*iid <= 5, "alice issues should have IID <= 5, got {iid}");
}
}
/// MR list parity: TUI and CLI return the same IIDs in the same order.
#[test]
fn test_mr_list_parity_iids_and_order() {
let conn = test_conn();
seed_fixture(&conn);
// CLI path.
let cli_result = query_mrs(&conn, &default_mr_filters()).expect("CLI query_mrs");
let cli_iids: Vec<i64> = cli_result.mrs.iter().map(|r| r.iid).collect();
// TUI path.
let tui_page = fetch_mr_list(
&conn,
&MrFilter::default(),
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.expect("TUI fetch_mr_list");
let tui_iids: Vec<i64> = tui_page.rows.iter().map(|r| r.iid).collect();
assert_eq!(cli_result.total_count, 5, "CLI MR count");
assert_eq!(tui_page.total_count, 5, "TUI MR count");
assert_eq!(
cli_iids, tui_iids,
"CLI and TUI MR IID order must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}"
);
// Verify descending order.
assert_eq!(cli_iids[0], 5, "most recently updated MR should be iid 5");
}
/// MR list parity with state filter.
#[test]
fn test_mr_list_parity_state_filter() {
let conn = test_conn();
seed_fixture(&conn);
// CLI: filter state = "opened".
let mut cli_filters = default_mr_filters();
cli_filters.state = Some("opened");
let cli_result = query_mrs(&conn, &cli_filters).expect("CLI opened MRs");
let cli_iids: Vec<i64> = cli_result.mrs.iter().map(|r| r.iid).collect();
// TUI: filter state = "opened".
let tui_filter = MrFilter {
state: Some("opened".into()),
..Default::default()
};
let tui_page = fetch_mr_list(
&conn,
&tui_filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.expect("TUI opened MRs");
let tui_iids: Vec<i64> = tui_page.rows.iter().map(|r| r.iid).collect();
assert_eq!(cli_result.total_count, 3, "CLI opened MR count");
assert_eq!(tui_page.total_count, 3, "TUI opened MR count");
assert_eq!(cli_iids, tui_iids, "Opened MR IIDs must match");
}
/// Shared field parity: verify overlapping fields agree between CLI and TUI.
///
/// CLI IssueListRow has more fields (discussion_count, assignees, web_url),
/// but the shared fields (iid, title, state, author) must be identical.
#[test]
fn test_issue_shared_fields_parity() {
let conn = test_conn();
seed_fixture(&conn);
let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI issues");
let tui_page = fetch_issue_list(
&conn,
&IssueFilter::default(),
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.expect("TUI issues");
assert_eq!(
cli_result.issues.len(),
tui_page.rows.len(),
"row count must match"
);
for (cli_row, tui_row) in cli_result.issues.iter().zip(tui_page.rows.iter()) {
assert_eq!(cli_row.iid, tui_row.iid, "IID mismatch");
assert_eq!(
cli_row.title, tui_row.title,
"title mismatch for iid {}",
cli_row.iid
);
assert_eq!(
cli_row.state, tui_row.state,
"state mismatch for iid {}",
cli_row.iid
);
assert_eq!(
cli_row.author_username, tui_row.author,
"author mismatch for iid {}",
cli_row.iid
);
assert_eq!(
cli_row.project_path, tui_row.project_path,
"project_path mismatch for iid {}",
cli_row.iid
);
}
}
/// Sort order parity: ascending sort returns the same order in both paths.
#[test]
fn test_issue_list_parity_ascending_sort() {
let conn = test_conn();
seed_fixture(&conn);
// CLI: ascending by updated_at.
let mut cli_filters = default_issue_filters();
cli_filters.order = "asc";
let cli_result = query_issues(&conn, &cli_filters).expect("CLI asc issues");
let cli_iids: Vec<i64> = cli_result.issues.iter().map(|r| r.iid).collect();
// TUI: ascending by updated_at.
let tui_page = fetch_issue_list(
&conn,
&IssueFilter::default(),
SortField::UpdatedAt,
SortOrder::Asc,
None,
None,
)
.expect("TUI asc issues");
let tui_iids: Vec<i64> = tui_page.rows.iter().map(|r| r.iid).collect();
assert_eq!(
cli_iids, tui_iids,
"Ascending sort order must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}"
);
// Ascending: iid 1 has lowest updated_at.
assert_eq!(cli_iids[0], 1);
assert_eq!(*cli_iids.last().unwrap(), 10);
}
/// Empty database parity: both paths handle zero rows gracefully.
#[test]
fn test_empty_database_parity() {
let conn = test_conn();
// No seed — empty DB.
// Dashboard counts should all be zero.
let clock = frozen_clock();
let dashboard = fetch_dashboard(&conn, &clock).expect("fetch_dashboard empty");
assert_eq!(dashboard.counts.issues_total, 0);
assert_eq!(dashboard.counts.mrs_total, 0);
assert_eq!(dashboard.counts.discussions, 0);
assert_eq!(dashboard.counts.notes_total, 0);
// Issue list: both empty.
let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI empty");
let tui_page = fetch_issue_list(
&conn,
&IssueFilter::default(),
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.expect("TUI empty");
assert_eq!(cli_result.total_count, 0);
assert_eq!(tui_page.total_count, 0);
assert!(cli_result.issues.is_empty());
assert!(tui_page.rows.is_empty());
// MR list: both empty.
let cli_mrs = query_mrs(&conn, &default_mr_filters()).expect("CLI empty MRs");
let tui_mrs = fetch_mr_list(
&conn,
&MrFilter::default(),
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.expect("TUI empty MRs");
assert_eq!(cli_mrs.total_count, 0);
assert_eq!(tui_mrs.total_count, 0);
}
/// Sanitization: TUI safety module strips dangerous escape sequences
/// while preserving safe SGR. Both paths return raw data from the DB,
/// and the safety module is applied at the view layer.
#[test]
fn test_sanitization_dangerous_sequences_stripped() {
let conn = test_conn();
insert_project(&conn, 1, "group/repo");
// Dangerous title: cursor movement (CSI 2A = move up 2) + bidi override.
let dangerous_title = "normal\x1b[2Ahidden\u{202E}reversed";
insert_issue(
&conn,
&TestIssue {
id: 1,
project_id: 1,
iid: 1,
title: dangerous_title,
state: "opened",
author: "alice",
created_at: 1000,
updated_at: 2000,
},
);
// Both CLI and TUI data layers return raw titles.
let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI dangerous issue");
let tui_page = fetch_issue_list(
&conn,
&IssueFilter::default(),
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.expect("TUI dangerous issue");
// Data layer parity: both return the raw title.
assert_eq!(cli_result.issues[0].title, dangerous_title);
assert_eq!(tui_page.rows[0].title, dangerous_title);
// Safety module strips dangerous sequences but preserves text.
use lore_tui::safety::{UrlPolicy, sanitize_for_terminal};
let sanitized = sanitize_for_terminal(&tui_page.rows[0].title, UrlPolicy::Strip);
// Cursor movement sequence (ESC[2A) should be stripped.
assert!(
!sanitized.contains('\x1b'),
"sanitized should have no ESC: {sanitized:?}"
);
// Bidi override (U+202E) should be stripped.
assert!(
!sanitized.contains('\u{202E}'),
"sanitized should have no bidi overrides: {sanitized:?}"
);
// Safe text should be preserved.
assert!(
sanitized.contains("normal"),
"should preserve 'normal': {sanitized:?}"
);
assert!(
sanitized.contains("hidden"),
"should preserve 'hidden': {sanitized:?}"
);
assert!(
sanitized.contains("reversed"),
"should preserve 'reversed': {sanitized:?}"
);
}