feat(tui): Phase 2 Issue List + MR List screens

Implement state, action, and view layers for both list screens:
- Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation
- MR List: mirrors Issue pattern with draft/reviewer/target branch filters
- Migration 027: covering indexes for TUI list screen queries
- Updated Msg types to use typed Page structs instead of raw Vec<Row>
- 303 tests passing, clippy clean

Beads: bd-3ei1, bd-2kr0, bd-3pm2
This commit is contained in:
teernisse
2026-02-18 13:06:06 -05:00
parent c5b7f4c864
commit 90c8b43267
33 changed files with 7850 additions and 483 deletions

View File

@@ -1,10 +1,255 @@
#![allow(dead_code)]
//! Dashboard screen state.
//!
//! The dashboard is the home screen — entity counts, per-project sync
//! status, recent activity, and the last sync summary.
// ---------------------------------------------------------------------------
// EntityCounts
// ---------------------------------------------------------------------------
/// Aggregated entity counts from the local database.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntityCounts {
pub issues_open: u64,
pub issues_total: u64,
pub mrs_open: u64,
pub mrs_total: u64,
pub discussions: u64,
pub notes_total: u64,
/// Percentage of notes that are system-generated (0-100).
pub notes_system_pct: u8,
pub documents: u64,
pub embeddings: u64,
}
// ---------------------------------------------------------------------------
// ProjectSyncInfo
// ---------------------------------------------------------------------------
/// Per-project sync freshness.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSyncInfo {
pub path: String,
pub minutes_since_sync: u64,
}
// ---------------------------------------------------------------------------
// RecentActivityItem
// ---------------------------------------------------------------------------
/// A recently-updated entity for the activity feed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentActivityItem {
/// "issue" or "mr".
pub entity_type: String,
pub iid: u64,
pub title: String,
pub state: String,
pub minutes_ago: u64,
}
// ---------------------------------------------------------------------------
// LastSyncInfo
// ---------------------------------------------------------------------------
/// Summary of the most recent sync run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LastSyncInfo {
pub status: String,
/// Milliseconds epoch UTC.
pub finished_at: Option<i64>,
pub command: String,
pub error: Option<String>,
}
// ---------------------------------------------------------------------------
// DashboardData
// ---------------------------------------------------------------------------
/// Data returned by the `fetch_dashboard` action.
///
/// Pure data transfer — no rendering or display logic.
#[derive(Debug, Clone, Default)]
pub struct DashboardData {
pub counts: EntityCounts,
pub projects: Vec<ProjectSyncInfo>,
pub recent: Vec<RecentActivityItem>,
pub last_sync: Option<LastSyncInfo>,
}
// ---------------------------------------------------------------------------
// DashboardState
// ---------------------------------------------------------------------------
/// State for the dashboard summary screen.
#[derive(Debug, Default)]
pub struct DashboardState {
pub issue_count: u64,
pub mr_count: u64,
pub counts: EntityCounts,
pub projects: Vec<ProjectSyncInfo>,
pub recent: Vec<RecentActivityItem>,
pub last_sync: Option<LastSyncInfo>,
/// Scroll offset for the recent activity list.
pub scroll_offset: usize,
}
impl DashboardState {
/// Apply fresh data from a `fetch_dashboard` result.
///
/// Preserves scroll offset (clamped to new data bounds).
pub fn update(&mut self, data: DashboardData) {
self.counts = data.counts;
self.projects = data.projects;
self.last_sync = data.last_sync;
self.recent = data.recent;
// Clamp scroll offset if the list shrunk.
if !self.recent.is_empty() {
self.scroll_offset = self.scroll_offset.min(self.recent.len() - 1);
} else {
self.scroll_offset = 0;
}
}
/// Scroll the recent activity list down by one.
pub fn scroll_down(&mut self) {
if !self.recent.is_empty() {
self.scroll_offset = (self.scroll_offset + 1).min(self.recent.len() - 1);
}
}
/// Scroll the recent activity list up by one.
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dashboard_state_default() {
let state = DashboardState::default();
assert_eq!(state.counts.issues_total, 0);
assert_eq!(state.scroll_offset, 0);
assert!(state.recent.is_empty());
}
#[test]
fn test_dashboard_state_update_applies_data() {
let mut state = DashboardState::default();
let data = DashboardData {
counts: EntityCounts {
issues_open: 3,
issues_total: 5,
..Default::default()
},
projects: vec![ProjectSyncInfo {
path: "group/project".into(),
minutes_since_sync: 42,
}],
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 1,
title: "Fix bug".into(),
state: "opened".into(),
minutes_ago: 10,
}],
last_sync: None,
};
state.update(data);
assert_eq!(state.counts.issues_open, 3);
assert_eq!(state.counts.issues_total, 5);
assert_eq!(state.projects.len(), 1);
assert_eq!(state.recent.len(), 1);
}
#[test]
fn test_dashboard_state_update_clamps_scroll() {
let mut state = DashboardState {
scroll_offset: 10,
..Default::default()
};
let data = DashboardData {
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 1,
title: "Only item".into(),
state: "opened".into(),
minutes_ago: 5,
}],
..Default::default()
};
state.update(data);
assert_eq!(state.scroll_offset, 0); // Clamped to len-1 = 0
}
#[test]
fn test_dashboard_state_update_empty_resets_scroll() {
let mut state = DashboardState {
scroll_offset: 5,
..Default::default()
};
state.update(DashboardData::default());
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_down_and_up() {
let mut state = DashboardState::default();
state.recent = (0..5)
.map(|i| RecentActivityItem {
entity_type: "issue".into(),
iid: i,
title: format!("Item {i}"),
state: "opened".into(),
minutes_ago: i,
})
.collect();
assert_eq!(state.scroll_offset, 0);
state.scroll_down();
assert_eq!(state.scroll_offset, 1);
state.scroll_down();
assert_eq!(state.scroll_offset, 2);
state.scroll_up();
assert_eq!(state.scroll_offset, 1);
state.scroll_up();
assert_eq!(state.scroll_offset, 0);
state.scroll_up(); // Can't go below 0
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_down_stops_at_end() {
let mut state = DashboardState::default();
state.recent = vec![RecentActivityItem {
entity_type: "mr".into(),
iid: 1,
title: "Only".into(),
state: "merged".into(),
minutes_ago: 0,
}];
state.scroll_down();
assert_eq!(state.scroll_offset, 0); // Can't scroll past single item
}
#[test]
fn test_scroll_on_empty_is_noop() {
let mut state = DashboardState::default();
state.scroll_down();
assert_eq!(state.scroll_offset, 0);
state.scroll_up();
assert_eq!(state.scroll_offset, 0);
}
}

View File

@@ -1,14 +1,376 @@
#![allow(dead_code)]
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/issue_list
//! Issue list screen state.
//!
//! Uses keyset pagination with a snapshot fence for stable ordering
//! under concurrent sync writes. Filter changes reset the pagination
//! cursor and snapshot fence.
use crate::message::IssueRow;
use std::hash::{Hash, Hasher};
// ---------------------------------------------------------------------------
// Cursor (keyset pagination boundary)
// ---------------------------------------------------------------------------
/// Keyset pagination cursor — (updated_at, iid) boundary.
///
/// The next page query uses `WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid)`
/// to avoid OFFSET instability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueCursor {
pub updated_at: i64,
pub iid: i64,
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
/// Structured filter for issue list queries.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueFilter {
pub state: Option<String>,
pub author: Option<String>,
pub assignee: Option<String>,
pub label: Option<String>,
pub milestone: Option<String>,
pub status: Option<String>,
pub free_text: Option<String>,
pub project_id: Option<i64>,
}
impl IssueFilter {
/// Compute a hash for change detection.
pub fn hash_value(&self) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.state.hash(&mut hasher);
self.author.hash(&mut hasher);
self.assignee.hash(&mut hasher);
self.label.hash(&mut hasher);
self.milestone.hash(&mut hasher);
self.status.hash(&mut hasher);
self.free_text.hash(&mut hasher);
self.project_id.hash(&mut hasher);
hasher.finish()
}
/// Whether any filter is active.
pub fn is_active(&self) -> bool {
self.state.is_some()
|| self.author.is_some()
|| self.assignee.is_some()
|| self.label.is_some()
|| self.milestone.is_some()
|| self.status.is_some()
|| self.free_text.is_some()
|| self.project_id.is_some()
}
}
// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------
/// A single row in the issue list.
#[derive(Debug, Clone)]
pub struct IssueListRow {
pub project_path: String,
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub labels: Vec<String>,
pub updated_at: i64,
}
// ---------------------------------------------------------------------------
// Page result
// ---------------------------------------------------------------------------
/// Result from a paginated issue list query.
#[derive(Debug, Clone)]
pub struct IssueListPage {
pub rows: Vec<IssueListRow>,
pub next_cursor: Option<IssueCursor>,
pub total_count: u64,
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
/// Fields available for sorting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortField {
#[default]
UpdatedAt,
Iid,
Title,
State,
Author,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Desc,
Asc,
}
// ---------------------------------------------------------------------------
// IssueListState
// ---------------------------------------------------------------------------
/// State for the issue list screen.
#[derive(Debug, Default)]
pub struct IssueListState {
pub rows: Vec<IssueRow>,
pub filter: String,
pub filter_focused: bool,
/// Current page of issue rows.
pub rows: Vec<IssueListRow>,
/// Total count of matching issues.
pub total_count: u64,
/// Selected row index (within current window).
pub selected_index: usize,
/// Scroll offset for the entity table.
pub scroll_offset: usize,
/// Cursor for the next page.
pub next_cursor: Option<IssueCursor>,
/// Whether a prefetch is in flight.
pub prefetch_in_flight: bool,
/// Current filter.
pub filter: IssueFilter,
/// Raw filter input text.
pub filter_input: String,
/// Whether the filter bar has focus.
pub filter_focused: bool,
/// Sort field.
pub sort_field: SortField,
/// Sort direction.
pub sort_order: SortOrder,
/// Snapshot fence: max updated_at from initial load.
pub snapshot_fence: Option<i64>,
/// Hash of the current filter for change detection.
pub filter_hash: u64,
/// Whether Quick Peek is visible.
pub peek_visible: bool,
}
impl IssueListState {
/// Reset pagination state (called when filter changes or on refresh).
pub fn reset_pagination(&mut self) {
self.rows.clear();
self.next_cursor = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.snapshot_fence = None;
self.total_count = 0;
self.prefetch_in_flight = false;
}
/// Apply a new page of results.
pub fn apply_page(&mut self, page: IssueListPage) {
// Set snapshot fence on first page load.
if self.snapshot_fence.is_none() {
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
}
self.rows.extend(page.rows);
self.next_cursor = page.next_cursor;
self.total_count = page.total_count;
self.prefetch_in_flight = false;
}
/// Check if filter changed and reset if needed.
pub fn check_filter_change(&mut self) -> bool {
let new_hash = self.filter.hash_value();
if new_hash != self.filter_hash {
self.filter_hash = new_hash;
self.reset_pagination();
true
} else {
false
}
}
/// Whether the user has scrolled near the end of current data (80% threshold).
pub fn should_prefetch(&self) -> bool {
if self.prefetch_in_flight || self.next_cursor.is_none() {
return false;
}
if self.rows.is_empty() {
return false;
}
let threshold = (self.rows.len() * 4) / 5; // 80%
self.selected_index >= threshold
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(count: usize, has_next: bool) -> IssueListPage {
let rows: Vec<IssueListRow> = (0..count)
.map(|i| IssueListRow {
project_path: "group/project".into(),
iid: (count - i) as i64,
title: format!("Issue {}", count - i),
state: "opened".into(),
author: "taylor".into(),
labels: vec![],
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
})
.collect();
let next_cursor = if has_next {
rows.last().map(|r| IssueCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
IssueListPage {
rows,
next_cursor,
total_count: if has_next {
(count * 2) as u64
} else {
count as u64
},
}
}
#[test]
fn test_apply_page_sets_snapshot_fence() {
let mut state = IssueListState::default();
let page = sample_page(5, false);
state.apply_page(page);
assert_eq!(state.rows.len(), 5);
assert!(state.snapshot_fence.is_some());
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
}
#[test]
fn test_apply_page_appends() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
assert_eq!(state.rows.len(), 5);
state.apply_page(sample_page(3, false));
assert_eq!(state.rows.len(), 8);
}
#[test]
fn test_reset_pagination_clears_state() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
state.selected_index = 3;
state.reset_pagination();
assert!(state.rows.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.next_cursor.is_none());
assert!(state.snapshot_fence.is_none());
}
#[test]
fn test_check_filter_change_detects_change() {
let mut state = IssueListState::default();
state.filter_hash = state.filter.hash_value();
state.filter.state = Some("opened".into());
assert!(state.check_filter_change());
}
#[test]
fn test_check_filter_change_no_change() {
let mut state = IssueListState::default();
state.filter_hash = state.filter.hash_value();
assert!(!state.check_filter_change());
}
#[test]
fn test_should_prefetch() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 4; // 40% — no prefetch
assert!(!state.should_prefetch());
state.selected_index = 8; // 80% — prefetch
assert!(state.should_prefetch());
}
#[test]
fn test_should_prefetch_no_next_page() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, false));
state.selected_index = 9;
assert!(!state.should_prefetch());
}
#[test]
fn test_should_prefetch_already_in_flight() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 9;
state.prefetch_in_flight = true;
assert!(!state.should_prefetch());
}
#[test]
fn test_issue_filter_is_active() {
let empty = IssueFilter::default();
assert!(!empty.is_active());
let active = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
assert!(active.is_active());
}
#[test]
fn test_issue_filter_hash_deterministic() {
let f1 = IssueFilter {
state: Some("opened".into()),
author: Some("taylor".into()),
..Default::default()
};
let f2 = f1.clone();
assert_eq!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_issue_filter_hash_differs() {
let f1 = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
let f2 = IssueFilter {
state: Some("closed".into()),
..Default::default()
};
assert_ne!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_snapshot_fence_not_overwritten_on_second_page() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
let fence = state.snapshot_fence;
state.apply_page(sample_page(3, false));
assert_eq!(
state.snapshot_fence, fence,
"Fence should not change on second page"
);
}
}

View File

@@ -24,7 +24,7 @@ pub mod sync;
pub mod timeline;
pub mod who;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use crate::message::Screen;
@@ -80,6 +80,8 @@ impl LoadState {
#[derive(Debug, Default)]
pub struct ScreenLoadStateMap {
map: HashMap<Screen, LoadState>,
/// Screens that have had a load state set at least once.
visited: HashSet<Screen>,
}
impl ScreenLoadStateMap {
@@ -94,6 +96,7 @@ impl ScreenLoadStateMap {
///
/// Setting to `Idle` removes the entry to prevent map growth.
pub fn set(&mut self, screen: Screen, state: LoadState) {
self.visited.insert(screen.clone());
if state == LoadState::Idle {
self.map.remove(&screen);
} else {
@@ -101,6 +104,12 @@ impl ScreenLoadStateMap {
}
}
/// Whether this screen has ever had a load initiated.
#[must_use]
pub fn was_visited(&self, screen: &Screen) -> bool {
self.visited.contains(screen)
}
/// Whether any screen is currently loading.
#[must_use]
pub fn any_loading(&self) -> bool {

View File

@@ -1,14 +1,422 @@
#![allow(dead_code)]
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/mr_list
//! Merge request list screen state.
//!
//! Mirrors the issue list pattern with MR-specific filter fields
//! (draft, reviewer, target/source branch). Uses the same keyset
//! pagination with snapshot fence for stable ordering.
use crate::message::MrRow;
use std::hash::{Hash, Hasher};
// ---------------------------------------------------------------------------
// Cursor (keyset pagination boundary)
// ---------------------------------------------------------------------------
/// Keyset pagination cursor — (updated_at, iid) boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrCursor {
pub updated_at: i64,
pub iid: i64,
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
/// Structured filter for MR list queries.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MrFilter {
pub state: Option<String>,
pub author: Option<String>,
pub reviewer: Option<String>,
pub target_branch: Option<String>,
pub source_branch: Option<String>,
pub label: Option<String>,
pub draft: Option<bool>,
pub free_text: Option<String>,
pub project_id: Option<i64>,
}
impl MrFilter {
/// Compute a hash for change detection.
pub fn hash_value(&self) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.state.hash(&mut hasher);
self.author.hash(&mut hasher);
self.reviewer.hash(&mut hasher);
self.target_branch.hash(&mut hasher);
self.source_branch.hash(&mut hasher);
self.label.hash(&mut hasher);
self.draft.hash(&mut hasher);
self.free_text.hash(&mut hasher);
self.project_id.hash(&mut hasher);
hasher.finish()
}
/// Whether any filter is active.
pub fn is_active(&self) -> bool {
self.state.is_some()
|| self.author.is_some()
|| self.reviewer.is_some()
|| self.target_branch.is_some()
|| self.source_branch.is_some()
|| self.label.is_some()
|| self.draft.is_some()
|| self.free_text.is_some()
|| self.project_id.is_some()
}
}
// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------
/// A single row in the MR list.
#[derive(Debug, Clone)]
pub struct MrListRow {
pub project_path: String,
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub target_branch: String,
pub labels: Vec<String>,
pub updated_at: i64,
pub draft: bool,
}
// ---------------------------------------------------------------------------
// Page result
// ---------------------------------------------------------------------------
/// Result from a paginated MR list query.
#[derive(Debug, Clone)]
pub struct MrListPage {
pub rows: Vec<MrListRow>,
pub next_cursor: Option<MrCursor>,
pub total_count: u64,
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
/// Fields available for sorting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrSortField {
#[default]
UpdatedAt,
Iid,
Title,
State,
Author,
TargetBranch,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrSortOrder {
#[default]
Desc,
Asc,
}
// ---------------------------------------------------------------------------
// MrListState
// ---------------------------------------------------------------------------
/// State for the MR list screen.
#[derive(Debug, Default)]
pub struct MrListState {
pub rows: Vec<MrRow>,
pub filter: String,
pub filter_focused: bool,
/// Current page of MR rows.
pub rows: Vec<MrListRow>,
/// Total count of matching MRs.
pub total_count: u64,
/// Selected row index (within current window).
pub selected_index: usize,
/// Scroll offset for the entity table.
pub scroll_offset: usize,
/// Cursor for the next page.
pub next_cursor: Option<MrCursor>,
/// Whether a prefetch is in flight.
pub prefetch_in_flight: bool,
/// Current filter.
pub filter: MrFilter,
/// Raw filter input text.
pub filter_input: String,
/// Whether the filter bar has focus.
pub filter_focused: bool,
/// Sort field.
pub sort_field: MrSortField,
/// Sort direction.
pub sort_order: MrSortOrder,
/// Snapshot fence: max updated_at from initial load.
pub snapshot_fence: Option<i64>,
/// Hash of the current filter for change detection.
pub filter_hash: u64,
/// Whether Quick Peek is visible.
pub peek_visible: bool,
}
impl MrListState {
/// Reset pagination state (called when filter changes or on refresh).
pub fn reset_pagination(&mut self) {
self.rows.clear();
self.next_cursor = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.snapshot_fence = None;
self.total_count = 0;
self.prefetch_in_flight = false;
}
/// Apply a new page of results.
pub fn apply_page(&mut self, page: MrListPage) {
// Set snapshot fence on first page load.
if self.snapshot_fence.is_none() {
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
}
self.rows.extend(page.rows);
self.next_cursor = page.next_cursor;
self.total_count = page.total_count;
self.prefetch_in_flight = false;
}
/// Check if filter changed and reset if needed.
pub fn check_filter_change(&mut self) -> bool {
let new_hash = self.filter.hash_value();
if new_hash != self.filter_hash {
self.filter_hash = new_hash;
self.reset_pagination();
true
} else {
false
}
}
/// Whether the user has scrolled near the end of current data (80% threshold).
pub fn should_prefetch(&self) -> bool {
if self.prefetch_in_flight || self.next_cursor.is_none() {
return false;
}
if self.rows.is_empty() {
return false;
}
let threshold = (self.rows.len() * 4) / 5; // 80%
self.selected_index >= threshold
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(count: usize, has_next: bool) -> MrListPage {
let rows: Vec<MrListRow> = (0..count)
.map(|i| MrListRow {
project_path: "group/project".into(),
iid: (count - i) as i64,
title: format!("MR {}", count - i),
state: "opened".into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
draft: i % 3 == 0,
})
.collect();
let next_cursor = if has_next {
rows.last().map(|r| MrCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
MrListPage {
rows,
next_cursor,
total_count: if has_next {
(count * 2) as u64
} else {
count as u64
},
}
}
#[test]
fn test_apply_page_sets_snapshot_fence() {
let mut state = MrListState::default();
let page = sample_page(5, false);
state.apply_page(page);
assert_eq!(state.rows.len(), 5);
assert!(state.snapshot_fence.is_some());
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
}
#[test]
fn test_apply_page_appends() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
assert_eq!(state.rows.len(), 5);
state.apply_page(sample_page(3, false));
assert_eq!(state.rows.len(), 8);
}
#[test]
fn test_reset_pagination_clears_state() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
state.selected_index = 3;
state.reset_pagination();
assert!(state.rows.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.next_cursor.is_none());
assert!(state.snapshot_fence.is_none());
}
#[test]
fn test_check_filter_change_detects_change() {
let mut state = MrListState::default();
state.filter_hash = state.filter.hash_value();
state.filter.state = Some("opened".into());
assert!(state.check_filter_change());
}
#[test]
fn test_check_filter_change_no_change() {
let mut state = MrListState::default();
state.filter_hash = state.filter.hash_value();
assert!(!state.check_filter_change());
}
#[test]
fn test_should_prefetch() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 4; // 40% -- no prefetch
assert!(!state.should_prefetch());
state.selected_index = 8; // 80% -- prefetch
assert!(state.should_prefetch());
}
#[test]
fn test_should_prefetch_no_next_page() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, false));
state.selected_index = 9;
assert!(!state.should_prefetch());
}
#[test]
fn test_should_prefetch_already_in_flight() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 9;
state.prefetch_in_flight = true;
assert!(!state.should_prefetch());
}
#[test]
fn test_mr_filter_is_active() {
let empty = MrFilter::default();
assert!(!empty.is_active());
let active = MrFilter {
state: Some("opened".into()),
..Default::default()
};
assert!(active.is_active());
let draft_active = MrFilter {
draft: Some(true),
..Default::default()
};
assert!(draft_active.is_active());
}
#[test]
fn test_mr_filter_hash_deterministic() {
let f1 = MrFilter {
state: Some("opened".into()),
author: Some("taylor".into()),
..Default::default()
};
let f2 = f1.clone();
assert_eq!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_mr_filter_hash_differs() {
let f1 = MrFilter {
state: Some("opened".into()),
..Default::default()
};
let f2 = MrFilter {
state: Some("merged".into()),
..Default::default()
};
assert_ne!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_snapshot_fence_not_overwritten_on_second_page() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
let fence = state.snapshot_fence;
state.apply_page(sample_page(3, false));
assert_eq!(
state.snapshot_fence, fence,
"Fence should not change on second page"
);
}
#[test]
fn test_mr_filter_reviewer_field() {
let f = MrFilter {
reviewer: Some("alice".into()),
..Default::default()
};
assert!(f.is_active());
assert_ne!(f.hash_value(), MrFilter::default().hash_value());
}
#[test]
fn test_mr_filter_target_branch_field() {
let f = MrFilter {
target_branch: Some("main".into()),
..Default::default()
};
assert!(f.is_active());
}
#[test]
fn test_mr_list_row_draft_field() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 1,
title: "Draft MR".into(),
state: "opened".into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 0,
draft: true,
};
assert!(row.draft);
}
}