feat(tui): Phase 2 detail screens — Issue Detail, MR Detail, discussion tree, cross-refs
Implements the remaining Phase 2 Core Screens: - Discussion tree widget (view/common/discussion_tree.rs): DiscussionNode/NoteNode types, expand/collapse state, visual row flattening, format_relative_time with Clock trait - Cross-reference widget (view/common/cross_ref.rs): CrossRefKind enum, navigable refs, badge rendering ([MR]/[REL]/[REF]) - Issue Detail (state + action + view): progressive hydration (metadata Phase 1, discussions Phase 2), section cycling, description scroll, sanitized GitLab content - MR Detail (state + action + view): tab bar (Overview/Files/Discussions), file changes with change type indicators, branch info, draft/merge status, diff note support - Message + update wiring: IssueDetailLoaded, MrDetailLoaded, DiscussionsLoaded handlers with TaskSupervisor stale-result guards Closes bd-1d6z, bd-8ab7, bd-3t1b, bd-1cl9 (Phase 2 epic). 389 tests passing, clippy clean, fmt clean.
This commit is contained in:
626
crates/lore-tui/src/view/issue_detail.rs
Normal file
626
crates/lore-tui/src/view/issue_detail.rs
Normal file
@@ -0,0 +1,626 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||
|
||||
//! Issue detail screen view.
|
||||
//!
|
||||
//! Composes metadata header, description, discussion tree, and
|
||||
//! cross-references into a scrollable detail layout. Supports
|
||||
//! progressive hydration: metadata renders immediately while
|
||||
//! discussions load async in Phase 2.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::clock::Clock;
|
||||
use crate::safety::{UrlPolicy, sanitize_for_terminal};
|
||||
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
|
||||
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
|
||||
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (Flexoki palette — will use injected Theme in a later phase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
|
||||
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
|
||||
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
||||
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
|
||||
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
|
||||
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg
|
||||
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn discussion_colors() -> DiscussionTreeColors {
|
||||
DiscussionTreeColors {
|
||||
author_fg: CYAN,
|
||||
timestamp_fg: TEXT_MUTED,
|
||||
body_fg: TEXT,
|
||||
system_fg: TEXT_MUTED,
|
||||
diff_path_fg: GREEN,
|
||||
resolved_fg: TEXT_MUTED,
|
||||
guide_fg: BORDER,
|
||||
selected_fg: SELECTED_FG,
|
||||
selected_bg: SELECTED_BG,
|
||||
expand_fg: ACCENT,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_ref_colors() -> CrossRefColors {
|
||||
CrossRefColors {
|
||||
kind_fg: ACCENT,
|
||||
label_fg: TEXT,
|
||||
muted_fg: TEXT_MUTED,
|
||||
selected_fg: SELECTED_FG,
|
||||
selected_bg: SELECTED_BG,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the full issue detail screen.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// Row 0: #42 Fix authentication flow (title bar)
|
||||
/// Row 1: opened | alice | backend, security (metadata row)
|
||||
/// Row 2: Milestone: v1.0 | Due: 2026-03-01 (optional)
|
||||
/// Row 3: ─────────────────────────────────── (separator)
|
||||
/// Row 4..N: Description text... (scrollable)
|
||||
/// ─────────────────────────────────── (separator)
|
||||
/// Discussions (3) (section header)
|
||||
/// ▶ alice: Fixed the login flow... (collapsed)
|
||||
/// ▼ bob: I think we should also... (expanded)
|
||||
/// bob: body line 1...
|
||||
/// ─────────────────────────────────── (separator)
|
||||
/// Cross References (section header)
|
||||
/// [MR] !10 Fix authentication MR
|
||||
/// ```
|
||||
pub fn render_issue_detail(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &IssueDetailState,
|
||||
area: Rect,
|
||||
clock: &dyn Clock,
|
||||
) {
|
||||
if area.height < 3 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref meta) = state.metadata else {
|
||||
// No metadata yet — the loading spinner handles this.
|
||||
return;
|
||||
};
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
|
||||
// --- Title bar ---
|
||||
y = render_title_bar(frame, meta, area.x, y, max_x);
|
||||
|
||||
// --- Metadata row ---
|
||||
y = render_metadata_row(frame, meta, area.x, y, max_x);
|
||||
|
||||
// --- Optional milestone / due date row ---
|
||||
if meta.milestone.is_some() || meta.due_date.is_some() {
|
||||
y = render_milestone_row(frame, meta, area.x, y, max_x);
|
||||
}
|
||||
|
||||
// --- Separator ---
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
if y >= bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remaining space is split between description, discussions, and cross-refs.
|
||||
let remaining = bottom.saturating_sub(y);
|
||||
|
||||
// Compute section heights based on content.
|
||||
let desc_lines = count_description_lines(meta, area.width);
|
||||
let disc_count = state.discussions.len();
|
||||
let xref_count = state.cross_refs.len();
|
||||
|
||||
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count);
|
||||
|
||||
// --- Description section ---
|
||||
if desc_h > 0 {
|
||||
let desc_area = Rect::new(area.x, y, area.width, desc_h);
|
||||
let is_focused = state.active_section == DetailSection::Description;
|
||||
render_description(frame, meta, state.description_scroll, desc_area, is_focused);
|
||||
y += desc_h;
|
||||
}
|
||||
|
||||
// --- Separator before discussions ---
|
||||
if (disc_h > 0 || xref_h > 0) && y < bottom {
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
}
|
||||
|
||||
// --- Discussions section ---
|
||||
if disc_h > 0 && y < bottom {
|
||||
let header_h = 1;
|
||||
let is_focused = state.active_section == DetailSection::Discussions;
|
||||
|
||||
// Section header.
|
||||
render_section_header(
|
||||
frame,
|
||||
&format!("Discussions ({})", state.discussions.len()),
|
||||
area.x,
|
||||
y,
|
||||
max_x,
|
||||
is_focused,
|
||||
);
|
||||
y += header_h;
|
||||
|
||||
if !state.discussions_loaded {
|
||||
// Still loading.
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "Loading discussions...", style, max_x);
|
||||
y += 1;
|
||||
} else if state.discussions.is_empty() {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "No discussions", style, max_x);
|
||||
y += 1;
|
||||
} else {
|
||||
let tree_height = disc_h.saturating_sub(header_h);
|
||||
if tree_height > 0 {
|
||||
let tree_area = Rect::new(area.x, y, area.width, tree_height);
|
||||
let rendered = render_discussion_tree(
|
||||
frame,
|
||||
&state.discussions,
|
||||
&state.tree_state,
|
||||
tree_area,
|
||||
&discussion_colors(),
|
||||
clock,
|
||||
);
|
||||
y += rendered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Separator before cross-refs ---
|
||||
if xref_h > 0 && y < bottom {
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
}
|
||||
|
||||
// --- Cross-references section ---
|
||||
if xref_h > 0 && y < bottom {
|
||||
let is_focused = state.active_section == DetailSection::CrossRefs;
|
||||
|
||||
render_section_header(
|
||||
frame,
|
||||
&format!("Cross References ({})", state.cross_refs.len()),
|
||||
area.x,
|
||||
y,
|
||||
max_x,
|
||||
is_focused,
|
||||
);
|
||||
y += 1;
|
||||
|
||||
if state.cross_refs.is_empty() {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "No cross-references", style, max_x);
|
||||
} else {
|
||||
let refs_height = xref_h.saturating_sub(1); // minus header
|
||||
if refs_height > 0 {
|
||||
let refs_area = Rect::new(area.x, y, area.width, refs_height);
|
||||
let _ = render_cross_refs(
|
||||
frame,
|
||||
&state.cross_refs,
|
||||
&state.cross_ref_state,
|
||||
refs_area,
|
||||
&cross_ref_colors(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the issue title bar: `#42 Fix authentication flow`
|
||||
fn render_title_bar(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let iid_text = format!("#{} ", meta.iid);
|
||||
let iid_style = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let title_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
|
||||
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
|
||||
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render the metadata row: `opened | alice | backend, security`
|
||||
fn render_metadata_row(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let state_fg = match meta.state.as_str() {
|
||||
"opened" => GREEN,
|
||||
"closed" => RED,
|
||||
_ => TEXT_MUTED,
|
||||
};
|
||||
let state_style = Cell {
|
||||
fg: state_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted_style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let author_style = Cell {
|
||||
fg: CYAN,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
|
||||
|
||||
if !meta.labels.is_empty() {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
|
||||
let labels_text = meta.labels.join(", ");
|
||||
let _ = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
|
||||
}
|
||||
|
||||
if !meta.assignees.is_empty() {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
|
||||
let assignees_text = format!("-> {}", meta.assignees.join(", "));
|
||||
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x);
|
||||
}
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render optional milestone / due date row.
|
||||
fn render_milestone_row(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let mut cx = x;
|
||||
|
||||
if let Some(ref ms) = meta.milestone {
|
||||
cx = frame.print_text_clipped(cx, y, "Milestone: ", muted, max_x);
|
||||
let val_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
cx = frame.print_text_clipped(cx, y, ms, val_style, max_x);
|
||||
}
|
||||
|
||||
if let Some(ref due) = meta.due_date {
|
||||
if cx > x {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
|
||||
}
|
||||
cx = frame.print_text_clipped(cx, y, "Due: ", muted, max_x);
|
||||
let val_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(cx, y, due, val_style, max_x);
|
||||
}
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render a horizontal separator line.
|
||||
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
|
||||
let sep_style = Cell {
|
||||
fg: BORDER,
|
||||
..Cell::default()
|
||||
};
|
||||
let line: String = "\u{2500}".repeat(width as usize);
|
||||
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render a section header with focus indicator.
|
||||
fn render_section_header(
|
||||
frame: &mut Frame<'_>,
|
||||
label: &str,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
is_focused: bool,
|
||||
) {
|
||||
if is_focused {
|
||||
let style = Cell {
|
||||
fg: SELECTED_FG,
|
||||
bg: SELECTED_BG,
|
||||
..Cell::default()
|
||||
};
|
||||
// Fill the row with selected background.
|
||||
frame.draw_rect_filled(Rect::new(x, y, max_x.saturating_sub(x), 1), style);
|
||||
let _ = frame.print_text_clipped(x, y, label, style, max_x);
|
||||
} else {
|
||||
let style = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(x, y, label, style, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the description section.
|
||||
fn render_description(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
scroll: usize,
|
||||
area: Rect,
|
||||
_is_focused: bool,
|
||||
) {
|
||||
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
|
||||
let lines: Vec<&str> = safe_desc.lines().collect();
|
||||
|
||||
let text_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
for (i, line) in lines
|
||||
.iter()
|
||||
.skip(scroll)
|
||||
.take(area.height as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let y = area.y + i as u16;
|
||||
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the number of visible description lines for layout allocation.
|
||||
fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize {
|
||||
if meta.description.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Rough estimate: count newlines. Proper word-wrap would need unicode width.
|
||||
meta.description.lines().count().max(1)
|
||||
}
|
||||
|
||||
/// Allocate vertical space between description, discussions, and cross-refs.
|
||||
///
|
||||
/// Priority: description gets min(content, 40%), discussions get most of the
|
||||
/// remaining space, cross-refs get a fixed portion at the bottom.
|
||||
fn allocate_sections(
|
||||
available: u16,
|
||||
desc_lines: usize,
|
||||
_disc_count: usize,
|
||||
xref_count: usize,
|
||||
) -> (u16, u16, u16) {
|
||||
if available == 0 {
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
let total = available as usize;
|
||||
|
||||
// Cross-refs: 1 header + count, max 25% of space.
|
||||
let xref_need = if xref_count > 0 {
|
||||
(1 + xref_count).min(total / 4)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let after_xref = total.saturating_sub(xref_need);
|
||||
|
||||
// Description: up to 40% of remaining, but at least the content lines.
|
||||
let desc_max = after_xref * 2 / 5;
|
||||
let desc_alloc = desc_lines.min(desc_max).min(after_xref);
|
||||
|
||||
// Discussions: everything else.
|
||||
let disc_alloc = after_xref.saturating_sub(desc_alloc);
|
||||
|
||||
(desc_alloc as u16, disc_alloc as u16, xref_need as u16)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::FakeClock;
|
||||
use crate::message::EntityKey;
|
||||
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
|
||||
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
|
||||
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
|
||||
use ftui::render::grapheme_pool::GraphemePool;
|
||||
|
||||
macro_rules! with_frame {
|
||||
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||
let mut pool = GraphemePool::new();
|
||||
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||
$body
|
||||
}};
|
||||
}
|
||||
|
||||
fn sample_metadata() -> IssueMetadata {
|
||||
IssueMetadata {
|
||||
iid: 42,
|
||||
project_path: "group/project".into(),
|
||||
title: "Fix authentication flow".into(),
|
||||
description: "The login page has a bug.\nSteps to reproduce:\n1. Go to /login\n2. Enter credentials\n3. Click submit".into(),
|
||||
state: "opened".into(),
|
||||
author: "alice".into(),
|
||||
assignees: vec!["bob".into()],
|
||||
labels: vec!["backend".into(), "security".into()],
|
||||
milestone: Some("v1.0".into()),
|
||||
due_date: Some("2026-03-01".into()),
|
||||
created_at: 1_700_000_000_000,
|
||||
updated_at: 1_700_000_060_000,
|
||||
web_url: "https://gitlab.com/group/project/-/issues/42".into(),
|
||||
discussion_count: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_state_with_metadata() -> IssueDetailState {
|
||||
let mut state = IssueDetailState::default();
|
||||
state.load_new(EntityKey::issue(1, 42));
|
||||
state.apply_metadata(IssueDetailData {
|
||||
metadata: sample_metadata(),
|
||||
cross_refs: vec![CrossRef {
|
||||
kind: CrossRefKind::ClosingMr,
|
||||
entity_key: EntityKey::mr(1, 10),
|
||||
label: "Fix auth MR".into(),
|
||||
navigable: true,
|
||||
}],
|
||||
});
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_no_metadata_no_panic() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = IssueDetailState::default();
|
||||
let clock = FakeClock::from_ms(1_700_000_000_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_with_metadata_no_panic() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_tiny_area() {
|
||||
with_frame!(5, 2, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 5, 2), &clock);
|
||||
// Should bail early, no panic.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_with_discussions() {
|
||||
with_frame!(80, 40, |frame| {
|
||||
let mut state = sample_state_with_metadata();
|
||||
state.apply_discussions(vec![DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![NoteNode {
|
||||
author: "alice".into(),
|
||||
body: "I found the bug".into(),
|
||||
created_at: 1_700_000_000_000,
|
||||
is_system: false,
|
||||
is_diff_note: false,
|
||||
diff_file_path: None,
|
||||
diff_new_line: None,
|
||||
}],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
}]);
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 40), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_discussions_loading() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
// discussions_loaded is false by default after load_new.
|
||||
assert!(!state.discussions_loaded);
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_narrow_terminal() {
|
||||
with_frame!(30, 10, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_empty() {
|
||||
assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_balanced() {
|
||||
let (d, disc, x) = allocate_sections(20, 5, 3, 2);
|
||||
assert!(d > 0);
|
||||
assert!(disc > 0);
|
||||
assert!(x > 0);
|
||||
assert_eq!(d + disc + x, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_no_xrefs() {
|
||||
let (d, disc, x) = allocate_sections(20, 5, 3, 0);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(d + disc, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_no_discussions() {
|
||||
let (d, disc, x) = allocate_sections(20, 5, 0, 2);
|
||||
assert!(d > 0);
|
||||
assert_eq!(d + disc + x, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_description_lines() {
|
||||
let meta = sample_metadata();
|
||||
let lines = count_description_lines(&meta, 80);
|
||||
assert_eq!(lines, 5); // 5 lines in the sample description
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_description_lines_empty() {
|
||||
let mut meta = sample_metadata();
|
||||
meta.description = String::new();
|
||||
assert_eq!(count_description_lines(&meta, 80), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user