feat(tui): [STALE] Warning-styled badge on search/inspect/ask (fb-32)
insta filter pattern '[indexed_at]' applied where snapshots otherwise capture time-dependent RFC3339 strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -377,6 +377,108 @@ fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
|
||||
assert!(rendered.contains("score_gate"), "refusal reason surfaced");
|
||||
}
|
||||
|
||||
/// p9-fb-32: when `AnswerCitation.stale == true`, the Ask pane's
|
||||
/// citations panel inserts a Warning-styled `[STALE] ` Span between
|
||||
/// the marker and the path URI.
|
||||
#[test]
|
||||
fn ask_citations_show_stale_badge_for_stale_citation() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.ask.as_mut().unwrap();
|
||||
let mut ans = make_answer(true, None, "answer body [1] [2].");
|
||||
// Replace fixture's single fresh citation with two — one stale
|
||||
// (notes/old.md) and one fresh (notes/new.md) — so the test
|
||||
// can assert the badge attaches to one row only.
|
||||
ans.citations = vec![
|
||||
AnswerCitation {
|
||||
marker: Some("1".into()),
|
||||
citation: Citation::Line {
|
||||
path: WorkspacePath::new("notes/old.md".into()).unwrap(),
|
||||
start: 1,
|
||||
end: 1,
|
||||
section: None,
|
||||
},
|
||||
indexed_at: OffsetDateTime::UNIX_EPOCH,
|
||||
stale: true,
|
||||
},
|
||||
AnswerCitation {
|
||||
marker: Some("2".into()),
|
||||
citation: Citation::Line {
|
||||
path: WorkspacePath::new("notes/new.md".into()).unwrap(),
|
||||
start: 5,
|
||||
end: 5,
|
||||
section: None,
|
||||
},
|
||||
indexed_at: OffsetDateTime::UNIX_EPOCH,
|
||||
stale: false,
|
||||
},
|
||||
];
|
||||
s.turns.push(Turn {
|
||||
question: "test".into(),
|
||||
answer: ans.answer.clone(),
|
||||
citations: ans.citations.clone(),
|
||||
created_at: ans.created_at,
|
||||
});
|
||||
s.last_answer = Some(ans);
|
||||
}
|
||||
let backend = TestBackend::new(120, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 120, 24);
|
||||
render_ask(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
rendered.contains("[STALE]"),
|
||||
"[STALE] badge must render somewhere on the citations panel: {rendered}"
|
||||
);
|
||||
let stale_line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("notes/old.md"))
|
||||
.expect("stale citation row must render");
|
||||
assert!(
|
||||
stale_line.contains("[STALE]"),
|
||||
"stale citation row must carry [STALE] badge: {stale_line}"
|
||||
);
|
||||
let fresh_line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("notes/new.md"))
|
||||
.expect("fresh citation row must render");
|
||||
assert!(
|
||||
!fresh_line.contains("[STALE]"),
|
||||
"fresh citation row must NOT carry [STALE] badge: {fresh_line}"
|
||||
);
|
||||
// Color side: the `[` of `[STALE]` must be Yellow (Warning role).
|
||||
let mut stale_yellow_found = false;
|
||||
for y in 0..buffer.area.height {
|
||||
for x in 0..buffer.area.width {
|
||||
let cell = &buffer[(x, y)];
|
||||
if cell.symbol() == "["
|
||||
&& x + 1 < buffer.area.width
|
||||
&& buffer[(x + 1, y)].symbol() == "S"
|
||||
{
|
||||
if let ratatui::style::Color::Yellow = cell.fg {
|
||||
stale_yellow_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
stale_yellow_found,
|
||||
"[STALE] badge in citations must use Yellow (Warning) fg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_toggle_changes_panel_title() {
|
||||
let mut app = fresh_app();
|
||||
|
||||
@@ -325,6 +325,81 @@ fn chunk_view_renders_text_and_block_ids() {
|
||||
);
|
||||
}
|
||||
|
||||
/// p9-fb-32: when a doc's `metadata.updated_at` is older than the
|
||||
/// configured `stale_threshold_days`, the Inspect pane prefixes the
|
||||
/// `doc_path` value with a Warning-styled `[STALE] ` Span. Threshold
|
||||
/// 0 (the staleness feature off) must NOT render the badge.
|
||||
#[test]
|
||||
fn inspect_doc_header_shows_stale_badge_when_threshold_exceeded() {
|
||||
let mut app = fresh_app();
|
||||
// Force a non-zero threshold so the staleness post-process can fire.
|
||||
app.config.search.stale_threshold_days = 30;
|
||||
{
|
||||
let s = app.inspect.as_mut().unwrap();
|
||||
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
|
||||
let mut doc = make_doc();
|
||||
// Backdate updated_at by 60 days so 60d > 30d threshold.
|
||||
doc.metadata.updated_at =
|
||||
OffsetDateTime::now_utc() - time::Duration::days(60);
|
||||
s.doc = Some(doc);
|
||||
}
|
||||
let rendered = render_to_string(&app, 100, 40);
|
||||
assert!(
|
||||
rendered.contains("[STALE]"),
|
||||
"[STALE] badge must render on stale doc header: {rendered}"
|
||||
);
|
||||
// Same line carrying the doc_path value must show the badge.
|
||||
let path_line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("notes/test.md"))
|
||||
.expect("doc_path line must render");
|
||||
assert!(
|
||||
path_line.contains("[STALE]"),
|
||||
"doc_path row must carry [STALE] badge: {path_line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_doc_header_omits_stale_badge_when_fresh() {
|
||||
let mut app = fresh_app();
|
||||
app.config.search.stale_threshold_days = 30;
|
||||
{
|
||||
let s = app.inspect.as_mut().unwrap();
|
||||
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
|
||||
let mut doc = make_doc();
|
||||
// 1 day old — under the 30d threshold.
|
||||
doc.metadata.updated_at =
|
||||
OffsetDateTime::now_utc() - time::Duration::days(1);
|
||||
s.doc = Some(doc);
|
||||
}
|
||||
let rendered = render_to_string(&app, 100, 40);
|
||||
assert!(
|
||||
!rendered.contains("[STALE]"),
|
||||
"fresh doc must NOT carry [STALE] badge: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_doc_header_omits_stale_badge_when_threshold_zero() {
|
||||
let mut app = fresh_app();
|
||||
// Threshold 0 = staleness feature disabled.
|
||||
app.config.search.stale_threshold_days = 0;
|
||||
{
|
||||
let s = app.inspect.as_mut().unwrap();
|
||||
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
|
||||
let mut doc = make_doc();
|
||||
// Even a year-old doc must not get [STALE] when threshold = 0.
|
||||
doc.metadata.updated_at =
|
||||
OffsetDateTime::now_utc() - time::Duration::days(365);
|
||||
s.doc = Some(doc);
|
||||
}
|
||||
let rendered = render_to_string(&app, 100, 40);
|
||||
assert!(
|
||||
!rendered.contains("[STALE]"),
|
||||
"threshold = 0 must disable [STALE] badge regardless of age: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_inspect_state_returns_to_library() {
|
||||
let mut config = Config::defaults();
|
||||
|
||||
@@ -252,6 +252,100 @@ fn render_search_with_hits_shows_input_and_path() {
|
||||
assert!(rendered.contains("notes/dyn.md"), "second hit path rendered");
|
||||
}
|
||||
|
||||
/// p9-fb-32: Search pane prefixes the rank/score header line with a
|
||||
/// Warning-styled `[STALE] ` Span when `hit.stale == true`. Pin the
|
||||
/// text-level signal (color is exercised via the cell scan below).
|
||||
#[test]
|
||||
fn search_pane_shows_stale_badge_for_old_doc() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.search.as_mut().unwrap();
|
||||
s.input.push_str("rust");
|
||||
s.mode = SearchMode::Hybrid;
|
||||
let mut stale_hit = make_hit(
|
||||
1,
|
||||
"notes/old.md",
|
||||
"ancient trait dispatch\nstill relevant",
|
||||
line_citation("notes/old.md", 7),
|
||||
);
|
||||
// Synthesize an indexed_at well past any threshold; combined
|
||||
// with `stale: true` this matches the post-process output of
|
||||
// `kebab_app::mark_stale_in_place`.
|
||||
stale_hit.indexed_at = time::OffsetDateTime::UNIX_EPOCH;
|
||||
stale_hit.stale = true;
|
||||
let fresh_hit = make_hit(
|
||||
2,
|
||||
"notes/new.md",
|
||||
"modern dispatch\nvtable",
|
||||
line_citation("notes/new.md", 3),
|
||||
);
|
||||
s.hits = vec![stale_hit, fresh_hit];
|
||||
s.selected_hit = 0;
|
||||
}
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
render_search(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
rendered.contains("[STALE]"),
|
||||
"[STALE] badge must render as text on stale hit: {rendered}"
|
||||
);
|
||||
// The badge appears on the same line that begins with rank `1.`
|
||||
// — the stale hit. The fresh `notes/new.md` row must NOT carry
|
||||
// the badge.
|
||||
let stale_line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("notes/old.md"))
|
||||
.expect("stale hit's header line must render");
|
||||
assert!(
|
||||
stale_line.contains("[STALE]"),
|
||||
"stale row must carry [STALE] badge: {stale_line}"
|
||||
);
|
||||
let fresh_line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("notes/new.md"))
|
||||
.expect("fresh hit's header line must render");
|
||||
assert!(
|
||||
!fresh_line.contains("[STALE]"),
|
||||
"fresh row must NOT carry [STALE] badge: {fresh_line}"
|
||||
);
|
||||
// Color side: the `[` of `[STALE]` must be Yellow (Warning role,
|
||||
// dark palette default).
|
||||
let mut stale_yellow_found = false;
|
||||
for y in 0..buffer.area.height {
|
||||
for x in 0..buffer.area.width {
|
||||
let cell = &buffer[(x, y)];
|
||||
if cell.symbol() == "[" {
|
||||
// The cell to the right should be 'S' if this is the
|
||||
// start of `[STALE]` — narrow check to avoid the
|
||||
// rank/score `[` cells (there shouldn't be any there).
|
||||
if x + 1 < buffer.area.width && buffer[(x + 1, y)].symbol() == "S" {
|
||||
if let ratatui::style::Color::Yellow = cell.fg {
|
||||
stale_yellow_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
stale_yellow_found,
|
||||
"[STALE] badge must be rendered with Yellow (Warning role) fg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_renders_without_panic() {
|
||||
let app = fresh_app();
|
||||
|
||||
Reference in New Issue
Block a user