#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::*;
fn sample_issue() -> Issue {
Issue {
projects: None,
id: None,
number: 1,
title: "Test Issue".into(),
status: Status::new("todo"),
labels: vec!["bug".into()],
priority: Priority::High,
assignee: Some("alice".into()),
estimate: Some("2".into()),
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![crate::relationships::Relationship::new(
1,
12,
crate::relationships::RelationKind::Related,
"Twelve",
)],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
files: vec!["openspec/changes/x/proposal.md".into()],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: "test.md".into(),
body: "Line one\n\nLine two".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
}
}
fn render_text(detail: &DetailState) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| render(detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
let buf = terminal.backend().buffer();
buf.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.chunks(120)
.map(|row| {
row.iter()
.copied()
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn detail_with(
relationships: Vec<crate::relationships::Relationship>,
chain: crate::relationships::DependencyChain,
blocked: Option<String>,
) -> DetailState {
let issue = sample_issue();
DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&issue),
"done",
issue.number,
),
issue,
relationships,
chain,
blocked,
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
}
}
#[test]
fn the_relationship_block_covers_a_blocks_edge_a_related_only_issue_and_the_empty_state() {
let blocks = detail_with(
vec![crate::relationships::Relationship::new(
1,
2,
crate::relationships::RelationKind::Blocks,
"Refactor store",
)],
no_chain(),
Some("Blocked by #1 Ship the thing".to_string()),
);
let text = render_text(&blocks);
assert!(text.contains("blocks #2 Refactor store"), "got:\n{text}");
assert!(text.contains("blocked: Blocked by #1"), "got:\n{text}");
assert!(text.contains("chain: none"), "got:\n{text}");
let related = detail_with(
vec![crate::relationships::Relationship::new(
1,
3,
crate::relationships::RelationKind::Related,
"Notes",
)],
no_chain(),
None,
);
let text = render_text(&related);
assert!(text.contains("related #3 Notes"), "got:\n{text}");
assert!(
text.contains("blocked: not blocked"),
"a `related` edge must not read as blocking: got:\n{text}"
);
assert!(
!text.contains("blocked: related"),
"and nothing may present a non-blocks edge as a blocker: got:\n{text}"
);
let empty = detail_with(vec![], no_chain(), None);
let text = render_text(&empty);
assert!(text.contains("blocked: not blocked"), "got:\n{text}");
assert!(text.contains("chain: none"), "got:\n{text}");
assert!(
!text.contains("links: "),
"an issue with no relationships has no links row: got:\n{text}"
);
}
#[test]
fn the_relationship_block_is_ordered_like_the_cli_detail() {
let mut chain = no_chain();
chain.paths.push(vec![crate::relationships::ChainNode {
number: 1,
title: "Ship it".into(),
}]);
let text = render_text(&detail_with(
vec![crate::relationships::Relationship::new(
1,
2,
crate::relationships::RelationKind::Blocks,
"Refactor store",
)],
chain,
Some("Blocked by #3 Something".to_string()),
));
let links = text.find("links: ").expect("a links row");
let chain_at = text.find("chain: ").expect("a chain row");
let blocked = text.find("blocked: ").expect("a blocked row");
assert!(
links < chain_at && chain_at < blocked,
"relationships, then chain, then blocked — got:\n{text}"
);
}
#[test]
fn render_files_line_emits_clickable_hyperlink() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
issue: sample_issue(),
relationships: sample_issue().relationships,
chain: no_chain(),
blocked: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&sample_issue()),
"done",
sample_issue().number,
),
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
let buf = terminal.backend().buffer();
let text: String = buf
.content()
.iter()
.map(|c| c.symbol().to_string())
.collect::<Vec<_>>()
.join("");
let link = hyperlink(
&file_uri("openspec/changes/x/proposal.md"),
"openspec/changes/x/proposal.md",
);
assert!(
text.contains(&link),
"files must render as an OSC 8 hyperlink to the resolved file"
);
assert!(text.contains("related #12 Twelve"), "got:\n{text}");
}
#[test]
fn hyperlink_wraps_label_and_keeps_file_uris() {
assert_eq!(
hyperlink("file:///a/b.md", "b.md"),
"\u{1b}]8;;file:///a/b.md\u{1b}\\b.md\u{1b}]8;;\u{1b}\\"
);
assert_eq!(file_uri("file:///already/abs.md"), "file:///already/abs.md");
assert!(file_uri("openspec/x.md").starts_with("file:///"));
}
#[test]
fn render_smoke_small_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
issue: sample_issue(),
relationships: sample_issue().relationships,
chain: no_chain(),
blocked: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&sample_issue()),
"done",
sample_issue().number,
),
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
}
#[test]
fn render_smoke_large_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 40);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
issue: sample_issue(),
relationships: sample_issue().relationships,
chain: no_chain(),
blocked: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&sample_issue()),
"done",
sample_issue().number,
),
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
}
#[test]
fn render_scroll_offset() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
let issue = sample_issue();
let relationships = issue.relationships.clone();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&issue),
"done",
issue.number,
),
issue,
relationships,
chain: no_chain(),
blocked: None,
scroll_offset: std::cell::Cell::new(5),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
}
#[test]
fn the_body_renders_markdown_not_plaintext() {
let mut detail = detail_with(Vec::new(), no_chain(), None);
detail.issue.body = "# Title\n\nThis is **bold** and `code`.\n".to_string();
let text = render_text(&detail);
assert!(text.contains("Title"), "{text}");
assert!(
!text.contains("**bold**"),
"bold markers are consumed: {text}"
);
assert!(text.contains("bold"), "{text}");
assert!(
!text.contains("`code`"),
"code markers are consumed: {text}"
);
}
#[test]
fn the_mention_line_is_highlighted_prominently() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut detail = detail_with(Vec::new(), no_chain(), None);
detail.issue.body = "start\n\nblocked on @alice\n".to_string();
detail.mention_body = Some("alice".to_string());
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
let buf = terminal.backend().buffer();
let highlighted = (0..buf.area.height).any(|y| {
(0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(ratatui::style::Color::Yellow))
});
assert!(highlighted, "the mention line carries a yellow background");
}
#[test]
fn a_mermaid_code_block_renders_as_a_diagram() {
let mut detail = detail_with(Vec::new(), no_chain(), None);
detail.issue.body = "```mermaid\nsequenceDiagram\n Alice->>Bob: Hello\n```\n".to_string();
let text = render_text(&detail);
assert!(
text.contains("mermaid"),
"the diagram carries a header: {text}"
);
assert!(
!text.contains("sequenceDiagram"),
"the keyword is consumed: {text}"
);
assert!(
text.contains("Alice") && text.contains("Bob"),
"the nodes are drawn: {text}"
);
}
#[test]
fn the_related_block_renders_a_count_chips_and_the_selection_marker() {
let mut detail = detail_with(
vec![crate::relationships::Relationship::new(
1,
2,
crate::relationships::RelationKind::Related,
"Twelve",
)],
no_chain(),
None,
);
detail.related = crate::related::classify_all(&[
"12".to_string(),
"../other-repo/issues/2026-01-01-x.md".to_string(),
"https://gitlab.com/foo/bar#7".to_string(),
]);
detail.related_cursor = 1;
let text = render_text(&detail);
assert!(
text.contains("related: 3"),
"a count is drawn: got:\n{text}"
);
let cell = |line: &str| {
line.trim_matches(|c: char| c == '│' || c == ' ')
.to_string()
};
assert!(
text.lines().any(|line| cell(line) == "12"),
"the local chip is drawn: got:\n{text}"
);
assert!(
text.lines()
.any(|line| cell(line).contains("../other-repo/issues/2026-01-01-x.md")),
"the path chip is drawn: got:\n{text}"
);
assert!(
text.lines()
.any(|line| cell(line).contains("https://gitlab.com/foo/bar#7")),
"the URL chip is drawn: got:\n{text}"
);
let selected = text
.lines()
.find(|line| cell(line).contains("../other-repo"))
.expect("the selected line");
assert!(
cell(selected).starts_with("> "),
"the selected related entry needs a marker: {selected:?}"
);
let first = text
.lines()
.find(|line| cell(line) == "12")
.expect("the first line");
assert!(
!cell(first).contains('>'),
"unselected related entries carry no marker: {first:?}"
);
let related_at = text.find("related: 3").expect("the related block");
let links_at = text.find("links: ").expect("the relationship row");
assert!(
related_at < links_at,
"the related block must precede the links row: got:\n{text}"
);
let empty = detail_with(vec![], no_chain(), None);
let empty_text = render_text(&empty);
assert!(
!empty_text.contains("related:"),
"an issue with no related_issues must draw no block: got:\n{empty_text}"
);
}
#[test]
fn assignee_render_identity_verbatim_bare_gets_at() {
assert_eq!(render_assignee(None), None);
assert_eq!(
render_assignee(Some("Herbert Maschke <thyseus@pm.me>")),
Some("Herbert Maschke <thyseus@pm.me>".to_string())
);
assert_eq!(render_assignee(Some("alice")), Some("@alice".to_string()));
assert_eq!(render_assignee(Some("@alice")), Some("@alice".to_string()));
}
fn epic_entry(heading: &str, detail: &[&str]) -> crate::commands::view::EpicBlockEntry {
crate::commands::view::EpicBlockEntry {
heading: heading.to_string(),
detail: detail.iter().map(|line| (*line).to_string()).collect(),
}
}
#[test]
fn t019_the_pane_renders_the_canonical_epic_rows_and_the_navigation_affordance() {
let mut detail = detail_with(vec![], no_chain(), None);
detail.epics = vec![epic_entry(
"Alpha (e-aaa111)",
&[
"progress: 1/2 done",
"weighted progress: 2/2 (100%)",
"children: 1",
"blocked: 0",
],
)];
let text = render_text(&detail);
assert!(text.contains("epics:"), "{text}");
assert!(text.contains("Alpha (e-aaa111)"), "{text}");
assert!(
text.contains("progress: 1/2 done"),
"the pane renders the same facts as `tix show`: {text}"
);
assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
assert!(
text.contains("members open directly from the epic view"),
"the direct issue navigation affordance is explicit (FR-004): {text}"
);
}
#[test]
fn t019_an_issue_in_no_epic_draws_no_epic_heading() {
let detail = detail_with(vec![], no_chain(), None);
let text = render_text(&detail);
assert!(
!text.contains("epics:"),
"an empty reference list is the empty state, not an empty heading: {text}"
);
}
#[test]
fn t019_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
let mut detail = detail_with(vec![], no_chain(), None);
detail.epics = vec![epic_entry(
"e-gone1 (missing reference)",
&[
"issue #5 records epic reference `e-gone1`, which is not in the registry \
(the reference is kept so it can be repaired)",
],
)];
let text = render_text(&detail);
assert!(
text.contains("e-gone1 (missing reference)"),
"a missing reference is named, not dropped (C-005): {text}"
);
assert!(text.contains("not in the registry"), "{text}");
}