- 4 stale response tests: issue list, dashboard, MR list, cross-screen isolation - 4 SQLITE_BUSY error handling tests: toast display, nav preservation, idempotent toasts, error-then-success - 7 cancel race tests: cancel/resubmit, rapid 5-submit sequence, key isolation, complete removes handle, stale completion no-op, stuck loading prevention, cancel_all - 1 issue detail stale guard test - Added active_cancel_token() method to TaskSupervisor for test observability
669 lines
20 KiB
Rust
669 lines
20 KiB
Rust
//! Race condition and reliability tests (bd-3fjk).
|
|
//!
|
|
//! Verifies the TUI handles async race conditions correctly:
|
|
//! - Stale responses from superseded tasks are silently dropped
|
|
//! - SQLITE_BUSY errors surface a user-friendly toast
|
|
//! - Cancel/resubmit sequences don't leave stuck loading states
|
|
//! - InterruptHandle only cancels its owning task's connection
|
|
//! - Rapid submit/cancel sequences (5 in quick succession) converge correctly
|
|
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{TimeZone, Utc};
|
|
use ftui::Model;
|
|
|
|
use lore_tui::app::LoreApp;
|
|
use lore_tui::clock::FakeClock;
|
|
use lore_tui::message::{AppError, EntityKey, Msg, Screen};
|
|
use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo, ProjectSyncInfo};
|
|
use lore_tui::state::issue_list::{IssueListPage, IssueListRow};
|
|
use lore_tui::state::mr_list::{MrListPage, MrListRow};
|
|
use lore_tui::task_supervisor::{CancelToken, TaskKey};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 fixture_dashboard_data() -> DashboardData {
|
|
DashboardData {
|
|
counts: EntityCounts {
|
|
issues_total: 42,
|
|
issues_open: 15,
|
|
mrs_total: 28,
|
|
mrs_open: 7,
|
|
discussions: 120,
|
|
notes_total: 350,
|
|
notes_system_pct: 18,
|
|
documents: 85,
|
|
embeddings: 200,
|
|
},
|
|
projects: vec![ProjectSyncInfo {
|
|
path: "infra/platform".into(),
|
|
minutes_since_sync: 5,
|
|
}],
|
|
recent: vec![],
|
|
last_sync: Some(LastSyncInfo {
|
|
status: "succeeded".into(),
|
|
finished_at: Some(1_736_942_100_000),
|
|
command: "sync".into(),
|
|
error: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn fixture_issue_list(count: usize) -> IssueListPage {
|
|
let rows: Vec<IssueListRow> = (0..count)
|
|
.map(|i| IssueListRow {
|
|
project_path: "infra/platform".into(),
|
|
iid: (100 + i) as i64,
|
|
title: format!("Issue {i}"),
|
|
state: "opened".into(),
|
|
author: "alice".into(),
|
|
labels: vec![],
|
|
updated_at: 1_736_942_000_000,
|
|
})
|
|
.collect();
|
|
IssueListPage {
|
|
total_count: count as u64,
|
|
next_cursor: None,
|
|
rows,
|
|
}
|
|
}
|
|
|
|
fn fixture_mr_list(count: usize) -> MrListPage {
|
|
let rows: Vec<MrListRow> = (0..count)
|
|
.map(|i| MrListRow {
|
|
project_path: "infra/platform".into(),
|
|
iid: (200 + i) as i64,
|
|
title: format!("MR {i}"),
|
|
state: "opened".into(),
|
|
author: "bob".into(),
|
|
labels: vec![],
|
|
updated_at: 1_736_942_000_000,
|
|
draft: false,
|
|
target_branch: "main".into(),
|
|
})
|
|
.collect();
|
|
MrListPage {
|
|
total_count: count as u64,
|
|
next_cursor: None,
|
|
rows,
|
|
}
|
|
}
|
|
|
|
fn load_dashboard(app: &mut LoreApp) {
|
|
let generation = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::Dashboard))
|
|
.generation;
|
|
app.update(Msg::DashboardLoaded {
|
|
generation,
|
|
data: Box::new(fixture_dashboard_data()),
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stale Response Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stale response with old generation is silently dropped.
|
|
///
|
|
/// Submit task A (gen N), then task B (gen M > N) with the same key.
|
|
/// Delivering a result with generation N should be a no-op.
|
|
#[test]
|
|
fn test_stale_issue_list_response_dropped() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
// Submit first task — get generation A.
|
|
let gen_a = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
|
|
// Submit second task (same key) — get generation B, cancels A.
|
|
let gen_b = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
assert!(gen_b > gen_a, "Generation B should be newer than A");
|
|
|
|
// Deliver stale result with gen_a — should be silently dropped.
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen_a,
|
|
page: fixture_issue_list(5),
|
|
});
|
|
assert_eq!(
|
|
app.state.issue_list.rows.len(),
|
|
0,
|
|
"Stale result should not populate state"
|
|
);
|
|
|
|
// Deliver fresh result with gen_b — should be applied.
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen_b,
|
|
page: fixture_issue_list(3),
|
|
});
|
|
assert_eq!(
|
|
app.state.issue_list.rows.len(),
|
|
3,
|
|
"Current-generation result should be applied"
|
|
);
|
|
}
|
|
|
|
/// Stale dashboard response dropped after navigation triggers re-load.
|
|
#[test]
|
|
fn test_stale_dashboard_response_dropped() {
|
|
let mut app = test_app();
|
|
|
|
// First load.
|
|
let gen_old = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::Dashboard))
|
|
.generation;
|
|
|
|
// Simulate re-navigation (new load).
|
|
let gen_new = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::Dashboard))
|
|
.generation;
|
|
|
|
// Deliver old generation — should not apply.
|
|
let mut old_data = fixture_dashboard_data();
|
|
old_data.counts.issues_total = 999;
|
|
app.update(Msg::DashboardLoaded {
|
|
generation: gen_old,
|
|
data: Box::new(old_data),
|
|
});
|
|
assert_eq!(
|
|
app.state.dashboard.counts.issues_total, 0,
|
|
"Stale dashboard data should not be applied"
|
|
);
|
|
|
|
// Deliver current generation — should apply.
|
|
app.update(Msg::DashboardLoaded {
|
|
generation: gen_new,
|
|
data: Box::new(fixture_dashboard_data()),
|
|
});
|
|
assert_eq!(
|
|
app.state.dashboard.counts.issues_total, 42,
|
|
"Current dashboard data should be applied"
|
|
);
|
|
}
|
|
|
|
/// MR list stale response dropped correctly.
|
|
#[test]
|
|
fn test_stale_mr_list_response_dropped() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
let gen_a = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::MrList))
|
|
.generation;
|
|
let gen_b = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::MrList))
|
|
.generation;
|
|
|
|
// Stale.
|
|
app.update(Msg::MrListLoaded {
|
|
generation: gen_a,
|
|
page: fixture_mr_list(10),
|
|
});
|
|
assert_eq!(app.state.mr_list.rows.len(), 0);
|
|
|
|
// Current.
|
|
app.update(Msg::MrListLoaded {
|
|
generation: gen_b,
|
|
page: fixture_mr_list(2),
|
|
});
|
|
assert_eq!(app.state.mr_list.rows.len(), 2);
|
|
}
|
|
|
|
/// Stale result for one screen does not interfere with another screen's data.
|
|
#[test]
|
|
fn test_stale_response_cross_screen_isolation() {
|
|
let mut app = test_app();
|
|
|
|
// Submit tasks for two different screens.
|
|
let gen_issues = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
let gen_mrs = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::MrList))
|
|
.generation;
|
|
|
|
// Deliver issue list results.
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen_issues,
|
|
page: fixture_issue_list(5),
|
|
});
|
|
assert_eq!(app.state.issue_list.rows.len(), 5);
|
|
|
|
// MR list should still be empty — different key.
|
|
assert_eq!(app.state.mr_list.rows.len(), 0);
|
|
|
|
// Deliver MR list results.
|
|
app.update(Msg::MrListLoaded {
|
|
generation: gen_mrs,
|
|
page: fixture_mr_list(3),
|
|
});
|
|
assert_eq!(app.state.mr_list.rows.len(), 3);
|
|
|
|
// Issue list should be unchanged.
|
|
assert_eq!(app.state.issue_list.rows.len(), 5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQLITE_BUSY Error Handling Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// DbBusy error shows user-friendly toast with "busy" in message.
|
|
#[test]
|
|
fn test_db_busy_shows_toast() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
app.update(Msg::Error(AppError::DbBusy));
|
|
assert!(
|
|
app.state.error_toast.is_some(),
|
|
"DbBusy should produce an error toast"
|
|
);
|
|
assert!(
|
|
app.state.error_toast.as_ref().unwrap().contains("busy"),
|
|
"Toast should mention 'busy'"
|
|
);
|
|
}
|
|
|
|
/// DbBusy error does not crash or alter navigation state.
|
|
#[test]
|
|
fn test_db_busy_preserves_navigation() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
|
|
|
// DbBusy should not change screen.
|
|
app.update(Msg::Error(AppError::DbBusy));
|
|
assert!(
|
|
app.navigation.is_at(&Screen::IssueList),
|
|
"DbBusy error should not alter navigation"
|
|
);
|
|
}
|
|
|
|
/// Multiple consecutive DbBusy errors don't stack — last message wins.
|
|
#[test]
|
|
fn test_db_busy_toast_idempotent() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
app.update(Msg::Error(AppError::DbBusy));
|
|
app.update(Msg::Error(AppError::DbBusy));
|
|
app.update(Msg::Error(AppError::DbBusy));
|
|
|
|
// Should have exactly one toast (last error).
|
|
assert!(app.state.error_toast.is_some());
|
|
assert!(app.state.error_toast.as_ref().unwrap().contains("busy"));
|
|
}
|
|
|
|
/// DbBusy followed by successful load clears the error.
|
|
#[test]
|
|
fn test_db_busy_then_success_clears_error() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
app.update(Msg::Error(AppError::DbBusy));
|
|
assert!(app.state.error_toast.is_some());
|
|
|
|
// Successful load comes in.
|
|
let gen_ok = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen_ok,
|
|
page: fixture_issue_list(3),
|
|
});
|
|
|
|
// Error toast should still be set (it's not auto-cleared by data loads).
|
|
// The user explicitly dismisses it via key press.
|
|
// What matters is the data was applied despite the prior error.
|
|
assert_eq!(
|
|
app.state.issue_list.rows.len(),
|
|
3,
|
|
"Data load should succeed after DbBusy error"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cancel Race Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Submit, cancel via token, resubmit: new task proceeds normally.
|
|
#[test]
|
|
fn test_cancel_then_resubmit_works() {
|
|
let mut app = test_app();
|
|
|
|
// Submit first task and capture its cancel token.
|
|
let gen1 = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
let token1 = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList))
|
|
.expect("Should have active handle");
|
|
assert!(!token1.is_cancelled());
|
|
|
|
// Resubmit with same key — old token should be cancelled.
|
|
let gen2 = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
assert!(
|
|
token1.is_cancelled(),
|
|
"Old token should be cancelled on resubmit"
|
|
);
|
|
assert!(gen2 > gen1);
|
|
|
|
// Deliver result for new task.
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen2,
|
|
page: fixture_issue_list(4),
|
|
});
|
|
assert_eq!(app.state.issue_list.rows.len(), 4);
|
|
}
|
|
|
|
/// Rapid sequence: 5 submit cycles for the same key.
|
|
/// Only the last generation should be accepted.
|
|
#[test]
|
|
fn test_rapid_submit_sequence_only_last_wins() {
|
|
let mut app = test_app();
|
|
let mut tokens: Vec<Arc<CancelToken>> = Vec::new();
|
|
let mut generations: Vec<u64> = Vec::new();
|
|
|
|
// Rapidly submit 5 tasks with the same key.
|
|
for _ in 0..5 {
|
|
let g = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
let token = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList))
|
|
.expect("Should have active handle");
|
|
generations.push(g);
|
|
tokens.push(token);
|
|
}
|
|
|
|
// All tokens except the last should be cancelled.
|
|
for (i, token) in tokens.iter().enumerate() {
|
|
if i < 4 {
|
|
assert!(token.is_cancelled(), "Token {i} should be cancelled");
|
|
} else {
|
|
assert!(!token.is_cancelled(), "Last token should still be active");
|
|
}
|
|
}
|
|
|
|
// Deliver results for each generation — only the last should apply.
|
|
for (i, g) in generations.iter().enumerate() {
|
|
let count = (i + 1) * 10;
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: *g,
|
|
page: fixture_issue_list(count),
|
|
});
|
|
}
|
|
|
|
// Only the last (50 rows) should have been applied.
|
|
assert_eq!(
|
|
app.state.issue_list.rows.len(),
|
|
50,
|
|
"Only the last generation's data should be applied"
|
|
);
|
|
}
|
|
|
|
/// Cancel token from one key does not affect tasks with different keys.
|
|
#[test]
|
|
fn test_cancel_token_key_isolation() {
|
|
let mut app = test_app();
|
|
|
|
// Submit tasks for two different keys.
|
|
app.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList));
|
|
let issue_token = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList))
|
|
.expect("issue handle");
|
|
|
|
app.supervisor.submit(TaskKey::LoadScreen(Screen::MrList));
|
|
let mr_token = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::LoadScreen(Screen::MrList))
|
|
.expect("mr handle");
|
|
|
|
// Resubmit only the issue task — should cancel issue token but not MR.
|
|
app.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList));
|
|
|
|
assert!(
|
|
issue_token.is_cancelled(),
|
|
"Issue token should be cancelled"
|
|
);
|
|
assert!(!mr_token.is_cancelled(), "MR token should NOT be cancelled");
|
|
}
|
|
|
|
/// After completing a task, the handle is removed and is_current returns false.
|
|
#[test]
|
|
fn test_complete_removes_handle() {
|
|
let mut app = test_app();
|
|
|
|
let gen_c = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
assert!(
|
|
app.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::IssueList), gen_c)
|
|
);
|
|
|
|
// Complete the task.
|
|
app.supervisor
|
|
.complete(&TaskKey::LoadScreen(Screen::IssueList), gen_c);
|
|
assert!(
|
|
!app.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::IssueList), gen_c),
|
|
"Handle should be removed after completion"
|
|
);
|
|
assert_eq!(app.supervisor.active_count(), 0);
|
|
}
|
|
|
|
/// Completing with a stale generation does not remove the newer handle.
|
|
#[test]
|
|
fn test_complete_stale_does_not_remove_newer() {
|
|
let mut app = test_app();
|
|
|
|
let gen1 = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
let gen2 = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
|
|
// Completing with old generation should be a no-op.
|
|
app.supervisor
|
|
.complete(&TaskKey::LoadScreen(Screen::IssueList), gen1);
|
|
assert!(
|
|
app.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::IssueList), gen2),
|
|
"Newer handle should survive stale completion"
|
|
);
|
|
}
|
|
|
|
/// No stuck loading state after cancel-then-resubmit through the full app.
|
|
#[test]
|
|
fn test_no_stuck_loading_after_cancel_resubmit() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
// Navigate to issue list — sets LoadingInitial.
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
|
|
|
// Re-navigate (resubmit) — cancels old, creates new.
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
|
|
// Deliver the result for the current generation.
|
|
let gen_cur = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen_cur,
|
|
page: fixture_issue_list(3),
|
|
});
|
|
|
|
// Data should be applied and loading should be idle.
|
|
assert_eq!(app.state.issue_list.rows.len(), 3);
|
|
}
|
|
|
|
/// cancel_all cancels all active tasks.
|
|
#[test]
|
|
fn test_cancel_all_cancels_everything() {
|
|
let mut app = test_app();
|
|
|
|
app.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList));
|
|
let t1 = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList))
|
|
.expect("handle");
|
|
|
|
app.supervisor.submit(TaskKey::LoadScreen(Screen::MrList));
|
|
let t2 = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::LoadScreen(Screen::MrList))
|
|
.expect("handle");
|
|
|
|
app.supervisor.submit(TaskKey::SyncStream);
|
|
let t3 = app
|
|
.supervisor
|
|
.active_cancel_token(&TaskKey::SyncStream)
|
|
.expect("handle");
|
|
|
|
app.supervisor.cancel_all();
|
|
|
|
assert!(t1.is_cancelled());
|
|
assert!(t2.is_cancelled());
|
|
assert!(t3.is_cancelled());
|
|
assert_eq!(app.supervisor.active_count(), 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue Detail Stale Guard (entity-keyed screens)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stale issue detail response is dropped when a newer load supersedes it.
|
|
#[test]
|
|
fn test_stale_issue_detail_response_dropped() {
|
|
let mut app = test_app();
|
|
load_dashboard(&mut app);
|
|
|
|
let key = EntityKey::issue(1, 101);
|
|
let screen = Screen::IssueDetail(key.clone());
|
|
|
|
let gen_old = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(screen.clone()))
|
|
.generation;
|
|
let gen_new = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(screen))
|
|
.generation;
|
|
|
|
// Deliver stale response.
|
|
app.update(Msg::IssueDetailLoaded {
|
|
generation: gen_old,
|
|
key: key.clone(),
|
|
data: Box::new(lore_tui::state::issue_detail::IssueDetailData {
|
|
metadata: lore_tui::state::issue_detail::IssueMetadata {
|
|
iid: 101,
|
|
project_path: "infra/platform".into(),
|
|
title: "STALE TITLE".into(),
|
|
description: String::new(),
|
|
state: "opened".into(),
|
|
author: "alice".into(),
|
|
assignees: vec![],
|
|
labels: vec![],
|
|
milestone: None,
|
|
due_date: None,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
web_url: String::new(),
|
|
discussion_count: 0,
|
|
},
|
|
cross_refs: vec![],
|
|
}),
|
|
});
|
|
|
|
// Stale — metadata should NOT be populated with "STALE TITLE".
|
|
assert_ne!(
|
|
app.state
|
|
.issue_detail
|
|
.metadata
|
|
.as_ref()
|
|
.map(|m| m.title.as_str()),
|
|
Some("STALE TITLE"),
|
|
"Stale issue detail should be dropped"
|
|
);
|
|
|
|
// Deliver current response.
|
|
app.update(Msg::IssueDetailLoaded {
|
|
generation: gen_new,
|
|
key,
|
|
data: Box::new(lore_tui::state::issue_detail::IssueDetailData {
|
|
metadata: lore_tui::state::issue_detail::IssueMetadata {
|
|
iid: 101,
|
|
project_path: "infra/platform".into(),
|
|
title: "CURRENT TITLE".into(),
|
|
description: String::new(),
|
|
state: "opened".into(),
|
|
author: "alice".into(),
|
|
assignees: vec![],
|
|
labels: vec![],
|
|
milestone: None,
|
|
due_date: None,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
web_url: String::new(),
|
|
discussion_count: 0,
|
|
},
|
|
cross_refs: vec![],
|
|
}),
|
|
});
|
|
|
|
assert_eq!(
|
|
app.state
|
|
.issue_detail
|
|
.metadata
|
|
.as_ref()
|
|
.map(|m| m.title.as_str()),
|
|
Some("CURRENT TITLE"),
|
|
"Current generation detail should be applied"
|
|
);
|
|
}
|