Complete TUI Phase 3 implementation with all 5 power feature screens: - Who screen: 5 modes (expert/workload/reviews/active/overlap) with mode tabs, input bar, result rendering, and hint bar - Search screen: full-text search with result list and scoring display - Timeline screen: chronological event feed with time-relative display - Trace screen: file provenance chains with expand/collapse, rename tracking, and linked issues/discussions - File History screen: per-file MR timeline with rename chain display and discussion snippets Also includes: - Command palette overlay (fuzzy search) - Bootstrap screen (initial sync flow) - Action layer split from monolithic action.rs to per-screen modules - Entity and render cache infrastructure - Shared who_types module in core crate - All screens wired into view/mod.rs dispatch - 597 tests passing, clippy clean (pedantic + nursery), fmt clean
627 lines
20 KiB
Rust
627 lines
20 KiB
Rust
#![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(", ");
|
|
cx = 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);
|
|
}
|
|
}
|