feat(tui): add soak + pagination race tests (bd-14hv)
7 soak tests: 50k-event sustained load, watchdog timeout, render interleaving, screen cycling, mode oscillation, depth bounds, multi-seed. 7 pagination race tests: concurrent read/write with snapshot fence, multi-reader, within-fence writes, stress 1000 iterations.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-wnuo
|
bd-14hv
|
||||||
|
|||||||
671
crates/lore-tui/tests/pagination_race_test.rs
Normal file
671
crates/lore-tui/tests/pagination_race_test.rs
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
//! Concurrent pagination/write race tests (bd-14hv).
|
||||||
|
//!
|
||||||
|
//! Proves that the keyset pagination + snapshot fence mechanism prevents
|
||||||
|
//! duplicate or skipped rows when a writer inserts new issues concurrently
|
||||||
|
//! with a reader paginating through the issue list.
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! - DbManager (3 readers + 1 writer) with WAL mode
|
||||||
|
//! - Reader threads: paginate using `fetch_issue_list()` with keyset cursor
|
||||||
|
//! - Writer thread: INSERT new issues concurrently
|
||||||
|
//! - Assertions: no duplicate IIDs, snapshot fence excludes new writes
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use lore_tui::action::fetch_issue_list;
|
||||||
|
use lore_tui::db::DbManager;
|
||||||
|
use lore_tui::state::issue_list::{IssueFilter, IssueListState, SortField, SortOrder};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test infrastructure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static DB_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn test_db_path() -> PathBuf {
|
||||||
|
let n = DB_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join("lore-tui-pagination-tests");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create test dir");
|
||||||
|
dir.join(format!(
|
||||||
|
"race-{}-{:?}-{n}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the schema needed for issue list queries.
|
||||||
|
fn create_schema(conn: &Connection) {
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
||||||
|
path_with_namespace TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE issues (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
iid INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
author_username TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE labels (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
gitlab_id INTEGER,
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE issue_labels (
|
||||||
|
issue_id INTEGER NOT NULL,
|
||||||
|
label_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(issue_id, label_id)
|
||||||
|
);
|
||||||
|
INSERT INTO projects (gitlab_project_id, path_with_namespace)
|
||||||
|
VALUES (1, 'group/project');
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.expect("create schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert N issues with sequential IIDs starting from `start_iid`.
|
||||||
|
///
|
||||||
|
/// Each issue gets `updated_at = base_ts - (offset * 1000)` to create
|
||||||
|
/// a deterministic ordering for keyset pagination (newest first).
|
||||||
|
fn seed_issues(conn: &Connection, start_iid: i64, count: i64, base_ts: i64) {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO issues (gitlab_id, project_id, iid, title, state,
|
||||||
|
author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'opened', 'alice', ?4, ?4, ?4)",
|
||||||
|
)
|
||||||
|
.expect("prepare insert");
|
||||||
|
|
||||||
|
for i in 0..count {
|
||||||
|
let iid = start_iid + i;
|
||||||
|
let ts = base_ts - (i * 1000);
|
||||||
|
stmt.execute(rusqlite::params![
|
||||||
|
iid * 100, // gitlab_id
|
||||||
|
iid,
|
||||||
|
format!("Issue {iid}"),
|
||||||
|
ts,
|
||||||
|
])
|
||||||
|
.expect("insert issue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Paginate through all issues without concurrent writes.
|
||||||
|
///
|
||||||
|
/// Baseline: keyset pagination yields every IID exactly once.
|
||||||
|
#[test]
|
||||||
|
fn test_pagination_no_duplicates_baseline() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open db");
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 200, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Paginate through all issues collecting IIDs.
|
||||||
|
let mut all_iids = Vec::new();
|
||||||
|
let mut state = IssueListState::default();
|
||||||
|
let filter = IssueFilter::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let page = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
state.next_cursor.as_ref(),
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("fetch page");
|
||||||
|
|
||||||
|
if page.rows.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in &page.rows {
|
||||||
|
all_iids.push(row.iid);
|
||||||
|
}
|
||||||
|
state.apply_page(page);
|
||||||
|
|
||||||
|
if state.next_cursor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every IID 1..=200 should appear exactly once.
|
||||||
|
let unique: HashSet<i64> = all_iids.iter().copied().collect();
|
||||||
|
assert_eq!(
|
||||||
|
unique.len(),
|
||||||
|
200,
|
||||||
|
"Expected 200 unique IIDs, got {}",
|
||||||
|
unique.len()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
all_iids.len(),
|
||||||
|
200,
|
||||||
|
"Expected 200 total IIDs, got {} (duplicates present)",
|
||||||
|
all_iids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concurrent writer inserts NEW issues (with future timestamps) while
|
||||||
|
/// reader paginates. Snapshot fence should exclude the new rows.
|
||||||
|
#[test]
|
||||||
|
fn test_pagination_no_duplicates_with_concurrent_writes() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = Arc::new(DbManager::open(&path).expect("open db"));
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
// Seed 200 issues.
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 200, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Barrier to synchronize reader and writer start.
|
||||||
|
let barrier = Arc::new(Barrier::new(2));
|
||||||
|
|
||||||
|
// Writer thread: inserts issues with NEWER timestamps (above the fence).
|
||||||
|
let db_w = Arc::clone(&db);
|
||||||
|
let barrier_w = Arc::clone(&barrier);
|
||||||
|
let writer = std::thread::spawn(move || {
|
||||||
|
barrier_w.wait();
|
||||||
|
for batch in 0..10 {
|
||||||
|
db_w.with_writer(|conn| {
|
||||||
|
for i in 0..10 {
|
||||||
|
let iid = 1000 + batch * 10 + i;
|
||||||
|
// Future timestamp: above the snapshot fence.
|
||||||
|
let ts = base_ts + 100_000 + (batch * 10 + i) * 1000;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (gitlab_id, project_id, iid, title, state,
|
||||||
|
author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'opened', 'writer', ?4, ?4, ?4)",
|
||||||
|
rusqlite::params![iid * 100, iid, format!("New {iid}"), ts],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("writer batch");
|
||||||
|
// Small yield to interleave with reader.
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reader thread: paginate with snapshot fence.
|
||||||
|
let db_r = Arc::clone(&db);
|
||||||
|
let barrier_r = Arc::clone(&barrier);
|
||||||
|
let reader = std::thread::spawn(move || {
|
||||||
|
barrier_r.wait();
|
||||||
|
|
||||||
|
let mut all_iids = Vec::new();
|
||||||
|
let mut state = IssueListState::default();
|
||||||
|
let filter = IssueFilter::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let page = db_r
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
state.next_cursor.as_ref(),
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("fetch page");
|
||||||
|
|
||||||
|
if page.rows.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in &page.rows {
|
||||||
|
all_iids.push(row.iid);
|
||||||
|
}
|
||||||
|
state.apply_page(page);
|
||||||
|
|
||||||
|
// Yield to let writer interleave.
|
||||||
|
std::thread::yield_now();
|
||||||
|
|
||||||
|
if state.next_cursor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all_iids
|
||||||
|
});
|
||||||
|
|
||||||
|
writer.join().expect("writer thread");
|
||||||
|
let all_iids = reader.join().expect("reader thread");
|
||||||
|
|
||||||
|
// The critical invariant: NO DUPLICATES.
|
||||||
|
let unique: HashSet<i64> = all_iids.iter().copied().collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
all_iids.len(),
|
||||||
|
unique.len(),
|
||||||
|
"Duplicate IIDs found in pagination results"
|
||||||
|
);
|
||||||
|
|
||||||
|
// All original issues present.
|
||||||
|
for iid in 1..=200 {
|
||||||
|
assert!(
|
||||||
|
unique.contains(&iid),
|
||||||
|
"Original issue {iid} missing from pagination"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer issues may appear on the first page (before the fence is
|
||||||
|
// established), but should NOT cause duplicates. Count them as a
|
||||||
|
// diagnostic.
|
||||||
|
let writer_count = all_iids.iter().filter(|&&iid| iid >= 1000).count();
|
||||||
|
eprintln!("Writer issues visible through fence: {writer_count} (expected: few or zero)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiple concurrent readers paginating simultaneously — no interference.
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_concurrent_readers() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = Arc::new(DbManager::open(&path).expect("open db"));
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 100, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let barrier = Arc::new(Barrier::new(4));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
for reader_id in 0..4 {
|
||||||
|
let db_r = Arc::clone(&db);
|
||||||
|
let barrier_r = Arc::clone(&barrier);
|
||||||
|
|
||||||
|
handles.push(std::thread::spawn(move || {
|
||||||
|
barrier_r.wait();
|
||||||
|
|
||||||
|
let mut all_iids = Vec::new();
|
||||||
|
let mut state = IssueListState::default();
|
||||||
|
let filter = IssueFilter::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let page = db_r
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
state.next_cursor.as_ref(),
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| panic!("reader {reader_id} fetch failed: {e}"));
|
||||||
|
|
||||||
|
if page.rows.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in &page.rows {
|
||||||
|
all_iids.push(row.iid);
|
||||||
|
}
|
||||||
|
state.apply_page(page);
|
||||||
|
|
||||||
|
if state.next_cursor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all_iids
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, h) in handles.into_iter().enumerate() {
|
||||||
|
let iids = h.join().unwrap_or_else(|_| panic!("reader {i} panicked"));
|
||||||
|
let unique: HashSet<i64> = iids.iter().copied().collect();
|
||||||
|
assert_eq!(iids.len(), unique.len(), "Reader {i} got duplicates");
|
||||||
|
assert_eq!(
|
||||||
|
unique.len(),
|
||||||
|
100,
|
||||||
|
"Reader {i} missed issues: got {}",
|
||||||
|
unique.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot fence invalidation: after `reset_pagination()`, the fence is
|
||||||
|
/// cleared and a new read picks up newly written rows.
|
||||||
|
#[test]
|
||||||
|
fn test_snapshot_fence_invalidated_on_refresh() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open db");
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 10, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// First pagination: snapshot fence set.
|
||||||
|
let mut state = IssueListState::default();
|
||||||
|
let filter = IssueFilter::default();
|
||||||
|
|
||||||
|
let page = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
state.apply_page(page);
|
||||||
|
|
||||||
|
assert_eq!(state.rows.len(), 10);
|
||||||
|
assert!(state.snapshot_fence.is_some());
|
||||||
|
|
||||||
|
// Writer adds new issues with FUTURE timestamps.
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
seed_issues(conn, 100, 5, base_ts + 500_000);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// WITH fence: new issues should NOT appear.
|
||||||
|
let fenced_page = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
None,
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fenced_page.total_count, 10,
|
||||||
|
"Fence should exclude new issues"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manual refresh: reset_pagination clears the fence.
|
||||||
|
state.reset_pagination();
|
||||||
|
assert!(state.snapshot_fence.is_none());
|
||||||
|
|
||||||
|
// WITHOUT fence: new issues should appear.
|
||||||
|
let refreshed_page = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
None,
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
refreshed_page.total_count, 15,
|
||||||
|
"After refresh, should see all 15 issues"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concurrent writer inserts issues with timestamps WITHIN the fence range.
|
||||||
|
///
|
||||||
|
/// This is the edge case: snapshot fence is timestamp-based, not
|
||||||
|
/// transaction-based, so writes with `updated_at <= fence` CAN appear.
|
||||||
|
/// The keyset cursor still prevents duplicates (no row appears twice),
|
||||||
|
/// but newly inserted rows with old timestamps might appear in later pages.
|
||||||
|
///
|
||||||
|
/// This test documents the known behavior.
|
||||||
|
#[test]
|
||||||
|
fn test_concurrent_write_within_fence_range() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = Arc::new(DbManager::open(&path).expect("open db"));
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
// Seed 100 issues spanning base_ts down to base_ts - 99000.
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 100, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let barrier = Arc::new(Barrier::new(2));
|
||||||
|
|
||||||
|
// Writer: insert issues with timestamps WITHIN the existing range.
|
||||||
|
let db_w = Arc::clone(&db);
|
||||||
|
let barrier_w = Arc::clone(&barrier);
|
||||||
|
let writer = std::thread::spawn(move || {
|
||||||
|
barrier_w.wait();
|
||||||
|
for i in 0..20 {
|
||||||
|
db_w.with_writer(|conn| {
|
||||||
|
let iid = 500 + i;
|
||||||
|
// Timestamp within the range of existing issues.
|
||||||
|
let ts = base_ts - 50_000 - i * 100;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (gitlab_id, project_id, iid, title, state,
|
||||||
|
author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'opened', 'writer', ?4, ?4, ?4)",
|
||||||
|
rusqlite::params![iid * 100, iid, format!("Mid {iid}"), ts],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("writer insert");
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reader: paginate with fence.
|
||||||
|
let db_r = Arc::clone(&db);
|
||||||
|
let barrier_r = Arc::clone(&barrier);
|
||||||
|
let reader = std::thread::spawn(move || {
|
||||||
|
barrier_r.wait();
|
||||||
|
|
||||||
|
let mut all_iids = Vec::new();
|
||||||
|
let mut state = IssueListState::default();
|
||||||
|
let filter = IssueFilter::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let page = db_r
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
state.next_cursor.as_ref(),
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("fetch");
|
||||||
|
|
||||||
|
if page.rows.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in &page.rows {
|
||||||
|
all_iids.push(row.iid);
|
||||||
|
}
|
||||||
|
state.apply_page(page);
|
||||||
|
|
||||||
|
std::thread::yield_now();
|
||||||
|
|
||||||
|
if state.next_cursor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all_iids
|
||||||
|
});
|
||||||
|
|
||||||
|
writer.join().expect("writer");
|
||||||
|
let all_iids = reader.join().expect("reader");
|
||||||
|
|
||||||
|
// The critical invariant: NO DUPLICATES regardless of timing.
|
||||||
|
let unique: HashSet<i64> = all_iids.iter().copied().collect();
|
||||||
|
assert_eq!(
|
||||||
|
all_iids.len(),
|
||||||
|
unique.len(),
|
||||||
|
"No duplicate IIDs should appear even with concurrent in-range writes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// All original issues must still be present.
|
||||||
|
for iid in 1..=100 {
|
||||||
|
assert!(unique.contains(&iid), "Original issue {iid} missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stress test: 1000 iterations of concurrent read+write with verification.
|
||||||
|
#[test]
|
||||||
|
fn test_pagination_stress_1000_iterations() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = Arc::new(DbManager::open(&path).expect("open db"));
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 100, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Run 1000 pagination cycles with concurrent writes.
|
||||||
|
let writer_iid = Arc::new(AtomicU64::new(1000));
|
||||||
|
|
||||||
|
for iteration in 0..1000 {
|
||||||
|
// Writer: insert one issue per iteration.
|
||||||
|
let next_iid = writer_iid.fetch_add(1, Ordering::Relaxed) as i64;
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
let ts = base_ts + 100_000 + next_iid * 100;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (gitlab_id, project_id, iid, title, state,
|
||||||
|
author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'opened', 'stress', ?4, ?4, ?4)",
|
||||||
|
rusqlite::params![next_iid * 100, next_iid, format!("Stress {next_iid}"), ts],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("stress write");
|
||||||
|
|
||||||
|
// Reader: paginate first page, verify no duplicates within that page.
|
||||||
|
let page = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&IssueFilter::default(),
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| panic!("iteration {iteration}: fetch failed: {e}"));
|
||||||
|
|
||||||
|
let iids: Vec<i64> = page.rows.iter().map(|r| r.iid).collect();
|
||||||
|
let unique: HashSet<i64> = iids.iter().copied().collect();
|
||||||
|
assert_eq!(
|
||||||
|
iids.len(),
|
||||||
|
unique.len(),
|
||||||
|
"Iteration {iteration}: duplicates within a single page"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background writes do NOT invalidate an active snapshot fence.
|
||||||
|
#[test]
|
||||||
|
fn test_background_writes_dont_invalidate_fence() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open db");
|
||||||
|
let base_ts = 1_700_000_000_000_i64;
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_schema(conn);
|
||||||
|
seed_issues(conn, 1, 50, base_ts);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Initial pagination sets the fence.
|
||||||
|
let mut state = IssueListState::default();
|
||||||
|
let filter = IssueFilter::default();
|
||||||
|
|
||||||
|
let page = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
state.apply_page(page);
|
||||||
|
let original_fence = state.snapshot_fence;
|
||||||
|
|
||||||
|
// Simulate background sync writing 20 new issues.
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
seed_issues(conn, 200, 20, base_ts + 1_000_000);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The state's fence should be unchanged — background writes are invisible.
|
||||||
|
assert_eq!(state.snapshot_fence, original_fence);
|
||||||
|
assert_eq!(state.rows.len(), 50);
|
||||||
|
|
||||||
|
// Re-fetch with the existing fence: still sees only original 50.
|
||||||
|
let fenced = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
fetch_issue_list(
|
||||||
|
conn,
|
||||||
|
&filter,
|
||||||
|
SortField::UpdatedAt,
|
||||||
|
SortOrder::Desc,
|
||||||
|
None,
|
||||||
|
state.snapshot_fence,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fenced.total_count, 50);
|
||||||
|
}
|
||||||
410
crates/lore-tui/tests/soak_test.rs
Normal file
410
crates/lore-tui/tests/soak_test.rs
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
//! Soak test for sustained TUI robustness (bd-14hv).
|
||||||
|
//!
|
||||||
|
//! Drives the TUI through 50,000+ events (navigation, filter, mode switches,
|
||||||
|
//! resize, tick) with FakeClock time acceleration. Verifies:
|
||||||
|
//! - No panic under sustained load
|
||||||
|
//! - No deadlock (watchdog timeout)
|
||||||
|
//! - Navigation stack depth stays bounded (no unbounded memory growth)
|
||||||
|
//! - Input mode stays valid after every event
|
||||||
|
//!
|
||||||
|
//! The soak simulates ~30 minutes of accelerated usage in <5s wall clock.
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use ftui::render::frame::Frame;
|
||||||
|
use ftui::render::grapheme_pool::GraphemePool;
|
||||||
|
use ftui::{Cmd, Event, KeyCode, KeyEvent, Model};
|
||||||
|
|
||||||
|
use lore_tui::app::LoreApp;
|
||||||
|
use lore_tui::clock::FakeClock;
|
||||||
|
use lore_tui::message::{InputMode, Msg, Screen};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn frozen_clock() -> FakeClock {
|
||||||
|
FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_app() -> LoreApp {
|
||||||
|
let mut app = LoreApp::new();
|
||||||
|
app.clock = Box::new(frozen_clock());
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> Msg {
|
||||||
|
Msg::RawEvent(Event::Key(KeyEvent::new(code)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_char(c: char) -> Msg {
|
||||||
|
key(KeyCode::Char(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(w: u16, h: u16) -> Msg {
|
||||||
|
Msg::Resize {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_at(app: &LoreApp, width: u16, height: u16) {
|
||||||
|
let w = width.max(1);
|
||||||
|
let h = height.max(1);
|
||||||
|
let mut pool = GraphemePool::new();
|
||||||
|
let mut frame = Frame::new(w, h, &mut pool);
|
||||||
|
app.view(&mut frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Seeded PRNG (xorshift64)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
Self(seed.wrapping_add(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
let mut x = self.0;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
self.0 = x;
|
||||||
|
x
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range(&mut self, max: u64) -> u64 {
|
||||||
|
self.next() % max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random TUI event from a realistic distribution.
|
||||||
|
///
|
||||||
|
/// Distribution:
|
||||||
|
/// - 50% navigation keys (j/k/up/down/enter/escape/tab)
|
||||||
|
/// - 15% filter/search keys (/, letters, backspace)
|
||||||
|
/// - 10% "go" prefix (g + second key)
|
||||||
|
/// - 10% resize events
|
||||||
|
/// - 10% tick events
|
||||||
|
/// - 5% special keys (ctrl+c excluded to avoid quit)
|
||||||
|
fn random_event(rng: &mut Rng) -> Msg {
|
||||||
|
match rng.range(20) {
|
||||||
|
// Navigation keys (50%)
|
||||||
|
0 | 1 => key(KeyCode::Down),
|
||||||
|
2 | 3 => key(KeyCode::Up),
|
||||||
|
4 => key(KeyCode::Enter),
|
||||||
|
5 => key(KeyCode::Escape),
|
||||||
|
6 => key(KeyCode::Tab),
|
||||||
|
7 => key_char('j'),
|
||||||
|
8 => key_char('k'),
|
||||||
|
9 => key(KeyCode::BackTab),
|
||||||
|
// Filter/search keys (15%)
|
||||||
|
10 => key_char('/'),
|
||||||
|
11 => key_char('a'),
|
||||||
|
12 => key(KeyCode::Backspace),
|
||||||
|
// Go prefix (10%)
|
||||||
|
13 => key_char('g'),
|
||||||
|
14 => key_char('d'),
|
||||||
|
// Resize (10%)
|
||||||
|
15 => {
|
||||||
|
let w = (rng.range(260) + 40) as u16;
|
||||||
|
let h = (rng.range(50) + 10) as u16;
|
||||||
|
resize(w, h)
|
||||||
|
}
|
||||||
|
16 => resize(80, 24),
|
||||||
|
// Tick (10%)
|
||||||
|
17 | 18 => Msg::Tick,
|
||||||
|
// Special keys (5%)
|
||||||
|
_ => match rng.range(6) {
|
||||||
|
0 => key(KeyCode::Home),
|
||||||
|
1 => key(KeyCode::End),
|
||||||
|
2 => key(KeyCode::PageUp),
|
||||||
|
3 => key(KeyCode::PageDown),
|
||||||
|
4 => key_char('G'),
|
||||||
|
_ => key_char('?'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check invariants that must hold after every event.
|
||||||
|
fn check_soak_invariants(app: &LoreApp, event_idx: usize) {
|
||||||
|
// Navigation stack depth >= 1 (always has root).
|
||||||
|
assert!(
|
||||||
|
app.navigation.depth() >= 1,
|
||||||
|
"Soak invariant: nav depth < 1 at event {event_idx}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigation depth bounded (soak shouldn't grow stack unboundedly).
|
||||||
|
// With random escape/pop interspersed, depth should stay reasonable.
|
||||||
|
// We use 500 as a generous upper bound.
|
||||||
|
assert!(
|
||||||
|
app.navigation.depth() <= 500,
|
||||||
|
"Soak invariant: nav depth {} exceeds 500 at event {event_idx}",
|
||||||
|
app.navigation.depth()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Input mode is a valid variant.
|
||||||
|
match &app.input_mode {
|
||||||
|
InputMode::Normal | InputMode::Text | InputMode::Palette | InputMode::GoPrefix { .. } => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumbs match depth.
|
||||||
|
assert_eq!(
|
||||||
|
app.navigation.breadcrumbs().len(),
|
||||||
|
app.navigation.depth(),
|
||||||
|
"Soak invariant: breadcrumbs != depth at event {event_idx}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Soak Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 50,000 random events with invariant checks — no panic, no unbounded growth.
|
||||||
|
///
|
||||||
|
/// Simulates ~30 minutes of sustained TUI usage at accelerated speed.
|
||||||
|
/// If Ctrl+C fires (we exclude it from the event alphabet), we restart.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_50k_events_no_panic() {
|
||||||
|
let seed = 0xDEAD_BEEF_u64;
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let mut app = test_app();
|
||||||
|
|
||||||
|
for event_idx in 0..50_000 {
|
||||||
|
let msg = random_event(&mut rng);
|
||||||
|
let cmd = app.update(msg);
|
||||||
|
|
||||||
|
// If quit fires (shouldn't with our alphabet, but be safe), restart.
|
||||||
|
if matches!(cmd, Cmd::Quit) {
|
||||||
|
app = test_app();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invariants every 100 events (full check is expensive at 50k).
|
||||||
|
if event_idx % 100 == 0 {
|
||||||
|
check_soak_invariants(&app, event_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final invariant check.
|
||||||
|
check_soak_invariants(&app, 50_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soak with interleaved renders — verifies view() never panics.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_with_renders_no_panic() {
|
||||||
|
let seed = 0xCAFE_BABE_u64;
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let mut app = test_app();
|
||||||
|
|
||||||
|
for event_idx in 0..10_000 {
|
||||||
|
let msg = random_event(&mut rng);
|
||||||
|
let cmd = app.update(msg);
|
||||||
|
|
||||||
|
if matches!(cmd, Cmd::Quit) {
|
||||||
|
app = test_app();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render every 50th event.
|
||||||
|
if event_idx % 50 == 0 {
|
||||||
|
let (w, h) = app.state.terminal_size;
|
||||||
|
if w > 0 && h > 0 {
|
||||||
|
render_at(&app, w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watchdog: run the soak in a thread with a timeout.
|
||||||
|
///
|
||||||
|
/// If the soak takes longer than 30 seconds, it's likely deadlocked.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_watchdog_no_deadlock() {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let seed = 0xBAAD_F00D_u64;
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let mut app = test_app();
|
||||||
|
|
||||||
|
for _ in 0..20_000 {
|
||||||
|
let msg = random_event(&mut rng);
|
||||||
|
let cmd = app.update(msg);
|
||||||
|
if matches!(cmd, Cmd::Quit) {
|
||||||
|
app = test_app();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send(()).expect("send completion signal");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait up to 30 seconds.
|
||||||
|
let result = rx.recv_timeout(Duration::from_secs(30));
|
||||||
|
assert!(result.is_ok(), "Soak test timed out — possible deadlock");
|
||||||
|
|
||||||
|
handle.join().expect("soak thread panicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-screen navigation soak: cycle through all screens.
|
||||||
|
///
|
||||||
|
/// Verifies the TUI handles rapid screen switching under sustained load.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_screen_cycling() {
|
||||||
|
let mut app = test_app();
|
||||||
|
|
||||||
|
let screens_to_visit = [
|
||||||
|
Screen::Dashboard,
|
||||||
|
Screen::IssueList,
|
||||||
|
Screen::MrList,
|
||||||
|
Screen::Search,
|
||||||
|
Screen::Timeline,
|
||||||
|
Screen::Who,
|
||||||
|
Screen::Trace,
|
||||||
|
Screen::FileHistory,
|
||||||
|
Screen::Sync,
|
||||||
|
Screen::Stats,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cycle through screens 500 times, doing random ops at each.
|
||||||
|
let mut rng = Rng::new(42);
|
||||||
|
for cycle in 0..500 {
|
||||||
|
for screen in &screens_to_visit {
|
||||||
|
app.update(Msg::NavigateTo(screen.clone()));
|
||||||
|
|
||||||
|
// Do 5 random events per screen.
|
||||||
|
for _ in 0..5 {
|
||||||
|
let msg = random_event(&mut rng);
|
||||||
|
let cmd = app.update(msg);
|
||||||
|
if matches!(cmd, Cmd::Quit) {
|
||||||
|
app = test_app();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic invariant check (skip depth bound — this test pushes 10 screens/cycle).
|
||||||
|
if cycle % 50 == 0 {
|
||||||
|
assert!(
|
||||||
|
app.navigation.depth() >= 1,
|
||||||
|
"Nav depth < 1 at cycle {cycle}"
|
||||||
|
);
|
||||||
|
match &app.input_mode {
|
||||||
|
InputMode::Normal
|
||||||
|
| InputMode::Text
|
||||||
|
| InputMode::Palette
|
||||||
|
| InputMode::GoPrefix { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation depth tracking: verify depth stays bounded under random pushes.
|
||||||
|
///
|
||||||
|
/// The soak includes both push (Enter, navigation) and pop (Escape, Backspace)
|
||||||
|
/// operations. Depth should fluctuate but remain bounded.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_nav_depth_bounded() {
|
||||||
|
let mut rng = Rng::new(777);
|
||||||
|
let mut app = test_app();
|
||||||
|
let mut max_depth = 0_usize;
|
||||||
|
|
||||||
|
for _ in 0..30_000 {
|
||||||
|
let msg = random_event(&mut rng);
|
||||||
|
let cmd = app.update(msg);
|
||||||
|
|
||||||
|
if matches!(cmd, Cmd::Quit) {
|
||||||
|
app = test_app();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = app.navigation.depth();
|
||||||
|
if depth > max_depth {
|
||||||
|
max_depth = depth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With ~50% navigation keys including Escape/pop, depth shouldn't
|
||||||
|
// grow unboundedly. 200 is a very generous upper bound.
|
||||||
|
assert!(
|
||||||
|
max_depth < 200,
|
||||||
|
"Navigation depth grew to {max_depth} — potential unbounded growth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rapid mode oscillation soak: rapidly switch between input modes.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_mode_oscillation() {
|
||||||
|
let mut app = test_app();
|
||||||
|
|
||||||
|
// Rapidly switch modes 10,000 times.
|
||||||
|
for i in 0..10_000 {
|
||||||
|
match i % 6 {
|
||||||
|
0 => {
|
||||||
|
app.update(key_char('g'));
|
||||||
|
} // Enter GoPrefix
|
||||||
|
1 => {
|
||||||
|
app.update(key(KeyCode::Escape));
|
||||||
|
} // Back to Normal
|
||||||
|
2 => {
|
||||||
|
app.update(key_char('/'));
|
||||||
|
} // Enter Text/Search
|
||||||
|
3 => {
|
||||||
|
app.update(key(KeyCode::Escape));
|
||||||
|
} // Back to Normal
|
||||||
|
4 => {
|
||||||
|
app.update(key_char('g'));
|
||||||
|
app.update(key_char('d'));
|
||||||
|
} // Go to Dashboard
|
||||||
|
_ => {
|
||||||
|
app.update(key(KeyCode::Escape));
|
||||||
|
} // Ensure Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputMode should always be valid.
|
||||||
|
match &app.input_mode {
|
||||||
|
InputMode::Normal
|
||||||
|
| InputMode::Text
|
||||||
|
| InputMode::Palette
|
||||||
|
| InputMode::GoPrefix { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After final Escape, should be in Normal.
|
||||||
|
app.update(key(KeyCode::Escape));
|
||||||
|
assert!(
|
||||||
|
matches!(app.input_mode, InputMode::Normal),
|
||||||
|
"Should be Normal after final Escape"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full soak: events + renders + multiple seeds for coverage.
|
||||||
|
#[test]
|
||||||
|
fn test_soak_multi_seed_comprehensive() {
|
||||||
|
for seed in [1, 42, 999, 0xFFFF, 0xDEAD_CAFE, 31337] {
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let mut app = test_app();
|
||||||
|
|
||||||
|
for event_idx in 0..5_000 {
|
||||||
|
let msg = random_event(&mut rng);
|
||||||
|
let cmd = app.update(msg);
|
||||||
|
|
||||||
|
if matches!(cmd, Cmd::Quit) {
|
||||||
|
app = test_app();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_idx % 200 == 0 {
|
||||||
|
let (w, h) = app.state.terminal_size;
|
||||||
|
if w > 0 && h > 0 {
|
||||||
|
render_at(&app, w, h);
|
||||||
|
}
|
||||||
|
check_soak_invariants(&app, event_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user