Skip to main content

tix/ui/
board.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6use std::collections::BTreeMap;
7use std::collections::HashMap;
8use std::collections::HashSet;
9
10use crate::config::BoardConfiguration;
11use crate::model::{Issue, Priority};
12use crate::search::{Corpus, StructuredFilter};
13use crate::storage::Store;
14
15use super::widgets;
16
17#[derive(Debug, Clone)]
18struct GroupSection {
19    name: String,
20    start: usize,
21    end: usize,
22    done: usize,
23    total: usize,
24}
25
26/// The grouped board's direct-navigation affordance (WP06 T030 step 5, FR-004).
27///
28/// A grouped board must still open an issue's own detail and edit without passing
29/// through the epic layer, so the footer states it rather than leaving it implied by
30/// the card text. One constant, so the render and any test that names it cannot
31/// drift.
32const GROUP_OPEN_AFFORDANCE: &str = "Enter: open issue";
33
34/// One badge per issue that records health, keyed by number (WP06 T030).
35///
36/// Built once per board build, like `blocked_reasons` and `child_markers`: the badge
37/// comes from the shared index and rollup, and an issue with neither a recorded value
38/// nor contributors gets no entry at all.
39fn health_badges(issues: &[Issue]) -> BTreeMap<u32, String> {
40    let policy = crate::health::HealthPolicy::v1();
41    let index = crate::health::HealthIndex::build(issues, &[], &[], &policy);
42    let mut out = BTreeMap::new();
43    for issue in issues {
44        let scope = crate::health::HealthScope::of_issue(issue.number);
45        let Some((state, recorded)) = index.state_of(issue.number) else {
46            continue;
47        };
48        let derived = crate::health::rollup(&index, &scope, &policy);
49        // Nothing recorded and nobody contributing: nothing to say. A legacy issue is
50        // reported as `unknown` by the detail view and by the CLI, which is where a
51        // reader who asked about it is looking.
52        if recorded.is_none() && derived.contributions.is_empty() {
53            continue;
54        }
55        let current = state.as_str();
56        let badge = if derived.is_derived() && derived.state.as_str() != current {
57            format!("{current}\u{2192}{}", derived.state.as_str())
58        } else {
59            current.to_string()
60        };
61        out.insert(issue.number, badge);
62    }
63    out
64}
65
66#[derive(Debug, Clone)]
67pub struct Card {
68    pub number: u32,
69    pub title: String,
70    pub priority: Priority,
71    pub labels: Vec<String>,
72    pub assignee: Option<String>,
73    pub filename: String,
74    pub commit: Option<String>,
75    pub created: String,
76    pub due_date: Option<String>,
77    pub status: String,
78    /// The issue's resolved category label, from the service's own `label()`
79    /// (WP05 T022, FR-004).
80    ///
81    /// `Some` for a column the board configures, `None` for one it does not — a
82    /// configuration error the user should be able to *see*, rather than a
83    /// transition-policy default silently applied to a display. The renderer shows
84    /// `(unmapped)` for `None`.
85    ///
86    /// **The label carries the meaning, not the color.** A board or terminal with no
87    /// color must still convey the category, so the label is always rendered and the
88    /// `color_hint` only ever tints it (T022 step 3, research R6).
89    pub category: Option<String>,
90    /// The category's color hint, for tinting. Advisory only: dropping it changes
91    /// the display's color and nothing else.
92    pub category_color: Option<crate::workflow::ColorHint>,
93    /// Whether the service considers this issue **complete** (WP05, C-004).
94    ///
95    /// Was `issue.status.is(done_column)` — a column-*name* comparison. On a board
96    /// whose `category_map` puts `completed` somewhere other than its `done_column`
97    /// that is a different question from the one the service answers, and this flag
98    /// drives the card's completion styling and the group's rollup.
99    pub done: bool,
100    pub cycle: Option<String>,
101    pub milestone: Option<String>,
102    /// The issue's blocked reason, from the relationship service, or `None`.
103    ///
104    /// Derived, never stored (data-model §4, C-003): recomputed from the graph
105    /// on every board build, so a card cannot show a stale blocked state after
106    /// its blocker moved.
107    pub blocked: Option<String>,
108    /// The issue's hierarchy marker: `3` for three children, `3 1/3` when one is
109    /// complete, or `None` for a leaf.
110    ///
111    /// `None` for a leaf rather than `Some("0")`, so a card cannot show a child
112    /// count for an issue that is not a parent. Built from the service's
113    /// [`crate::commands::parent::HierarchyFacts`] on every board build, so it
114    /// cannot go stale when a child is created or moved — the same discipline
115    /// `blocked` follows.
116    pub children: Option<String>,
117    /// The issue's decomposition parent, when it has one (tix #1399).
118    ///
119    /// Carried on the card rather than looked up at render time, so the board can
120    /// place a sub-issue beneath its parent without a second pass over the issues.
121    pub parent: Option<u32>,
122    /// How deeply the card is nested in the board's tree rendering (tix #1399).
123    ///
124    /// `0` for a top-level card, `1` for a sub-issue shown directly under its
125    /// parent. Never above `1`: only one level is rendered, so a grandchild stays
126    /// at `0` and is drawn as a normal top-level card.
127    pub depth: u8,
128    /// Whether this issue is **archived** — its file is dot-prefixed (issue #1492).
129    ///
130    /// A card only ever carries `true` on a board built with archived issues
131    /// included (the TUI's archive toggle); the default board is enumerated from
132    /// the active set and never sees one. Rendered as the `[archived]` token, in the
133    /// same bracketed language as `[~]` and `[N]`.
134    pub archived: bool,
135
136    /// The issue's compact health badge (mission `project-issue-health-status-01M3C8HX`,
137    /// WP06 T030): the state the issue records, and the derived value beside it when
138    /// the two differ (`at_risk->needs_attention`).
139    ///
140    /// Built from the **shared** index and rollup — never from a surface-local reading
141    /// of the `health:` block, and never from a comparison of statuses (C-006). The
142    /// spelling is the state's stable `as_str()`, the same word the CLI's
143    /// `--columns health` cell prints, so the two surfaces cannot diverge. `None` when
144    /// the issue records no health at all: a legacy card carries no badge, exactly as
145    /// a leaf carries no child marker, so the board stays scannable.
146    pub health: Option<String>,
147    /// How many `related_issues` entries the issue carries (tix #1513).
148    ///
149    /// A plain count, computed from the issue set the board was built over, so the
150    /// card cannot go stale when the field is edited — the same discipline `blocked`
151    /// and `children` follow. `0` means no marker at all, which keeps a column of
152    /// cards scannable.
153    pub related: usize,
154    /// The issue's **resolved project ids**, in registry declaration order
155    /// (WP06 T028).
156    ///
157    /// Resolved once per board build from the shared membership index
158    /// ([`crate::projects::ProjectIndex::build`]), so the board cannot disagree with
159    /// `tix project list` about who is a member, and an association the registry does
160    /// not know is not a project here either. Empty means the issue is in no project,
161    /// which is the bucket the partition labels `(no project)`.
162    ///
163    /// Carried on the card rather than looked up at render time for the same reason
164    /// `blocked`, `children` and `related` are: the renderer has no store, and a
165    /// second read of the issue set per frame is work the build already did.
166    pub projects: Vec<String>,
167    /// The issue's **resolved epic ids**, in registry declaration order (WP06
168    /// T030).
169    ///
170    /// Resolved once per board build from the shared membership index
171    /// ([`crate::epics::EpicIndex::build`]) — only references the registry can
172    /// resolve — so the board cannot disagree with `tix epic list` about who is a
173    /// member, and a reference the registry does not know is not an epic here
174    /// either. Empty means the issue is in no epic, which is the bucket the
175    /// partition labels `(no epic)`.
176    ///
177    /// Carried on the card rather than looked up at render time for the same reason
178    /// `projects` is: the renderer has no store, and a second read of the issue set
179    /// per frame is work the build already did.
180    pub epics: Vec<String>,
181}
182
183/// The project partition of one board build (WP06 T028).
184///
185/// Built once from the **shared** membership index
186/// ([`crate::projects::ProjectIndex::build`]), so the board cannot disagree with
187/// `tix project list` about who is a member: the resolved ids are the registry's,
188/// and an association the registry does not know is not a project here either — it
189/// is reported by `tix lint` instead, and its issue is bucketed `(no project)`.
190///
191/// That last point is a **chosen asymmetry** with the criterion rather than a
192/// disagreement to be papered over: `StructuredFilter::project_matches` matches a
193/// stored association **verbatim**, so `project:<stale-id>` still *finds* the issues
194/// carrying a deleted project's id (the repair affordance `tix project remove`
195/// needs), while this partition — which answers "which project owns this issue?" —
196/// has no bucket for an id nothing resolves to. See `project_matches`'s doc.
197///
198/// The bucket rules are the design decision T027 step 4 records:
199///
200/// - an issue in **no** project is its own clearly labelled `(no project)` bucket;
201/// - an issue in exactly **one** project sits in that project's bucket;
202/// - an issue in **several** projects sits in a single `(multiple projects)`
203///   bucket rather than being duplicated into each project's section.
204///
205/// Duplication is the road not taken, and deliberately: one issue would become two
206/// cards, and the board's cursor, hit-testing and per-column counts are all
207/// index-into-one-list arithmetic — a card that appeared twice would need a second
208/// identity the renderer does not have. The **filter** still matches an issue in
209/// two projects under either id (that is [`crate::search::StructuredFilter::project_matches`]),
210/// so the union semantics are not lost where they matter.
211struct ProjectPartition {
212    /// Issue number -> its resolved project ids, registry declaration order.
213    memberships: HashMap<u32, Vec<String>>,
214    /// Project id -> its declaration index, for the sort rank.
215    order: HashMap<String, usize>,
216}
217
218impl ProjectPartition {
219    fn build(config: &crate::config::Config, issues: &[Issue]) -> Self {
220        // Both halves come from the **service**, so the board cannot disagree with
221        // `tix list --group project` / `tix status --group project` about who is a
222        // member or which bucket labels them (WP09 item D): the memberships are
223        // [`crate::projects::ProjectIndex::resolved_memberships`], and the label is
224        // [`crate::projects::project_bucket_label`].
225        let index = crate::projects::ProjectIndex::build(config, issues);
226        let order: HashMap<String, usize> = index
227            .projects()
228            .iter()
229            .enumerate()
230            .map(|(position, project)| (project.id.to_string(), position))
231            .collect();
232        Self {
233            memberships: index.resolved_memberships(),
234            order,
235        }
236    }
237
238    /// The issue's resolved project ids, in declaration order.
239    fn memberships(&self, number: u32) -> Vec<String> {
240        self.memberships.get(&number).cloned().unwrap_or_default()
241    }
242
243    /// The partition rank of one issue: a single membership sorts by its project's
244    /// declaration index, several sort after every declared project (and before
245    /// the no-project bucket), and none sorts last so `(no project)` is the final
246    /// section.
247    fn rank(&self, number: u32) -> usize {
248        match self.memberships.get(&number).map(Vec::as_slice) {
249            None | Some([]) => usize::MAX,
250            Some([only]) => self.order.get(only).copied().unwrap_or(usize::MAX),
251            Some(_) => usize::MAX - 1,
252        }
253    }
254}
255
256/// The group-section label for one card's project membership (WP06 T028).
257///
258/// `(no project)` is the clearly labelled bucket T028 step 2 requires, and
259/// `(multiple projects)` is the bucket an issue in several projects lands in (see
260/// [`ProjectPartition`] for why it is one bucket rather than a duplicate per
261/// project). A single membership is labelled with the project **id**, because the
262/// id is what selects a project, so a reader can act on the header.
263fn project_bucket_label(projects: &[String]) -> String {
264    // The service owns the bucket rule (WP09 item D); this delegates rather than
265    // spelling the three labels a second time.
266    crate::projects::project_bucket_label(projects)
267}
268
269/// The epic partition of one board build (WP06 T030).
270///
271/// The exact analogue of [`ProjectPartition`] for the epic registry. Built once from
272/// the **shared** membership index ([`crate::epics::EpicIndex::build`]), so the
273/// board cannot disagree with `tix epic list` about who is a member: the resolved ids
274/// are the registry's, and a reference the registry does not know is not an epic here
275/// either — it is reported by `tix lint` instead, and its issue is bucketed
276/// `(no epic)`.
277///
278/// That last point is the same **chosen asymmetry** the project partition records:
279/// [`crate::search::StructuredFilter::epic_matches`] matches a stored reference
280/// **verbatim**, so `epic:<stale-id>` still *finds* the issues carrying a deleted
281/// epic's id (the repair affordance `tix epic remove` needs), while this partition —
282/// which answers "which epic owns this issue?" — has no bucket for an id nothing
283/// resolves to.
284///
285/// The bucket rules are the decision T029 step 4 records (one bucket per issue):
286///
287/// - an issue in **no** epic is its own clearly labelled `(no epic)` bucket;
288/// - an issue in exactly **one** epic sits in that epic's bucket;
289/// - an issue in **several** epics sits in a single `(multiple epics)` bucket rather
290///   than being duplicated into each epic's section.
291///
292/// Duplication is the road not taken for the reason [`ProjectPartition`] gives: one
293/// issue would become several cards, and the board's cursor, hit-testing and
294/// per-column counts are all index-into-one-list arithmetic. The **filter** still
295/// matches an issue in two epics under either id, so the union semantics are not lost
296/// where they matter.
297struct EpicPartition {
298    /// Issue number -> its resolved epic ids, registry declaration order.
299    memberships: HashMap<u32, Vec<String>>,
300    /// Epic id -> its declaration index, for the sort rank.
301    order: HashMap<String, usize>,
302}
303
304impl EpicPartition {
305    fn build(config: &crate::config::Config, issues: &[Issue]) -> Self {
306        // Both halves come from the **service**: the index owns the membership rule
307        // (which references resolve), and `memberships_by_issue` is the service's own
308        // inverse of its buckets, in declaration order — so a filter, the CLI's
309        // `--group epic` and this partition cannot disagree about who is a member
310        // (WP09 item C).
311        let index = crate::epics::EpicIndex::build(config, issues);
312        let order: HashMap<String, usize> = index
313            .epics()
314            .iter()
315            .enumerate()
316            .map(|(position, epic)| (epic.id.to_string(), position))
317            .collect();
318        let memberships = index.memberships_by_issue();
319        Self { memberships, order }
320    }
321
322    /// The issue's resolved epic ids, in declaration order.
323    fn memberships(&self, number: u32) -> Vec<String> {
324        self.memberships.get(&number).cloned().unwrap_or_default()
325    }
326
327    /// The partition rank of one issue: a single membership sorts by its epic's
328    /// declaration index, several sort after every declared epic (and before the
329    /// no-epic bucket), and none sorts last so `(no epic)` is the final section.
330    fn rank(&self, number: u32) -> usize {
331        match self.memberships.get(&number).map(Vec::as_slice) {
332            None | Some([]) => usize::MAX,
333            Some([only]) => self.order.get(only).copied().unwrap_or(usize::MAX),
334            Some(_) => usize::MAX - 1,
335        }
336    }
337}
338
339/// The group-section label for one card's epic membership (WP06 T030).
340///
341/// Delegates to [`crate::search::epic_bucket_label`] rather than spelling the three
342/// labels a second time, so the board's header and the filter's vocabulary name the
343/// same bucket.
344fn epic_bucket_label(ids: &[String]) -> String {
345    crate::search::epic_bucket_label(ids)
346}
347
348impl crate::config::OrderBy {
349    /// Sort cards in place per the board's `order_by` setting. `none` is a
350    /// no-op. `sort_by` is stable, so equal creation dates keep the
351    /// underlying store order (relative filename ascending) — deterministic
352    /// ties with no extra key. Dates are zero-padded `YYYY-MM-DD`, so
353    /// lexicographic comparison is chronological.
354    pub fn sort(&self, cards: &mut [Card]) {
355        match self {
356            crate::config::OrderBy::CreatedAtDesc => {
357                cards.sort_by(|a, b| b.created.cmp(&a.created))
358            }
359            crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
360            crate::config::OrderBy::None => {}
361        }
362    }
363}
364
365fn sort_by_default_order(board_config: &BoardConfiguration, cards: &mut [Card]) {
366    match board_config.order_by {
367        crate::config::OrderBy::CreatedAtDesc => {
368            cards.sort_by(|a, b| {
369                b.created
370                    .cmp(&a.created)
371                    .then_with(|| a.filename.cmp(&b.filename))
372            });
373        }
374        crate::config::OrderBy::CreatedAtAsc => {
375            cards.sort_by(|a, b| {
376                a.created
377                    .cmp(&b.created)
378                    .then_with(|| a.filename.cmp(&b.filename))
379            });
380        }
381        crate::config::OrderBy::None => {
382            cards.sort_by(|a, b| a.filename.cmp(&b.filename));
383        }
384    }
385}
386
387fn sort_cards(board_config: &BoardConfiguration, cards: &mut [Card]) {
388    if board_config.card_order.is_empty() {
389        sort_by_default_order(board_config, cards);
390        return;
391    }
392
393    let mut baseline = cards.to_vec();
394    sort_by_default_order(board_config, &mut baseline);
395
396    let manual_cards: Vec<Card> = board_config
397        .card_order
398        .iter()
399        .filter_map(|filename| {
400            baseline
401                .iter()
402                .find(|card| card.filename == *filename)
403                .cloned()
404        })
405        .collect();
406    let touched: HashSet<String> = manual_cards
407        .iter()
408        .map(|card| card.filename.clone())
409        .collect();
410    let mut manual_iter = manual_cards.into_iter();
411    let mut ordered = Vec::with_capacity(baseline.len());
412
413    for card in baseline {
414        if touched.contains(card.filename.as_str()) {
415            if let Some(manual_card) = manual_iter.next() {
416                ordered.push(manual_card);
417            } else {
418                ordered.push(card);
419            }
420        } else {
421            ordered.push(card);
422        }
423    }
424
425    cards.clone_from_slice(&ordered);
426}
427
428/// Reorder one column so each card's sub-issues sit **immediately after it**,
429/// marking them `depth = 1` (tix #1399).
430///
431/// Only **one** level is ever nested. A card is placed under its parent only when
432/// that parent is itself a *top-level* card of this column — meaning the parent has
433/// no parent of its own present here. A grandchild (a card whose parent is itself a
434/// sub-issue) therefore stays `depth = 0` in its normal position rather than
435/// stacking under the child, so the tree cannot grow past one indent.
436///
437/// The ordering is settled **here, at build time**, not in the renderer: the
438/// selection index and the pointer hit-test both address cards by their index in
439/// the column, so a reorder only the renderer knew about would make a click or a
440/// `j` land on the wrong card.
441///
442/// Every card is emitted exactly once: a nested card is dropped from the top-level
443/// sequence and re-emitted under its parent, and nothing else moves.
444fn nest_children(cards: &mut Vec<Card>) {
445    let count = cards.len();
446    if count == 0 {
447        return;
448    }
449    let position: std::collections::HashMap<u32, usize> = cards
450        .iter()
451        .enumerate()
452        .map(|(i, card)| (card.number, i))
453        .collect();
454    // A card is a root of *this* column when it has no parent here: no parent at
455    // all, or a parent living in another column.
456    let is_root = |index: usize| match cards[index].parent {
457        None => true,
458        Some(parent) => !position.contains_key(&parent),
459    };
460
461    let mut adopted: Vec<Vec<usize>> = vec![Vec::new(); count];
462    let mut top_level: Vec<usize> = Vec::with_capacity(count);
463    for (index, card) in cards.iter().enumerate() {
464        match card
465            .parent
466            .and_then(|parent| position.get(&parent).copied())
467        {
468            Some(parent) if is_root(parent) => adopted[parent].push(index),
469            _ => top_level.push(index),
470        }
471    }
472
473    let mut ordered: Vec<Card> = Vec::with_capacity(count);
474    for index in top_level {
475        let mut card = cards[index].clone();
476        card.depth = 0;
477        ordered.push(card);
478        for &child in &adopted[index] {
479            let mut child_card = cards[child].clone();
480            child_card.depth = 1;
481            ordered.push(child_card);
482        }
483    }
484    *cards = ordered;
485}
486
487pub(crate) fn sparse_card_order(board_config: &BoardConfiguration, cards: &[Card]) -> Vec<String> {
488    let mut baseline = cards.to_vec();
489    sort_by_default_order(board_config, &mut baseline);
490    baseline
491        .iter()
492        .zip(cards.iter())
493        .filter(|(baseline_card, current_card)| baseline_card.filename != current_card.filename)
494        .map(|(_, current_card)| current_card.filename.clone())
495        .collect()
496}
497
498#[derive(Debug, Clone)]
499pub struct BoardColumn {
500    pub name: String,
501    pub cards: Vec<Card>,
502}
503
504/// What a pointer position over the board resolves to (D3). `row` is the
505/// card's index inside its column — the same index [`BoardState::selected`]
506/// uses — so the caller can compare it directly against the selection.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub enum HitTarget {
509    Card { col: usize, row: usize },
510    Column { col: usize },
511}
512
513/// Compact capacity cells the board's cycle group headers append (WP06 T032).
514///
515/// One already-rendered cell per configured cycle scope, keyed by **both** the cycle
516/// reference a card names and the scope's resolved display label, so either spelling
517/// of one scope finds it. Built by [`crate::app::App`] from the shared calculation —
518/// never here, and never from a second pass over the issues.
519pub type CapacitySummaries = std::collections::BTreeMap<String, String>;
520
521#[derive(Debug, Clone)]
522pub struct BoardState {
523    /// Visible columns only — configured columns not listed in the active
524    pub columns: Vec<BoardColumn>,
525    /// Columns hidden from the active board configuration. Their cards stay
526    /// in board state (keyed by column name) and remain reachable via
527    /// filters/search (DM-01M2MHJB884WCTVCDHPD2T3GE7); they never render.
528    pub hidden: Vec<BoardColumn>,
529    pub mismatch: BoardColumn,
530    /// Whether the computed "Mismatched" pseudo-column is part of the
531    /// navigation/render surface (C-002 / FR-003). Cards in unknown statuses
532    /// are always collected into [`BoardState::mismatch`]; the column is only
533    /// shown when the effective configuration sets `show_mismatch`.
534    pub show_mismatch: bool,
535    pub active_col: usize,
536    pub selected: usize,
537    /// The `(col, card index)` the pointer is hovering (D8), set by `Moved`
538    /// events and cleared on `Up` / when the pointer leaves the board.
539    /// Rendered as a highlight only — it never changes the selection.
540    pub hover: Option<(usize, usize)>,
541    /// The column the pointer is over, set for **any** position inside a column —
542    /// including empty space below the last card, where `hover` is `None`. The
543    /// renderer marks the hovered column from this, so an empty column lights up
544    /// too. Cleared with `hover`.
545    pub hover_col: Option<usize>,
546    pub filter: Option<String>,
547}
548
549/// Shorten `msg` to at most `budget` **display cells**, appending an ellipsis
550/// when anything was dropped.
551///
552/// The marker is appended *within* the budget rather than after it, so the result
553/// is never wider than the area it is rendered into. A combining mark or a
554/// zero-width control contributes no width, so it is kept without consuming
555/// budget rather than being dropped.
556pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
557    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
558
559    const ELLIPSIS: char = '\u{2026}';
560    if budget == 0 {
561        return String::new();
562    }
563    if UnicodeWidthStr::width(msg) <= budget {
564        return msg.to_string();
565    }
566
567    // Reserve one cell for the marker. A double-width character cannot fit in
568    // the last cell, so the loop stops on a width overflow rather than on a
569    // character count.
570    let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
571    let mut out = String::new();
572    let mut used = 0usize;
573    for ch in msg.chars() {
574        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
575        if used + w + marker_width > budget {
576            break;
577        }
578        out.push(ch);
579        used += w;
580    }
581    out.push(ELLIPSIS);
582    out
583}
584
585/// The blocked reason for every issue, read from the relationship service.
586///
587/// One graph over the **whole** set, so the answer does not depend on which
588/// issues the board happens to be showing (see [`BoardState::blocked`]).
589fn blocked_reasons(
590    issues: &[crate::model::Issue],
591    done_column: &str,
592) -> std::collections::BTreeMap<u32, String> {
593    let views: Vec<crate::relationships::IssueView<'_>> =
594        issues.iter().map(crate::model::Issue::issue_view).collect();
595    let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
596    issues
597        .iter()
598        .filter_map(|issue| {
599            crate::commands::blocked_note(&graph.blocked_state(issue.number))
600                .map(|note| (issue.number, note))
601        })
602        .collect()
603}
604
605/// The child-count marker for every issue that has children, keyed by number.
606///
607/// One index over the whole set, so the marker does not depend on which issues the
608/// board is showing. The text comes from the shared `HierarchyFacts`, which is what
609/// `tix parent show`, `tix show` and `tix list --show progress` read, so the four
610/// surfaces cannot report different figures.
611fn child_markers(
612    issues: &[crate::model::Issue],
613    done_column: &str,
614) -> std::collections::BTreeMap<u32, String> {
615    let views: Vec<crate::relationships::IssueView<'_>> =
616        issues.iter().map(crate::model::Issue::issue_view).collect();
617    let index = crate::hierarchy::HierarchyIndex::build(&views);
618    issues
619        .iter()
620        .filter_map(|issue| {
621            let facts =
622                crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
623            facts.has_children().then(|| {
624                (
625                    issue.number,
626                    format!(
627                        "{} {}/{}",
628                        facts.rollup.direct_children,
629                        facts.rollup.completed_children,
630                        facts.rollup.progress.total
631                    ),
632                )
633            })
634        })
635        .collect()
636}
637
638impl BoardState {
639    /// WP05: the resolution must be for the board this call is building, not for
640    /// whichever board happens to be `default`.
641    ///
642    /// `board_config` can be any of the configured boards, and a board selected in
643    /// the TUI is not necessarily the default one. Resolving against the default
644    /// would give a card on the `staging` board the categories configured for
645    /// `default` — a wrong answer rather than a missing one, which is worse.
646    ///
647    /// The board is identified by **pointer identity** against the config's boards,
648    /// because `build` receives a `&BoardConfiguration` and not a name. That is exact
649    /// and needs no name plumbing; a board not found in the config falls back to its
650    /// own columns with the global vocabulary, which is the honest reading of a
651    /// configuration `tix` did not load.
652    /// Public so the startup path can build a board from a single shared
653    /// enumeration (tix #1518) instead of each caller re-deriving it.
654    pub fn resolution_for(
655        store: &Store,
656        board_config: &BoardConfiguration,
657    ) -> crate::workflow::CategoryResolution {
658        let name = store
659            .config
660            .configs()
661            .find(|(_, board)| std::ptr::eq(*board, board_config))
662            .map(|(name, _)| name)
663            .unwrap_or("default");
664        crate::workflow::resolution(&store.config, board_config, name)
665    }
666
667    pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
668        Self::build_with(store, board_config, false, false)
669    }
670
671    /// [`BoardState::build`], optionally including **archived** issues (issue #1492).
672    ///
673    /// `include_archived == false` is the default and the fast path: enumeration reads
674    /// [`Store::list`], which drops a dot-prefixed filename from the directory entry
675    /// without opening the file. `true` reads [`Store::list_all`], which is what the
676    /// TUI's archive toggle asks for *on demand* — the point being that a repository
677    /// with thousands of archived issues is never walked for a board that does not
678    /// show them.
679    pub fn build_with_archived(
680        store: &Store,
681        board_config: &BoardConfiguration,
682        include_archived: bool,
683    ) -> Self {
684        Self::build_with(store, board_config, include_archived, false)
685    }
686
687    /// [`BoardState::build_with_archived`], optionally rendering sub-issues under
688    /// their parents (tix #1399).
689    ///
690    /// `show_children == true` reorders every column so a card's direct sub-issues
691    /// follow it, marked `depth = 1` (see [`nest_children`]). The reorder happens at
692    /// **build** time, not at draw time, because the selection index and the pointer
693    /// hit-test both address cards by their index inside a column — a tree only the
694    /// renderer knew about would make a click or a `j` land on the wrong card.
695    pub fn build_with(
696        store: &Store,
697        board_config: &BoardConfiguration,
698        include_archived: bool,
699        show_children: bool,
700    ) -> Self {
701        let issues = if include_archived {
702            store.list_all()
703        } else {
704            store.list()
705        }
706        .unwrap_or_default();
707        let resolution = Self::resolution_for(store, board_config);
708        Self::build_from_issues(
709            &issues,
710            board_config,
711            &store.config,
712            &resolution,
713            show_children,
714        )
715    }
716
717    /// Build a board from an in-memory slice of issues (issue #10 follow-up).
718    ///
719    /// The single build path: [`BoardState::build_with`] reads the store and
720    /// delegates here, and the TUI's incremental refresh rebuilds from its
721    /// in-memory corpus without re-reading the tree. `config` supplies the
722    /// completion column; `resolution` is the category resolution of the board
723    /// being built (see [`BoardState::resolution_for`]).
724    pub fn build_from_issues(
725        issues: &[Issue],
726        board_config: &BoardConfiguration,
727        config: &crate::config::Config,
728        resolution: &crate::workflow::CategoryResolution,
729        show_children: bool,
730    ) -> Self {
731        // WP09: the **service's** completion column, not `done_column`. The
732        // `blocked_reasons` and `child_markers` helpers take a column *name* — that is
733        // their existing signature and it is not what makes them wrong. What was
734        // wrong is which name they were handed: on a board whose `category_map` puts
735        // `completed` on `shipped` while `done_column` is `done`, the TUI's blocked
736        // marker was computed against `done_column` while `tix list`'s was computed
737        // against the service, so the same issue read as blocked in the TUI and clear
738        // in the CLI. The name is now resolved once, by the service.
739        let done_column = crate::workflow::completion_column_or_default(board_config, config);
740        let done_column = done_column.as_str();
741        let blocked = blocked_reasons(issues, done_column);
742        let children = child_markers(issues, done_column);
743        let show_mismatch = board_config.show_mismatch;
744        let hidden_names: Vec<&str> = board_config
745            .hidden_columns
746            .iter()
747            .map(String::as_str)
748            .collect();
749        let mut column_map: Vec<Vec<Card>> =
750            board_config.columns.iter().map(|_| Vec::new()).collect();
751        let mut mismatch_cards = Vec::new();
752
753        let health_badges = health_badges(issues);
754        // The same partition the filtered build uses, from the same shared index:
755        // an unfiltered board and a project-filtered one must label a card's
756        // project identically (WP06 T028).
757        let projects = ProjectPartition::build(config, issues);
758        // The epic partition, from the same shared membership index, so an
759        // unfiltered board and an epic-filtered one label a card's epic identically
760        // (WP06 T030).
761        let epics = EpicPartition::build(config, issues);
762        for issue in issues {
763            let card = Card {
764                number: issue.number,
765                title: issue.title.clone(),
766                priority: issue.priority.clone(),
767                labels: issue.labels.clone(),
768                assignee: issue.assignee.clone(),
769                filename: issue.filename.clone(),
770                commit: issue.commit.clone(),
771                created: issue.created.clone(),
772                due_date: issue.due_date.clone(),
773                status: issue.status.display().to_string(),
774                category: crate::workflow::category_of(issue, resolution)
775                    .map(|c| c.label().to_string()),
776                category_color: crate::workflow::category_of(issue, resolution)
777                    .and_then(|c| c.color_hint()),
778                done: crate::workflow::is_completed(issue, resolution),
779                cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
780                milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
781                blocked: blocked.get(&issue.number).cloned(),
782                children: children.get(&issue.number).cloned(),
783                parent: issue.parent,
784                depth: 0,
785                archived: issue.archived,
786                related: issue.related_issues.len(),
787                health: health_badges.get(&issue.number).cloned(),
788                projects: projects.memberships(issue.number),
789                epics: epics.memberships(issue.number),
790            };
791            if let Some(idx) = board_config
792                .columns
793                .iter()
794                .position(|c| c == issue.status.display())
795            {
796                column_map[idx].push(card);
797            } else {
798                mismatch_cards.push(card);
799            }
800        }
801
802        for cards in column_map.iter_mut() {
803            sort_cards(board_config, cards);
804        }
805        sort_cards(board_config, &mut mismatch_cards);
806
807        let mut columns: Vec<BoardColumn> = Vec::new();
808        let mut hidden: Vec<BoardColumn> = Vec::new();
809        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
810            let col = BoardColumn {
811                name: name.clone(),
812                cards,
813            };
814            if hidden_names.contains(&name.as_str()) {
815                hidden.push(col);
816            } else {
817                columns.push(col);
818            }
819        }
820
821        let mismatch = BoardColumn {
822            name: "Mismatched".to_string(),
823            cards: mismatch_cards,
824        };
825
826        let mut state = BoardState {
827            columns,
828            hidden,
829            mismatch,
830            show_mismatch,
831            active_col: 0,
832            selected: 0,
833            filter: None,
834            hover: None,
835            hover_col: None,
836        };
837        state.apply_child_order(show_children);
838        state
839    }
840
841    /// Nest each column's sub-issues beneath their parents when `show_children` is
842    /// on (tix #1399). A no-op when it is off, so the default build is untouched.
843    ///
844    /// Applied to the visible columns and the "Mismatched" pseudo-column — every
845    /// surface `render` and `hit_test` actually address. Hidden columns have no
846    /// rendered presence, so they are left in their flat order.
847    fn apply_child_order(&mut self, show_children: bool) {
848        if !show_children {
849            return;
850        }
851        for column in &mut self.columns {
852            nest_children(&mut column.cards);
853        }
854        nest_children(&mut self.mismatch.cards);
855    }
856
857    /// Build a board from the search corpus, keeping only cards whose full
858    /// serialized text matches **both** the fuzzy `query` layer and the
859    /// structured `filter` layer (FR-012/FR-013). Both predicates run in a
860    /// single pass over the corpus via [`crate::search::matches`] (NFR-001).
861    ///
862    /// When a filter is active (non-empty query or structured criteria set),
863    /// hidden columns' matching cards are **revealed** in the rendered surface
864    /// (DM-01M2MHJB884WCTVCDHPD2T3GE7: hidden-column cards reachable via
865    /// filters/search). When no filter is active, matching cards from hidden
866    /// columns are stashed in [`BoardState::hidden`] (no visual presence).
867    pub fn build_filtered(
868        board_config: &BoardConfiguration,
869        config: &crate::config::Config,
870        corpus: &Corpus,
871        query: &str,
872        filter: &StructuredFilter,
873        resolution: &crate::workflow::CategoryResolution,
874    ) -> Self {
875        Self::build_filtered_with(
876            board_config,
877            config,
878            corpus,
879            query,
880            filter,
881            resolution,
882            false,
883        )
884    }
885
886    /// [`BoardState::build_filtered`], optionally rendering sub-issues under their
887    /// parents (tix #1399).
888    ///
889    /// The `u` toggle is global, so it must keep working while a search or
890    /// structured filter is active: `build_filtered` delegates here with
891    /// `show_children == false` and the TUI passes its flag through.
892    pub fn build_filtered_with(
893        board_config: &BoardConfiguration,
894        config: &crate::config::Config,
895        corpus: &Corpus,
896        query: &str,
897        filter: &StructuredFilter,
898        resolution: &crate::workflow::CategoryResolution,
899        show_children: bool,
900    ) -> Self {
901        let show_mismatch = board_config.show_mismatch;
902        // WP09, as in `build`: the service's completion column, so the filtered and
903        // unfiltered boards agree on which column is blocked and which is complete.
904        let done_column = crate::workflow::completion_column_or_default(board_config, config);
905        let done_column = done_column.as_str();
906        // Built from the corpus, not from the filtered slice below: a blocker the
907        // query happens to exclude must still count as a blocker.
908        let blocked = blocked_reasons(&corpus.issues, done_column);
909        // Same reason, same discipline: a child the query excludes is still a
910        // child, so a parent's marker must not shrink because the board is
911        // filtered.
912        let children = child_markers(&corpus.issues, done_column);
913        let filtering = !query.is_empty() || filter.has_active_criteria();
914        // A `group:<key>` with no explicit `sort:<key>` sorts by that key
915        // (WP06 T028). A partition is only contiguous — and only in the key's own
916        // order — when the cards are ordered by the same key, and the contract for
917        // the project partition names the order explicitly ("declaration order,
918        // no-project bucket last"). Without this, `group:project` alone would emit
919        // one section per created-date run and the order would depend on the user
920        // remembering to type `sort:project` as well.
921        //
922        // An explicit `sort:` still wins, so `group:cycle sort:due` keeps meaning
923        // what it meant.
924        let sort_key = crate::search::control_token(query, "sort")
925            .or_else(|| crate::search::control_token(query, "group"));
926        // The project partition, resolved once from the shared membership index
927        // (NFR-001): the sorter and the renderer both read this, so there is no
928        // second membership read and no scan per criterion.
929        let projects = ProjectPartition::build(config, &corpus.issues);
930        // The epic partition, read once from the same shared index as `projects`
931        // (WP06 T030): the sorter and the renderer both read this, so there is no
932        // second membership read.
933        let epics = EpicPartition::build(config, &corpus.issues);
934        // A bare-number query (all-ASCII-digit after trim) is an exact issue
935        // number match, not a fuzzy digit search: only the issue whose
936        // `number` equals it survives, still ANDed with the structured
937        // filter (board-filtering FR-02 / FR-03). Any other query keeps the
938        // fuzzy-text path.
939        let number = crate::search::number_query(query);
940        let hidden_names: Vec<&str> = board_config
941            .hidden_columns
942            .iter()
943            .map(String::as_str)
944            .collect();
945        let mut column_map: Vec<Vec<Card>> =
946            board_config.columns.iter().map(|_| Vec::new()).collect();
947        let mut mismatch_cards = Vec::new();
948        let mut matched_issues: Vec<&crate::model::Issue> = Vec::new();
949
950        for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
951            // Bare-number query: exact issue-number match replaces the fuzzy
952            // text layer (board-filtering FR-02); the structured filter still
953            // runs (FR-03). Otherwise the composite fuzzy+structured
954            // predicate applies unchanged.
955            // `matches_issue`, not `matches` (WP08 T035): the TUI filter modal can
956            // set a `category` criterion, and `matches` cannot judge it — it would
957            // silently drop the criterion and show the **unfiltered** board while
958            // the modal showed `category: completed` as active. The board already
959            // holds the `resolution` (it is a parameter, because WP05 needed it for
960            // the card labels), so the criterion costs one hash lookup per issue.
961            let keep = if let Some(n) = number {
962                issue.number == n && filter.evaluate_issue(issue, text, resolution)
963            } else {
964                crate::search::matches_issue(query, filter, issue, text, resolution)
965            };
966            if !keep {
967                continue;
968            }
969            matched_issues.push(issue);
970        }
971
972        Self::sort_filtered_issues(&mut matched_issues, sort_key.as_deref(), &projects, &epics);
973
974        let health_badges = health_badges(&corpus.issues);
975        for issue in matched_issues {
976            let card = Card {
977                number: issue.number,
978                title: issue.title.clone(),
979                priority: issue.priority.clone(),
980                labels: issue.labels.clone(),
981                assignee: issue.assignee.clone(),
982                filename: issue.filename.clone(),
983                commit: issue.commit.clone(),
984                created: issue.created.clone(),
985                due_date: issue.due_date.clone(),
986                status: issue.status.display().to_string(),
987                category: crate::workflow::category_of(issue, resolution)
988                    .map(|c| c.label().to_string()),
989                category_color: crate::workflow::category_of(issue, resolution)
990                    .and_then(|c| c.color_hint()),
991                done: crate::workflow::is_completed(issue, resolution),
992                cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
993                milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
994                blocked: blocked.get(&issue.number).cloned(),
995                children: children.get(&issue.number).cloned(),
996                parent: issue.parent,
997                depth: 0,
998                archived: issue.archived,
999                related: issue.related_issues.len(),
1000                health: health_badges.get(&issue.number).cloned(),
1001                projects: projects.memberships(issue.number),
1002                epics: epics.memberships(issue.number),
1003            };
1004            if let Some(idx) = board_config
1005                .columns
1006                .iter()
1007                .position(|c| c == issue.status.display())
1008            {
1009                column_map[idx].push(card);
1010            } else {
1011                mismatch_cards.push(card);
1012            }
1013        }
1014
1015        if sort_key.is_none() {
1016            for cards in column_map.iter_mut() {
1017                sort_cards(board_config, cards);
1018            }
1019            sort_cards(board_config, &mut mismatch_cards);
1020        }
1021
1022        let mut columns: Vec<BoardColumn> = Vec::new();
1023        let mut hidden: Vec<BoardColumn> = Vec::new();
1024        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
1025            let col = BoardColumn {
1026                name: name.clone(),
1027                cards,
1028            };
1029            if hidden_names.contains(&name.as_str()) {
1030                if filtering {
1031                    // Reveal hidden column when a filter is active so
1032                    // matching cards appear in the rendered surface
1033                    // (DM-01M2MHJB884WCTVCDHPD2T3GE7).
1034                    columns.push(col);
1035                } else {
1036                    hidden.push(col);
1037                }
1038            } else {
1039                columns.push(col);
1040            }
1041        }
1042
1043        let mut state = BoardState {
1044            columns,
1045            hidden,
1046            mismatch: BoardColumn {
1047                name: "Mismatched".to_string(),
1048                cards: mismatch_cards,
1049            },
1050            show_mismatch,
1051            active_col: 0,
1052            selected: 0,
1053            filter: Some(query.to_string()),
1054            hover: None,
1055            hover_col: None,
1056        };
1057        state.apply_child_order(show_children);
1058        state
1059    }
1060
1061    fn sort_filtered_issues(
1062        issues: &mut [&crate::model::Issue],
1063        sort: Option<&str>,
1064        projects: &ProjectPartition,
1065        epics: &EpicPartition,
1066    ) {
1067        match sort.unwrap_or("created") {
1068            // The project partition order (WP06 T028): registry declaration order
1069            // for a single membership, after every declared project for an issue in
1070            // several, and last of all for an issue in none — which is exactly the
1071            // order the buckets are rendered in, so the sections come out
1072            // contiguous and the `(no project)` bucket is last.
1073            "project" => issues.sort_by(|a, b| {
1074                projects
1075                    .rank(a.number)
1076                    .cmp(&projects.rank(b.number))
1077                    .then(a.number.cmp(&b.number))
1078            }),
1079            // The epic partition order (WP06 T030): the exact analogue of `project`
1080            // above, so `group:epic` alone emits one contiguous section per epic in
1081            // declaration order, then `(multiple epics)`, then `(no epic)` last.
1082            "epic" => issues.sort_by(|a, b| {
1083                epics
1084                    .rank(a.number)
1085                    .cmp(&epics.rank(b.number))
1086                    .then(a.number.cmp(&b.number))
1087            }),
1088            "due" => {
1089                issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number)))
1090            }
1091            "start" => issues.sort_by(|a, b| {
1092                a.start_date
1093                    .cmp(&b.start_date)
1094                    .then(a.number.cmp(&b.number))
1095            }),
1096            "reviewer" => {
1097                issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
1098            }
1099            "cycle" => issues.sort_by(|a, b| {
1100                a.cycles
1101                    .as_ref()
1102                    .and_then(|v| v.first())
1103                    .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
1104                    .then(a.number.cmp(&b.number))
1105            }),
1106            "milestone" => issues.sort_by(|a, b| {
1107                a.milestones
1108                    .as_ref()
1109                    .and_then(|v| v.first())
1110                    .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
1111                    .then(a.number.cmp(&b.number))
1112            }),
1113            "status" => issues.sort_by(|a, b| {
1114                a.status
1115                    .display()
1116                    .cmp(b.status.display())
1117                    .then(a.number.cmp(&b.number))
1118            }),
1119            "priority" => issues.sort_by(|a, b| {
1120                a.priority
1121                    .to_string()
1122                    .cmp(&b.priority.to_string())
1123                    .then(a.number.cmp(&b.number))
1124            }),
1125            _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
1126        }
1127    }
1128
1129    /// True when no card anywhere is visible (used for zero-match feedback).
1130    pub fn is_empty(&self) -> bool {
1131        self.columns.iter().all(|c| c.cards.is_empty())
1132            && (!self.show_mismatch || self.mismatch.cards.is_empty())
1133    }
1134
1135    pub fn total_cols(&self) -> usize {
1136        if self.show_mismatch {
1137            self.columns.len() + 1
1138        } else {
1139            self.columns.len()
1140        }
1141    }
1142
1143    /// Map a terminal pointer `(x, y)` — given against the same `area` passed
1144    /// to [`render`] — to the card or column underneath. `status` mirrors
1145    /// whether [`render`] is drawing a status message, so the status row (not
1146    /// part of any column) never resolves to a target. Returns `None` for
1147    /// positions outside the board area (`y` above the columns or beyond the
1148    /// board) or when nothing is drawn. Uses [`compute_column_rects`] and
1149    /// [`super::widgets::card_line_count`] — the geometry `render` draws with —
1150    /// so the hit-tested layout matches the rendered one by construction (D3).
1151    pub fn hit_test(
1152        &self,
1153        area: Rect,
1154        display: BoardDisplay,
1155        status: bool,
1156        x: u16,
1157        y: u16,
1158    ) -> Option<HitTarget> {
1159        if area.height < 2 || area.width < 4 || self.total_cols() == 0 {
1160            return None;
1161        }
1162        let board_height = area.height.saturating_sub(u16::from(status));
1163        if y >= area.y + board_height {
1164            return None;
1165        }
1166        let board_area = Rect {
1167            height: board_height,
1168            ..area
1169        };
1170
1171        let rects = compute_column_rects(board_area, self.total_cols());
1172        let (col, col_rect) = rects
1173            .iter()
1174            .enumerate()
1175            .find(|(_, r)| r.contains(Position::new(x, y)))?;
1176
1177        let cards: &[Card] = if col < self.columns.len() {
1178            &self.columns[col].cards
1179        } else {
1180            &self.mismatch.cards
1181        };
1182
1183        let group_mode = self
1184            .filter
1185            .as_deref()
1186            .and_then(|q| crate::search::control_token(q, "group"));
1187
1188        // Items stack top-down exactly as `render` pushes them: group headers
1189        // occupy one row each, cards occupy `card_line_count` rows.
1190        let mut items: Vec<Option<usize>> = Vec::new();
1191        if let Some(mode) = group_mode.as_deref() {
1192            if let Some(sections) = Self::group_sections(cards, Some(mode)) {
1193                for section in sections {
1194                    items.push(None);
1195                    for (j, card) in cards
1196                        .iter()
1197                        .enumerate()
1198                        .take(section.end)
1199                        .skip(section.start)
1200                    {
1201                        let count = widgets::card_line_count(
1202                            card,
1203                            col_rect.width as usize,
1204                            display.wrap_titles,
1205                            display.title_lines,
1206                        );
1207                        for _ in 0..count {
1208                            items.push(Some(j));
1209                        }
1210                    }
1211                }
1212            }
1213        } else {
1214            for (j, card) in cards.iter().enumerate() {
1215                let count = widgets::card_line_count(
1216                    card,
1217                    col_rect.width as usize,
1218                    display.wrap_titles,
1219                    display.title_lines,
1220                );
1221                for _ in 0..count {
1222                    items.push(Some(j));
1223                }
1224            }
1225        }
1226
1227        let has_footer = BoardState::group_footer(cards, group_mode.as_deref()).is_some();
1228        let list_height = col_rect.height.saturating_sub(u16::from(has_footer));
1229        // Content rows live between the block's top border and bottom border.
1230        let content_top = col_rect.y + 1;
1231        let content_end = col_rect.y + list_height.saturating_sub(1);
1232        if y >= content_top && y < content_end {
1233            let offset = usize::from(y - content_top);
1234            if let Some(Some(row)) = items.get(offset) {
1235                return Some(HitTarget::Card { col, row: *row });
1236            }
1237        }
1238        Some(HitTarget::Column { col })
1239    }
1240
1241    /// Columns a card can be moved into: the configured columns plus (when
1242    /// shown) the "Mismatched" sentinel, which is never a move target. The
1243    /// `</>` move keys navigate *among movable columns* only.
1244    pub fn real_cols(&self) -> usize {
1245        self.columns.len()
1246    }
1247
1248    pub fn column_name(&self, idx: usize) -> &str {
1249        if idx < self.columns.len() {
1250            &self.columns[idx].name
1251        } else if idx == self.columns.len() && self.show_mismatch {
1252            &self.mismatch.name
1253        } else {
1254            "Unknown"
1255        }
1256    }
1257
1258    /// The name under the active cursor, resolving **hidden** columns too
1259    /// (visible region first, then hidden, then Unknown). Lets `toggle` keep a
1260    /// logical cursor on a just-hidden column so a second press re-shows it
1261    /// (T017: "a second press on the same column re-shows it").
1262    pub fn active_column_name(&self) -> &str {
1263        let visible = self.columns.len();
1264        if self.active_col < visible {
1265            &self.columns[self.active_col].name
1266        } else {
1267            let hidden_idx = self.active_col - visible;
1268            if hidden_idx < self.hidden.len() {
1269                &self.hidden[hidden_idx].name
1270            } else {
1271                "Unknown"
1272            }
1273        }
1274    }
1275
1276    pub fn column_cards(&self, idx: usize) -> &[Card] {
1277        if idx < self.columns.len() {
1278            &self.columns[idx].cards
1279        } else if idx == self.columns.len() && self.show_mismatch {
1280            &self.mismatch.cards
1281        } else {
1282            &[]
1283        }
1284    }
1285
1286    pub fn get_selected_card(&self) -> Option<&Card> {
1287        self.column_cards(self.active_col).get(self.selected)
1288    }
1289
1290    pub fn move_left(&mut self) {
1291        let total = self.real_cols();
1292        self.active_col = if total == 0 {
1293            0
1294        } else if self.active_col >= total {
1295            total - 1
1296        } else {
1297            (self.active_col + total - 1) % total
1298        };
1299        self.clamp_selection();
1300    }
1301
1302    pub fn move_right(&mut self) {
1303        let total = self.real_cols();
1304        self.active_col = if total == 0 || self.active_col >= total {
1305            0
1306        } else {
1307            (self.active_col + 1) % total
1308        };
1309        self.clamp_selection();
1310    }
1311
1312    pub fn move_up(&mut self) {
1313        if self.selected > 0 {
1314            self.selected -= 1;
1315        }
1316    }
1317
1318    pub fn move_down(&mut self) {
1319        let cards = self.column_cards(self.active_col);
1320        if self.selected + 1 < cards.len() {
1321            self.selected += 1;
1322        }
1323    }
1324
1325    /// Move the selection by `cards` (negative moves up), clamped to the column
1326    /// (tix #1442 follow-up).
1327    ///
1328    /// The page step: `move_up`/`move_down` are the one-card steps, this is the same
1329    /// move by a screenful. It clamps for the reason the detail pane's scroll does — a
1330    /// jump past the end should land on the last card, not walk off it.
1331    pub fn move_selection_by(&mut self, cards: i32) {
1332        let len = self.column_cards(self.active_col).len();
1333        if len == 0 {
1334            self.selected = 0;
1335            return;
1336        }
1337        let next = i32::try_from(self.selected)
1338            .unwrap_or(i32::MAX)
1339            .saturating_add(cards);
1340        self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
1341    }
1342
1343    pub fn clamp_selection(&mut self) {
1344        let cards = self.column_cards(self.active_col);
1345        if cards.is_empty() {
1346            self.selected = 0;
1347        } else if self.selected >= cards.len() {
1348            self.selected = cards.len() - 1;
1349        }
1350    }
1351
1352    pub fn select_column(&mut self, col: usize) {
1353        let total = self.real_cols();
1354        if total > 0 {
1355            self.active_col = col % total;
1356        }
1357        self.clamp_selection();
1358    }
1359
1360    fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
1361        let sections = Self::group_sections(cards, mode)?;
1362        let text = sections
1363            .into_iter()
1364            .map(|section| {
1365                let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
1366                format!("{} {}/{} {pct}%", section.name, section.done, section.total)
1367            })
1368            .collect::<Vec<_>>()
1369            .join(" | ");
1370        // The grouped board keeps **direct issue navigation** (FR-004): the card
1371        // cursor and `Enter` open the issue's own detail and edit, with the group
1372        // label only as the section above it. The route is stated beside the
1373        // partition rather than implied by the card text.
1374        Some(format!("{text}  ·  {GROUP_OPEN_AFFORDANCE}"))
1375    }
1376
1377    fn card_group(card: &Card, key: &str) -> String {
1378        match key {
1379            "cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
1380            "milestone" => card
1381                .milestone
1382                .clone()
1383                .unwrap_or_else(|| "(none)".to_string()),
1384            // WP06 T028: the project bucket is read off the card's resolved
1385            // memberships, so a `group:project` board shows one section per project
1386            // in declaration order, then `(multiple projects)`, then the labelled
1387            // `(no project)` bucket last.
1388            "project" => project_bucket_label(&card.projects),
1389            // WP06 T030: the epic bucket, read off the card's resolved references
1390            // — one section per epic in declaration order, then `(multiple epics)`,
1391            // then the labelled `(no epic)` bucket last.
1392            "epic" => epic_bucket_label(&card.epics),
1393            _ => "(none)".to_string(),
1394        }
1395    }
1396
1397    fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
1398        let key =
1399            mode.filter(|m| *m == "cycle" || *m == "milestone" || *m == "project" || *m == "epic")?;
1400        if cards.is_empty() {
1401            return None;
1402        }
1403        let mut sections = Vec::new();
1404        let mut current = String::new();
1405        let mut start = 0usize;
1406        let mut done = 0usize;
1407        let mut total = 0usize;
1408        for (idx, card) in cards.iter().enumerate() {
1409            let group = Self::card_group(card, key);
1410            if idx == 0 {
1411                current = group;
1412            } else if group != current {
1413                sections.push(GroupSection {
1414                    name: std::mem::take(&mut current),
1415                    start,
1416                    end: idx,
1417                    done,
1418                    total,
1419                });
1420                current = group;
1421                start = idx;
1422                done = 0;
1423                total = 0;
1424            }
1425            total += 1;
1426            if card.done {
1427                done += 1;
1428            }
1429        }
1430        sections.push(GroupSection {
1431            name: current,
1432            start,
1433            end: cards.len(),
1434            done,
1435            total,
1436        });
1437        Some(sections)
1438    }
1439}
1440
1441/// Render options carried from the active board configuration into the board
1442/// draw. Kept tiny and copyable so tests and call sites stay light.
1443#[derive(Debug, Clone, Copy)]
1444pub struct BoardDisplay {
1445    pub wrap_titles: bool,
1446    pub title_lines: usize,
1447}
1448
1449impl Default for BoardDisplay {
1450    fn default() -> Self {
1451        BoardDisplay {
1452            wrap_titles: true,
1453            title_lines: 3,
1454        }
1455    }
1456}
1457
1458impl From<&crate::config::BoardConfiguration> for BoardDisplay {
1459    fn from(config: &crate::config::BoardConfiguration) -> Self {
1460        BoardDisplay {
1461            wrap_titles: config.wrap_titles,
1462            title_lines: config.title_lines,
1463        }
1464    }
1465}
1466
1467/// Equal-width column slices for a board of `total_cols` columns (the same
1468/// `Constraint::Ratio` layout `render` draws), shared so hit-testing can map a
1469/// pointer position back to a column without re-deriving geometry (D3).
1470pub fn compute_column_rects(area: Rect, total_cols: usize) -> Vec<Rect> {
1471    if total_cols == 0 {
1472        return Vec::new();
1473    }
1474    Layout::default()
1475        .direction(Direction::Horizontal)
1476        .constraints(vec![Constraint::Ratio(1, total_cols as u32); total_cols])
1477        .split(area)
1478        .to_vec()
1479}
1480
1481// `wip` is the one derived fact the board needs beyond its own state (WP05 T025):
1482// the columns' usage against their limits, from the shared snapshot. It is passed
1483// in rather than built here so every renderer in a frame reports the same figures.
1484#[allow(clippy::too_many_arguments)]
1485pub fn render(
1486    board: &BoardState,
1487    f: &mut Frame,
1488    area: Rect,
1489    status_msg: &Option<String>,
1490    drag_preview_col: Option<usize>,
1491    display: BoardDisplay,
1492    theme: &crate::ui::color::Theme,
1493    wip: Option<&crate::wip::WipSnapshot>,
1494    capacity: Option<&CapacitySummaries>,
1495) -> usize {
1496    // How many cards one screen of the **active** column holds (tix #1442 follow-up).
1497    //
1498    // Returned rather than re-derived by the caller: this is the only place that knows
1499    // the column's inner height *and* each card's rendered height, and a page key needs
1500    // both. `0` when nothing was drawn, so the caller cannot page a column that is not
1501    // on screen.
1502    let mut page_cards = 0usize;
1503
1504    if area.height < 2 || area.width < 4 {
1505        return 0;
1506    }
1507
1508    let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
1509    let board_height = area.height.saturating_sub(status_height);
1510    let board_area = Rect {
1511        height: board_height,
1512        ..area
1513    };
1514    let status_area = Rect {
1515        y: area.y + board_height,
1516        height: status_height,
1517        ..area
1518    };
1519
1520    let total = board.total_cols();
1521    if total == 0 {
1522        return 0;
1523    }
1524
1525    let chunks = compute_column_rects(board_area, total);
1526
1527    for (i, chunk) in chunks.iter().enumerate() {
1528        let col = if i < board.columns.len() {
1529            &board.columns[i]
1530        } else {
1531            &board.mismatch
1532        };
1533        let group_mode = board
1534            .filter
1535            .as_deref()
1536            .and_then(|q| crate::search::control_token(q, "group"));
1537
1538        let is_active = i == board.active_col;
1539        let is_mismatch_col = i == board.columns.len();
1540        let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
1541        // The column the pointer sits in — set for empty columns too, so hovering
1542        // anywhere in a column lights the whole column up (not just a card).
1543        let hovered_col = board.hover_col;
1544
1545        let mut selected_row: Option<usize> = None;
1546        let items: Vec<ListItem> = if let Some(mode) = group_mode.as_deref() {
1547            if let Some(sections) = BoardState::group_sections(&col.cards, Some(mode)) {
1548                let mut items = Vec::new();
1549                for section in sections {
1550                    let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
1551                    // WP06 T032: a `[cycle]`-grouped board appends the scope's compact
1552                    // capacity cell to its own section header — the same line, one more
1553                    // fact, so grouping, filtering, card order and hit-testing are all
1554                    // untouched. A group whose name names no configured scope (a card in
1555                    // no cycle, or a cycle with no ceiling) keeps exactly the header it
1556                    // had, because a fabricated cell would be a TUI-only number.
1557                    let capacity_cell = (mode == "cycle")
1558                        .then(|| capacity.and_then(|cells| cells.get(&section.name)))
1559                        .flatten();
1560                    let header = match capacity_cell {
1561                        Some(cell) => format!(
1562                            "-- {mode}: {} ({}/{} done, {pct}%) · {cell} --",
1563                            section.name, section.done, section.total
1564                        ),
1565                        None => format!(
1566                            "-- {mode}: {} ({}/{} done, {pct}%) --",
1567                            section.name, section.done, section.total
1568                        ),
1569                    };
1570                    items.push(ListItem::new(Line::from(Span::styled(
1571                        header,
1572                        Style::default()
1573                            .fg(Color::Yellow)
1574                            .add_modifier(Modifier::BOLD),
1575                    ))));
1576                    for j in section.start..section.end {
1577                        let card = &col.cards[j];
1578                        let lines = widgets::card_lines_themed(
1579                            card,
1580                            chunk.width as usize,
1581                            display.wrap_titles,
1582                            display.title_lines,
1583                            theme,
1584                        );
1585                        let style = if is_active && j == board.selected {
1586                            selected_row = Some(items.len());
1587                            Style::default()
1588                                .bg(Color::DarkGray)
1589                                .add_modifier(Modifier::BOLD)
1590                        } else if board.hover == Some((i, j)) {
1591                            // Hover highlight only — selection is untouched (D8).
1592                            if drag_preview_col == Some(i) {
1593                                Style::default()
1594                                    .fg(Color::Yellow)
1595                                    .add_modifier(Modifier::BOLD)
1596                            } else {
1597                                Style::default().fg(Color::Cyan)
1598                            }
1599                        } else if is_mismatch_highlight {
1600                            Style::default().fg(Color::Red)
1601                        } else {
1602                            Style::default()
1603                        };
1604                        items.push(ListItem::new(lines).style(style));
1605                    }
1606                }
1607                items
1608            } else {
1609                Vec::new()
1610            }
1611        } else {
1612            col.cards
1613                .iter()
1614                .enumerate()
1615                .map(|(j, card)| {
1616                    let lines = widgets::card_lines_themed(
1617                        card,
1618                        chunk.width as usize,
1619                        display.wrap_titles,
1620                        display.title_lines,
1621                        theme,
1622                    );
1623                    let style = if is_active && j == board.selected {
1624                        selected_row = Some(j);
1625                        Style::default()
1626                            .bg(Color::DarkGray)
1627                            .add_modifier(Modifier::BOLD)
1628                    } else if board.hover == Some((i, j)) {
1629                        if drag_preview_col == Some(i) {
1630                            Style::default()
1631                                .fg(Color::Yellow)
1632                                .add_modifier(Modifier::BOLD)
1633                        } else {
1634                            Style::default().fg(Color::Cyan)
1635                        }
1636                    } else if is_mismatch_highlight {
1637                        Style::default().fg(Color::Red)
1638                    } else {
1639                        Style::default()
1640                    };
1641                    ListItem::new(lines).style(style)
1642                })
1643                .collect()
1644        };
1645
1646        // The column's own count, replaced by the usage cell when the board
1647        // configures a limit for it: `in progress (4/3 8/10d!)` says what the header
1648        // said and what the limit is, and a board with no limits keeps the plain
1649        // `in progress (4)` it always had (WP05 T025).
1650        let title = match wip
1651            .and_then(|snapshot| snapshot.column(&col.name))
1652            .filter(|usage| usage.limit.is_some())
1653        {
1654            Some(usage) => format!("{} ({})", col.name, usage.cell()),
1655            None => format!("{} ({})", col.name, col.cards.len()),
1656        };
1657        let border_style = if drag_preview_col == Some(i) {
1658            Style::default().fg(Color::Yellow)
1659        } else if is_active {
1660            Style::default().fg(Color::Cyan)
1661        } else if is_mismatch_highlight {
1662            Style::default().fg(Color::Red)
1663        } else if hovered_col == Some(i) {
1664            // The hovered column gets its own accent so the pointer's column is
1665            // visible even when the card itself is small.
1666            Style::default()
1667                .fg(Color::Blue)
1668                .add_modifier(Modifier::BOLD)
1669        } else {
1670            Style::default()
1671        };
1672
1673        let block = Block::default()
1674            .borders(Borders::ALL)
1675            .title(title)
1676            .border_style(border_style);
1677
1678        let col_chunks = if BoardState::group_footer(&col.cards, group_mode.as_deref()).is_some() {
1679            Layout::default()
1680                .direction(Direction::Vertical)
1681                .constraints([Constraint::Min(1), Constraint::Length(1)])
1682                .split(*chunk)
1683        } else {
1684            Layout::default()
1685                .direction(Direction::Vertical)
1686                .constraints([Constraint::Min(1), Constraint::Length(0)])
1687                .split(*chunk)
1688        };
1689
1690        let list = List::new(items).block(block);
1691
1692        if is_active {
1693            // A page is what the reader can see: the column's inner height against the
1694            // heights the cards actually render at.
1695            page_cards = cards_per_screen(
1696                &col.cards,
1697                chunk.width as usize,
1698                col_chunks[0].height.saturating_sub(2),
1699                display,
1700            );
1701            let mut state = ListState::default();
1702            if let Some(row) = selected_row {
1703                state.select(Some(row));
1704            }
1705            f.render_stateful_widget(list, col_chunks[0], &mut state);
1706        } else {
1707            f.render_widget(list, col_chunks[0]);
1708        }
1709        if let Some(footer) = BoardState::group_footer(&col.cards, group_mode.as_deref()) {
1710            let para = Paragraph::new(Line::from(Span::styled(
1711                footer,
1712                Style::default().fg(Color::DarkGray),
1713            )));
1714            f.render_widget(para, col_chunks[1]);
1715        }
1716    }
1717
1718    if let Some(msg) = status_msg {
1719        // The status area is one row and does not wrap, so anything longer is cut
1720        // by the widget with no indication at all. Mark the cut instead: a
1721        // silently shortened warning reads as a complete one, and the
1722        // retired-`links:` disclosure runs to ~100 characters before the filename,
1723        // which is routinely longer still.
1724        //
1725        // `Paragraph` is handed the truncated string rather than the full one, so
1726        // the ellipsis cannot itself be pushed off the right edge.
1727        //
1728        // Truncation is measured in **display cells**, not scalar values. A CJK
1729        // character occupies two cells, so `chars().take(budget)` yields a string
1730        // up to twice as wide as the area; the widget then hard-cuts it at the
1731        // right edge and discards the marker appended at the end of the
1732        // overflowed string — silently reinstating the exact failure this
1733        // truncation exists to prevent. `unicode-width` is already a dependency,
1734        // so this costs no new one.
1735        let budget = (status_area.width as usize).saturating_sub(1);
1736        let text = truncate_to_cells(msg, budget);
1737        let para =
1738            Paragraph::new(Line::from(Span::raw(text))).style(Style::default().fg(Color::Yellow));
1739        f.render_widget(para, status_area);
1740    }
1741
1742    page_cards
1743}
1744
1745/// How many of `cards` fit into `rows` of a column `width` cells wide.
1746///
1747/// Measured with the same [`widgets::card_lines`] the renderer draws, so the answer is
1748/// the height the reader actually sees rather than an estimate from a constant. Group
1749/// headers and the group footer are **not** counted: the result is a step size for a
1750/// page key, and a step that overshoots by a header or two is friendlier than one that
1751/// undershoots and leaves the reader pressing the key again.
1752///
1753/// Zero for an empty column — "there is nothing to page" — and at least one otherwise,
1754/// so a page key in a column shorter than a single card still moves somewhere.
1755fn cards_per_screen(cards: &[Card], width: usize, rows: u16, display: BoardDisplay) -> usize {
1756    if cards.is_empty() {
1757        return 0;
1758    }
1759    let mut used: u16 = 0;
1760    let mut fits = 0usize;
1761    for card in cards {
1762        let height = u16::try_from(
1763            widgets::card_lines(card, width, display.wrap_titles, display.title_lines).len(),
1764        )
1765        .unwrap_or(u16::MAX);
1766        if fits > 0 && used.saturating_add(height) > rows {
1767            break;
1768        }
1769        used = used.saturating_add(height);
1770        fits += 1;
1771        if used >= rows {
1772            break;
1773        }
1774    }
1775    fits.max(1)
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780    use super::*;
1781    use crate::config::Config;
1782
1783    #[test]
1784    fn compute_column_rects_splits_into_equal_columns() {
1785        let area = Rect::new(5, 7, 90, 30);
1786        let rects = compute_column_rects(area, 3);
1787        assert_eq!(rects.len(), 3);
1788        assert_eq!((rects[0].x, rects[0].width), (5, 30));
1789        assert_eq!((rects[1].x, rects[1].width), (35, 30));
1790        assert_eq!((rects[2].x, rects[2].width), (65, 30));
1791        assert_eq!((rects[0].y, rects[0].height), (7, 30));
1792        assert_eq!((rects[2].y, rects[2].height), (7, 30));
1793    }
1794
1795    #[test]
1796    fn compute_column_rects_zero_columns_is_empty() {
1797        assert_eq!(
1798            compute_column_rects(Rect::new(0, 0, 10, 10), 0),
1799            [] as [ratatui::prelude::Rect; 0]
1800        );
1801    }
1802
1803    #[test]
1804    fn card_line_count_matches_rendered_card_heights() {
1805        use ratatui::Terminal;
1806        use ratatui::backend::TestBackend;
1807
1808        let wrapped = "A deliberately long card title that cannot fit on a single line inside the narrow column and must wrap over several rows";
1809        let cards = vec![
1810            Card {
1811                health: None,
1812                number: 1,
1813                title: "Short".into(),
1814                priority: crate::model::Priority::Medium,
1815                labels: vec![],
1816                assignee: None,
1817                filename: "1.md".into(),
1818                commit: None,
1819                created: "2026-01-01".into(),
1820                due_date: None,
1821                status: "todo".into(),
1822                category: None,
1823                category_color: None,
1824                done: false,
1825                cycle: None,
1826                milestone: None,
1827                blocked: None,
1828                children: None,
1829                parent: None,
1830                depth: 0,
1831                archived: false,
1832                related: 0,
1833                projects: vec![],
1834                epics: Vec::new(),
1835            },
1836            Card {
1837                health: None,
1838                number: 2,
1839                title: wrapped.into(),
1840                priority: crate::model::Priority::Medium,
1841                labels: vec![],
1842                assignee: None,
1843                filename: "2.md".into(),
1844                commit: None,
1845                created: "2026-01-01".into(),
1846                due_date: None,
1847                status: "todo".into(),
1848                category: None,
1849                category_color: None,
1850                done: false,
1851                cycle: None,
1852                milestone: None,
1853                blocked: None,
1854                children: None,
1855                parent: None,
1856                depth: 0,
1857                archived: false,
1858                related: 0,
1859                projects: vec![],
1860                epics: Vec::new(),
1861            },
1862            Card {
1863                health: None,
1864                number: 3,
1865                title: "Another long title card that also wraps".into(),
1866                priority: crate::model::Priority::Medium,
1867                labels: vec![],
1868                assignee: None,
1869                filename: "3.md".into(),
1870                commit: None,
1871                created: "2026-01-01".into(),
1872                due_date: None,
1873                status: "todo".into(),
1874                category: None,
1875                category_color: None,
1876                done: false,
1877                cycle: None,
1878                milestone: None,
1879                blocked: None,
1880                children: None,
1881                parent: None,
1882                depth: 0,
1883                archived: false,
1884                related: 0,
1885                projects: vec![],
1886                epics: Vec::new(),
1887            },
1888        ];
1889        let board = BoardState {
1890            columns: vec![BoardColumn {
1891                name: "todo".into(),
1892                cards,
1893            }],
1894            hidden: vec![],
1895            mismatch: BoardColumn {
1896                name: "Mismatched".into(),
1897                cards: vec![],
1898            },
1899            active_col: 0,
1900            selected: 0,
1901            filter: None,
1902            hover: None,
1903            hover_col: None,
1904            show_mismatch: false,
1905        };
1906        let display = BoardDisplay {
1907            wrap_titles: true,
1908            title_lines: 3,
1909        };
1910        let width = 80u16;
1911        let height = 30u16;
1912        let backend = TestBackend::new(width, height);
1913        let mut terminal = Terminal::new(backend).unwrap();
1914        terminal
1915            .draw(|f| {
1916                render(
1917                    &board,
1918                    f,
1919                    f.area(),
1920                    &None,
1921                    None,
1922                    display,
1923                    &crate::ui::color::Theme::default(),
1924                    None,
1925                    None,
1926                );
1927            })
1928            .unwrap();
1929        let buffer = terminal.backend().buffer();
1930
1931        let col_width = compute_column_rects(Rect::new(0, 0, width, height), 1)[0].width as usize;
1932
1933        let mut starts: Vec<(u32, u16)> = Vec::new();
1934        for y in 0..height {
1935            let line: String = (1..width.min(24))
1936                .map(|x| buffer[(x, y)].symbol())
1937                .collect();
1938            for card in &board.columns[0].cards {
1939                if line.starts_with(&format!("#{} ", card.number))
1940                    && !starts.iter().any(|(n, _)| *n == card.number)
1941                {
1942                    starts.push((card.number, y));
1943                    break;
1944                }
1945            }
1946        }
1947        starts.sort_by_key(|(_, y)| *y);
1948        assert_eq!(
1949            starts.len(),
1950            3,
1951            "every card found in the buffer: {starts:?}"
1952        );
1953        for (idx, (number, y)) in starts.iter().enumerate() {
1954            let span: u16 = if idx + 1 < starts.len() {
1955                starts[idx + 1].1 - y
1956            } else {
1957                u16::try_from(
1958                    (*y..height)
1959                        .filter(|&rw| {
1960                            rw != height - 1
1961                                && (1..(width - 1)).any(|x| buffer[(x, rw)].symbol() != " ")
1962                        })
1963                        .count(),
1964                )
1965                .unwrap()
1966            };
1967            let card = board.columns[0]
1968                .cards
1969                .iter()
1970                .find(|c| c.number == *number)
1971                .expect("card on the board");
1972            let expected =
1973                widgets::card_line_count(card, col_width, display.wrap_titles, display.title_lines);
1974            assert_eq!(
1975                span, expected,
1976                "rendered height of card #{number} must match card_line_count"
1977            );
1978        }
1979    }
1980
1981    #[test]
1982    fn hit_test_cards_and_column_fallback() {
1983        let tall = "A deliberately long card title that cannot fit on a single line inside the narrow column and wraps over several rows";
1984        let cards = vec![
1985            Card {
1986                health: None,
1987                number: 1,
1988                title: "one".into(),
1989                priority: Priority::Medium,
1990                labels: vec![],
1991                assignee: None,
1992                filename: "1.md".into(),
1993                commit: None,
1994                created: "2026-01-01".into(),
1995                due_date: None,
1996                status: "todo".into(),
1997                category: None,
1998                category_color: None,
1999                done: false,
2000                cycle: None,
2001                milestone: None,
2002                blocked: None,
2003                children: None,
2004                parent: None,
2005                depth: 0,
2006                archived: false,
2007                related: 0,
2008                projects: vec![],
2009                epics: Vec::new(),
2010            },
2011            Card {
2012                health: None,
2013                number: 2,
2014                title: tall.into(),
2015                priority: Priority::Medium,
2016                labels: vec![],
2017                assignee: None,
2018                filename: "2.md".into(),
2019                commit: None,
2020                created: "2026-01-02".into(),
2021                due_date: None,
2022                status: "todo".into(),
2023                category: None,
2024                category_color: None,
2025                done: false,
2026                cycle: None,
2027                milestone: None,
2028                blocked: None,
2029                children: None,
2030                parent: None,
2031                depth: 0,
2032                archived: false,
2033                related: 0,
2034                projects: vec![],
2035                epics: Vec::new(),
2036            },
2037        ];
2038        let board = BoardState {
2039            columns: vec![
2040                BoardColumn {
2041                    name: "todo".into(),
2042                    cards,
2043                },
2044                BoardColumn {
2045                    name: "done".into(),
2046                    cards: vec![Card {
2047                        health: None,
2048                        number: 3,
2049                        title: "three".into(),
2050                        priority: Priority::Medium,
2051                        labels: vec![],
2052                        assignee: None,
2053                        filename: "3.md".into(),
2054                        commit: None,
2055                        created: "2026-01-03".into(),
2056                        due_date: None,
2057                        status: "done".into(),
2058                        category: None,
2059                        category_color: None,
2060                        done: true,
2061                        cycle: None,
2062                        milestone: None,
2063                        blocked: None,
2064                        children: None,
2065                        parent: None,
2066                        depth: 0,
2067                        archived: false,
2068                        related: 0,
2069                        projects: vec![],
2070                        epics: Vec::new(),
2071                    }],
2072                },
2073            ],
2074            hidden: vec![],
2075            mismatch: BoardColumn {
2076                name: "Mismatched".into(),
2077                cards: vec![],
2078            },
2079            active_col: 0,
2080            selected: 0,
2081            filter: None,
2082            hover: None,
2083            hover_col: None,
2084            show_mismatch: false,
2085        };
2086        let display = BoardDisplay::default();
2087        let area = Rect::new(0, 0, 80, 30);
2088        let widths = compute_column_rects(area, 2);
2089        let col0 = widths[0].width as usize;
2090        let card1_height = widgets::card_line_count(&board.columns[0].cards[0], col0, true, 3);
2091
2092        // First card occupies a single row right below the top border.
2093        assert_eq!(
2094            board.hit_test(area, display, false, 3, 1),
2095            Some(HitTarget::Card { col: 0, row: 0 })
2096        );
2097        // Second (wrapping) card starts where the first one ends, row-wise.
2098        assert_eq!(
2099            board.hit_test(area, display, false, 3, 1 + card1_height),
2100            Some(HitTarget::Card { col: 0, row: 1 })
2101        );
2102        // Blank area below the stacked cards selects the column itself.
2103        assert_eq!(
2104            board.hit_test(area, display, false, 3, 10),
2105            Some(HitTarget::Column { col: 0 })
2106        );
2107        // The block border still resolves to its column.
2108        assert_eq!(
2109            board.hit_test(area, display, false, 0, 5),
2110            Some(HitTarget::Column { col: 0 })
2111        );
2112        // Second column, single card in the first content row.
2113        assert_eq!(
2114            board.hit_test(area, display, false, 55, 0),
2115            Some(HitTarget::Column { col: 1 })
2116        );
2117        assert_eq!(
2118            board.hit_test(area, display, false, 55, 1),
2119            Some(HitTarget::Card { col: 1, row: 0 })
2120        );
2121        // The block's bottom border still resolves to its column.
2122        assert_eq!(
2123            board.hit_test(area, display, false, 3, 29),
2124            Some(HitTarget::Column { col: 0 })
2125        );
2126    }
2127
2128    #[test]
2129    fn hit_test_outside_area_is_none() {
2130        let board = BoardState {
2131            columns: vec![BoardColumn {
2132                name: "todo".into(),
2133                cards: vec![Card {
2134                    health: None,
2135                    number: 1,
2136                    title: "one".into(),
2137                    priority: Priority::Medium,
2138                    labels: vec![],
2139                    assignee: None,
2140                    filename: "1.md".into(),
2141                    commit: None,
2142                    created: "2026-01-01".into(),
2143                    due_date: None,
2144                    status: "todo".into(),
2145                    category: None,
2146                    category_color: None,
2147                    done: false,
2148                    cycle: None,
2149                    milestone: None,
2150                    blocked: None,
2151                    children: None,
2152                    parent: None,
2153                    depth: 0,
2154                    archived: false,
2155                    related: 0,
2156                    projects: vec![],
2157                    epics: Vec::new(),
2158                }],
2159            }],
2160            hidden: vec![],
2161            mismatch: BoardColumn {
2162                name: "Mismatched".into(),
2163                cards: vec![],
2164            },
2165            active_col: 0,
2166            selected: 0,
2167            filter: None,
2168            hover: None,
2169            hover_col: None,
2170            show_mismatch: false,
2171        };
2172        let display = BoardDisplay::default();
2173        let area = Rect::new(0, 5, 40, 12);
2174        // Above the area.
2175        assert_eq!(board.hit_test(area, display, false, 3, 3), None);
2176        // Inside the area on a card (content starts one row below the top border).
2177        assert_eq!(
2178            board.hit_test(area, display, false, 3, 6),
2179            Some(HitTarget::Card { col: 0, row: 0 })
2180        );
2181        // Beyond the right edge of the area.
2182        assert_eq!(board.hit_test(area, display, false, 44, 7), None);
2183    }
2184
2185    #[test]
2186    fn hit_test_respects_status_row() {
2187        let board = BoardState {
2188            columns: vec![BoardColumn {
2189                name: "todo".into(),
2190                cards: vec![Card {
2191                    health: None,
2192                    number: 1,
2193                    title: "one".into(),
2194                    priority: Priority::Medium,
2195                    labels: vec![],
2196                    assignee: None,
2197                    filename: "1.md".into(),
2198                    commit: None,
2199                    created: "2026-01-01".into(),
2200                    due_date: None,
2201                    status: "todo".into(),
2202                    category: None,
2203                    category_color: None,
2204                    done: false,
2205                    cycle: None,
2206                    milestone: None,
2207                    blocked: None,
2208                    children: None,
2209                    parent: None,
2210                    depth: 0,
2211                    archived: false,
2212                    related: 0,
2213                    projects: vec![],
2214                    epics: Vec::new(),
2215                }],
2216            }],
2217            hidden: vec![],
2218            mismatch: BoardColumn {
2219                name: "Mismatched".into(),
2220                cards: vec![],
2221            },
2222            active_col: 0,
2223            selected: 0,
2224            filter: None,
2225            hover: None,
2226            hover_col: None,
2227            show_mismatch: false,
2228        };
2229        let display = BoardDisplay::default();
2230        let area = Rect::new(0, 0, 80, 30);
2231        // With a status message the last row belongs to the status line, not a
2232        // column, so it must not resolve.
2233        assert_eq!(board.hit_test(area, display, true, 3, 29), None);
2234        assert_eq!(
2235            board.hit_test(area, display, true, 3, 1),
2236            Some(HitTarget::Card { col: 0, row: 0 })
2237        );
2238    }
2239
2240    #[test]
2241    fn hit_test_group_header_is_not_a_card() {
2242        let cycle_card = |number: u32, cycle: &str| Card {
2243            health: None,
2244            number,
2245            title: format!("task {number}"),
2246            priority: Priority::Medium,
2247            labels: vec![],
2248            assignee: None,
2249            filename: format!("{number}.md"),
2250            commit: None,
2251            created: format!("2026-01-{number:02}"),
2252            due_date: None,
2253            status: "todo".into(),
2254            category: None,
2255            category_color: None,
2256            done: false,
2257            cycle: Some(cycle.into()),
2258            milestone: None,
2259            blocked: None,
2260            children: None,
2261            parent: None,
2262            depth: 0,
2263            archived: false,
2264            related: 0,
2265            projects: vec![],
2266            epics: Vec::new(),
2267        };
2268        let board = BoardState {
2269            columns: vec![
2270                BoardColumn {
2271                    name: "todo".into(),
2272                    cards: vec![cycle_card(1, "alpha"), cycle_card(2, "beta")],
2273                },
2274                BoardColumn {
2275                    name: "done".into(),
2276                    cards: vec![],
2277                },
2278            ],
2279            hidden: vec![],
2280            mismatch: BoardColumn {
2281                name: "Mismatched".into(),
2282                cards: vec![],
2283            },
2284            active_col: 0,
2285            selected: 0,
2286            filter: Some("group:cycle".into()),
2287            hover: None,
2288            hover_col: None,
2289            show_mismatch: false,
2290        };
2291        let display = BoardDisplay::default();
2292        let area = Rect::new(0, 0, 80, 30);
2293        let col0 = area.width / 2;
2294        let first_height =
2295            widgets::card_line_count(&board.columns[0].cards[0], col0 as usize, true, 3);
2296
2297        // Header row for the "alpha" section selects the column, not a card.
2298        assert_eq!(
2299            board.hit_test(area, display, false, 5, 1),
2300            Some(HitTarget::Column { col: 0 })
2301        );
2302        // The card below its header maps to its real column index.
2303        assert_eq!(
2304            board.hit_test(area, display, false, 5, 2),
2305            Some(HitTarget::Card { col: 0, row: 0 })
2306        );
2307        // Second section header sits below the first card's rows.
2308        assert_eq!(
2309            board.hit_test(area, display, false, 5, 2 + first_height),
2310            Some(HitTarget::Column { col: 0 })
2311        );
2312        assert_eq!(
2313            board.hit_test(area, display, false, 5, 3 + first_height),
2314            Some(HitTarget::Card { col: 0, row: 1 })
2315        );
2316    }
2317
2318    #[test]
2319    fn hit_test_mismatch_column_resolves() {
2320        let board = BoardState {
2321            columns: vec![BoardColumn {
2322                name: "todo".into(),
2323                cards: vec![],
2324            }],
2325            hidden: vec![],
2326            mismatch: BoardColumn {
2327                name: "Mismatched".into(),
2328                cards: vec![Card {
2329                    health: None,
2330                    number: 9,
2331                    title: "oddball".into(),
2332                    priority: Priority::Medium,
2333                    labels: vec![],
2334                    assignee: None,
2335                    filename: "9.md".into(),
2336                    commit: None,
2337                    created: "2026-01-09".into(),
2338                    due_date: None,
2339                    status: "weird".into(),
2340                    category: None,
2341                    category_color: None,
2342                    done: false,
2343                    cycle: None,
2344                    milestone: None,
2345                    blocked: None,
2346                    children: None,
2347                    parent: None,
2348                    depth: 0,
2349                    archived: false,
2350                    related: 0,
2351                    projects: vec![],
2352                    epics: Vec::new(),
2353                }],
2354            },
2355            active_col: 0,
2356            selected: 0,
2357            filter: None,
2358            hover: None,
2359            hover_col: None,
2360            show_mismatch: true,
2361        };
2362        let display = BoardDisplay::default();
2363        let area = Rect::new(0, 0, 90, 30);
2364        let mismatch_col = board.columns.len();
2365        // The mismatch column is the last (3rd) of the equal-width columns.
2366        assert_eq!(
2367            board.hit_test(area, display, false, 70, 1),
2368            Some(HitTarget::Card {
2369                col: mismatch_col,
2370                row: 0
2371            })
2372        );
2373    }
2374
2375    #[test]
2376    fn render_highlights_hovered_card_without_selecting() {
2377        use ratatui::Terminal;
2378        use ratatui::backend::TestBackend;
2379        let board = BoardState {
2380            columns: vec![BoardColumn {
2381                name: "todo".into(),
2382                cards: vec![
2383                    Card {
2384                        health: None,
2385                        number: 1,
2386                        title: "Alpha".into(),
2387                        priority: Priority::Medium,
2388                        labels: vec![],
2389                        assignee: None,
2390                        filename: "1.md".into(),
2391                        commit: None,
2392                        created: "2026-01-01".into(),
2393                        due_date: None,
2394                        status: "todo".into(),
2395                        category: None,
2396                        category_color: None,
2397                        done: false,
2398                        cycle: None,
2399                        milestone: None,
2400                        blocked: None,
2401                        children: None,
2402                        parent: None,
2403                        depth: 0,
2404                        archived: false,
2405                        related: 0,
2406                        projects: vec![],
2407                        epics: Vec::new(),
2408                    },
2409                    Card {
2410                        health: None,
2411                        number: 2,
2412                        title: "Beta".into(),
2413                        priority: Priority::Medium,
2414                        labels: vec![],
2415                        assignee: None,
2416                        filename: "2.md".into(),
2417                        commit: None,
2418                        created: "2026-01-02".into(),
2419                        due_date: None,
2420                        status: "todo".into(),
2421                        category: None,
2422                        category_color: None,
2423                        done: false,
2424                        cycle: None,
2425                        milestone: None,
2426                        blocked: None,
2427                        children: None,
2428                        parent: None,
2429                        depth: 0,
2430                        archived: false,
2431                        related: 0,
2432                        projects: vec![],
2433                        epics: Vec::new(),
2434                    },
2435                ],
2436            }],
2437            hidden: vec![],
2438            mismatch: BoardColumn {
2439                name: "Mismatched".into(),
2440                cards: vec![],
2441            },
2442            show_mismatch: false,
2443            active_col: 0,
2444            selected: 0,
2445            hover: Some((0, 1)),
2446            hover_col: Some(0),
2447            filter: None,
2448        };
2449        let display = BoardDisplay::default();
2450        let backend = TestBackend::new(80, 30);
2451        let mut terminal = Terminal::new(backend).unwrap();
2452        terminal
2453            .draw(|f| {
2454                render(
2455                    &board,
2456                    f,
2457                    f.area(),
2458                    &None,
2459                    None,
2460                    display,
2461                    &crate::ui::color::Theme::default(),
2462                    None,
2463                    None,
2464                );
2465            })
2466            .unwrap();
2467        let buffer = terminal.backend().buffer();
2468
2469        let mut rows = [None, None];
2470        for y in 0..30u16 {
2471            let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
2472            if line.starts_with("#1") {
2473                rows[0] = Some(y);
2474            }
2475            if line.starts_with("#2") {
2476                rows[1] = Some(y);
2477            }
2478        }
2479        let y1 = rows[0].expect("first card rendered");
2480        let y2 = rows[1].expect("second card rendered");
2481
2482        // The selected card keeps its selection style (dark-gray background)...
2483        assert_eq!(buffer[(3, y1)].style().bg, Some(Color::DarkGray));
2484        // ...while the hovered card is highlighted in cyan...
2485        assert_eq!(buffer[(3, y2)].style().fg, Some(Color::Cyan));
2486        // ...and hovering changed nothing about the selection.
2487        assert_eq!((board.active_col, board.selected), (0, 0));
2488    }
2489
2490    #[test]
2491    fn render_highlights_the_hovered_cards_column() {
2492        use ratatui::Terminal;
2493        use ratatui::backend::TestBackend;
2494
2495        let card = |number: u32, title: &str, status: &str| Card {
2496            health: None,
2497            number,
2498            title: title.into(),
2499            priority: Priority::Medium,
2500            labels: vec![],
2501            assignee: None,
2502            filename: format!("{number}.md"),
2503            commit: None,
2504            created: "2026-01-01".into(),
2505            due_date: None,
2506            status: status.into(),
2507            category: None,
2508            category_color: None,
2509            done: false,
2510            cycle: None,
2511            milestone: None,
2512            blocked: None,
2513            children: None,
2514            parent: None,
2515            depth: 0,
2516            archived: false,
2517            related: 0,
2518            projects: vec![],
2519            epics: Vec::new(),
2520        };
2521        let board = BoardState {
2522            columns: vec![
2523                BoardColumn {
2524                    name: "todo".into(),
2525                    cards: vec![card(1, "Alpha", "todo")],
2526                },
2527                BoardColumn {
2528                    name: "doing".into(),
2529                    cards: vec![card(2, "Beta", "doing")],
2530                },
2531            ],
2532            hidden: vec![],
2533            mismatch: BoardColumn {
2534                name: "Mismatched".into(),
2535                cards: vec![],
2536            },
2537            show_mismatch: false,
2538            active_col: 0,
2539            selected: 0,
2540            // Hovering the card in the *second* column.
2541            hover: Some((1, 0)),
2542            hover_col: Some(1),
2543            filter: None,
2544        };
2545        let display = BoardDisplay::default();
2546        let area = Rect::new(0, 0, 80, 30);
2547        let backend = TestBackend::new(80, 30);
2548        let mut terminal = Terminal::new(backend).unwrap();
2549        terminal
2550            .draw(|f| {
2551                render(
2552                    &board,
2553                    f,
2554                    f.area(),
2555                    &None,
2556                    None,
2557                    display,
2558                    &crate::ui::color::Theme::default(),
2559                    None,
2560                    None,
2561                );
2562            })
2563            .unwrap();
2564        let buffer = terminal.backend().buffer();
2565
2566        let chunks = compute_column_rects(area, board.total_cols());
2567        let hovered = chunks[1];
2568        let active = chunks[0];
2569        assert_eq!(
2570            buffer[(hovered.x, hovered.y)].style().fg,
2571            Some(Color::Blue),
2572            "the hovered column is outlined"
2573        );
2574        assert_eq!(
2575            buffer[(active.x, active.y)].style().fg,
2576            Some(Color::Cyan),
2577            "the active column keeps its own colour"
2578        );
2579    }
2580
2581    #[test]
2582    fn render_marks_an_empty_hovered_column() {
2583        use ratatui::Terminal;
2584        use ratatui::backend::TestBackend;
2585
2586        let board = BoardState {
2587            columns: vec![
2588                BoardColumn {
2589                    name: "todo".into(),
2590                    cards: vec![],
2591                },
2592                BoardColumn {
2593                    name: "doing".into(),
2594                    cards: vec![],
2595                },
2596            ],
2597            hidden: vec![],
2598            mismatch: BoardColumn {
2599                name: "Mismatched".into(),
2600                cards: vec![],
2601            },
2602            show_mismatch: false,
2603            active_col: 0,
2604            selected: 0,
2605            hover: None,
2606            // The pointer is over the *empty* second column.
2607            hover_col: Some(1),
2608            filter: None,
2609        };
2610        let display = BoardDisplay::default();
2611        let area = Rect::new(0, 0, 80, 30);
2612        let backend = TestBackend::new(80, 30);
2613        let mut terminal = Terminal::new(backend).unwrap();
2614        terminal
2615            .draw(|f| {
2616                render(
2617                    &board,
2618                    f,
2619                    f.area(),
2620                    &None,
2621                    None,
2622                    display,
2623                    &crate::ui::color::Theme::default(),
2624                    None,
2625                    None,
2626                );
2627            })
2628            .unwrap();
2629        let buffer = terminal.backend().buffer();
2630
2631        let chunks = compute_column_rects(area, board.total_cols());
2632        assert_eq!(
2633            buffer[(chunks[1].x, chunks[1].y)].style().fg,
2634            Some(Color::Blue),
2635            "an empty column with no cards is still outlined when hovered"
2636        );
2637        assert_eq!(
2638            buffer[(chunks[0].x, chunks[0].y)].style().fg,
2639            Some(Color::Cyan),
2640            "the active column keeps its own colour"
2641        );
2642    }
2643
2644    #[test]
2645    fn total_cols_includes_mismatch() {
2646        let board = BoardState {
2647            columns: vec![
2648                BoardColumn {
2649                    name: "a".into(),
2650                    cards: vec![],
2651                },
2652                BoardColumn {
2653                    name: "b".into(),
2654                    cards: vec![],
2655                },
2656            ],
2657            mismatch: BoardColumn {
2658                name: "Mismatched".into(),
2659                cards: vec![],
2660            },
2661            active_col: 0,
2662            selected: 0,
2663            filter: None,
2664            hover: None,
2665            hover_col: None,
2666            hidden: vec![],
2667            show_mismatch: true,
2668        };
2669        assert_eq!(board.total_cols(), 3);
2670    }
2671
2672    #[test]
2673    fn column_name_mismatch_index() {
2674        let board = BoardState {
2675            columns: vec![BoardColumn {
2676                name: "todo".into(),
2677                cards: vec![],
2678            }],
2679            mismatch: BoardColumn {
2680                name: "Mismatched".into(),
2681                cards: vec![],
2682            },
2683            active_col: 0,
2684            selected: 0,
2685            filter: None,
2686            hover: None,
2687            hover_col: None,
2688            hidden: vec![],
2689            show_mismatch: true,
2690        };
2691        assert_eq!(board.column_name(0), "todo");
2692        assert_eq!(board.column_name(1), "Mismatched");
2693    }
2694
2695    #[test]
2696    fn move_left_wraps_around() {
2697        let mut board = BoardState {
2698            columns: vec![
2699                BoardColumn {
2700                    name: "a".into(),
2701                    cards: vec![],
2702                },
2703                BoardColumn {
2704                    name: "b".into(),
2705                    cards: vec![],
2706                },
2707            ],
2708            hidden: vec![],
2709            mismatch: BoardColumn {
2710                name: "Mismatched".into(),
2711                cards: vec![],
2712            },
2713            active_col: 0,
2714            selected: 0,
2715            filter: None,
2716            hover: None,
2717            hover_col: None,
2718            show_mismatch: true,
2719        };
2720        board.move_left();
2721        assert_eq!(board.active_col, 1);
2722    }
2723
2724    #[test]
2725    fn move_right_wraps_around() {
2726        let mut board = BoardState {
2727            columns: vec![
2728                BoardColumn {
2729                    name: "a".into(),
2730                    cards: vec![],
2731                },
2732                BoardColumn {
2733                    name: "b".into(),
2734                    cards: vec![],
2735                },
2736            ],
2737            mismatch: BoardColumn {
2738                name: "Mismatched".into(),
2739                cards: vec![],
2740            },
2741            active_col: 2,
2742            selected: 0,
2743            filter: None,
2744            hover: None,
2745            hover_col: None,
2746            hidden: vec![],
2747            show_mismatch: true,
2748        };
2749        board.move_right();
2750        assert_eq!(board.active_col, 0);
2751    }
2752
2753    #[test]
2754    fn clamp_selection_empty_column() {
2755        let mut board = BoardState {
2756            columns: vec![BoardColumn {
2757                name: "a".into(),
2758                cards: vec![],
2759            }],
2760            mismatch: BoardColumn {
2761                name: "Mismatched".into(),
2762                cards: vec![],
2763            },
2764            active_col: 0,
2765            selected: 5,
2766            filter: None,
2767            hover: None,
2768            hover_col: None,
2769            hidden: vec![],
2770            show_mismatch: true,
2771        };
2772        board.clamp_selection();
2773        assert_eq!(board.selected, 0);
2774    }
2775
2776    #[test]
2777    fn clamp_selection_single_card() {
2778        let mut board = BoardState {
2779            columns: vec![BoardColumn {
2780                name: "a".into(),
2781                cards: vec![Card {
2782                    health: None,
2783                    number: 1,
2784                    title: "X".into(),
2785                    priority: crate::model::Priority::Medium,
2786                    labels: vec![],
2787                    assignee: None,
2788                    filename: "x.md".into(),
2789                    commit: None,
2790                    created: "2026-01-01".into(),
2791                    due_date: None,
2792                    status: "todo".into(),
2793                    category: None,
2794                    category_color: None,
2795                    done: false,
2796                    cycle: None,
2797                    milestone: None,
2798                    blocked: None,
2799                    children: None,
2800                    parent: None,
2801                    depth: 0,
2802                    archived: false,
2803                    related: 0,
2804                    projects: vec![],
2805                    epics: Vec::new(),
2806                }],
2807            }],
2808            mismatch: BoardColumn {
2809                name: "Mismatched".into(),
2810                cards: vec![],
2811            },
2812            active_col: 0,
2813            selected: 5,
2814            filter: None,
2815            hover: None,
2816            hover_col: None,
2817            hidden: vec![],
2818            show_mismatch: true,
2819        };
2820        board.clamp_selection();
2821        assert_eq!(board.selected, 0);
2822    }
2823
2824    #[test]
2825    fn move_up_clamps_at_top() {
2826        let mut board = BoardState {
2827            columns: vec![BoardColumn {
2828                name: "a".into(),
2829                cards: vec![Card {
2830                    health: None,
2831                    number: 1,
2832                    title: "X".into(),
2833                    priority: crate::model::Priority::Medium,
2834                    labels: vec![],
2835                    assignee: None,
2836                    filename: "x.md".into(),
2837                    commit: None,
2838                    created: "2026-01-01".into(),
2839                    due_date: None,
2840                    status: "todo".into(),
2841                    category: None,
2842                    category_color: None,
2843                    done: false,
2844                    cycle: None,
2845                    milestone: None,
2846                    blocked: None,
2847                    children: None,
2848                    parent: None,
2849                    depth: 0,
2850                    archived: false,
2851                    related: 0,
2852                    projects: vec![],
2853                    epics: Vec::new(),
2854                }],
2855            }],
2856            mismatch: BoardColumn {
2857                name: "Mismatched".into(),
2858                cards: vec![],
2859            },
2860            active_col: 0,
2861            selected: 0,
2862            filter: None,
2863            hover: None,
2864            hover_col: None,
2865            hidden: vec![],
2866            show_mismatch: true,
2867        };
2868        board.move_up();
2869        assert_eq!(board.selected, 0);
2870    }
2871
2872    #[test]
2873    fn move_down_clamps_at_bottom() {
2874        let mut board = BoardState {
2875            columns: vec![BoardColumn {
2876                name: "a".into(),
2877                cards: vec![Card {
2878                    health: None,
2879                    number: 1,
2880                    title: "X".into(),
2881                    priority: crate::model::Priority::Medium,
2882                    labels: vec![],
2883                    assignee: None,
2884                    filename: "x.md".into(),
2885                    commit: None,
2886                    created: "2026-01-01".into(),
2887                    due_date: None,
2888                    status: "todo".into(),
2889                    category: None,
2890                    category_color: None,
2891                    done: false,
2892                    cycle: None,
2893                    milestone: None,
2894                    blocked: None,
2895                    children: None,
2896                    parent: None,
2897                    depth: 0,
2898                    archived: false,
2899                    related: 0,
2900                    projects: vec![],
2901                    epics: Vec::new(),
2902                }],
2903            }],
2904            mismatch: BoardColumn {
2905                name: "Mismatched".into(),
2906                cards: vec![],
2907            },
2908            active_col: 0,
2909            selected: 0,
2910            filter: None,
2911            hover: None,
2912            hover_col: None,
2913            hidden: vec![],
2914            show_mismatch: true,
2915        };
2916        board.move_down();
2917        assert_eq!(board.selected, 0);
2918    }
2919
2920    #[test]
2921    fn render_smoke_does_not_panic_on_tiny_frame() {
2922        use ratatui::Terminal;
2923        use ratatui::backend::TestBackend;
2924
2925        let backend = TestBackend::new(5, 3);
2926        let mut terminal = Terminal::new(backend).unwrap();
2927        let board = BoardState {
2928            columns: vec![],
2929            mismatch: BoardColumn {
2930                name: "Mismatched".into(),
2931                cards: vec![],
2932            },
2933            active_col: 0,
2934            selected: 0,
2935            filter: None,
2936            hover: None,
2937            hover_col: None,
2938            hidden: vec![],
2939            show_mismatch: true,
2940        };
2941        terminal
2942            .draw(|f| {
2943                render(
2944                    &board,
2945                    f,
2946                    f.area(),
2947                    &None,
2948                    None,
2949                    BoardDisplay::default(),
2950                    &crate::ui::color::Theme::default(),
2951                    None,
2952                    None,
2953                );
2954            })
2955            .unwrap();
2956    }
2957
2958    #[test]
2959    fn render_smoke_full_board() {
2960        use ratatui::Terminal;
2961        use ratatui::backend::TestBackend;
2962
2963        let backend = TestBackend::new(80, 24);
2964        let mut terminal = Terminal::new(backend).unwrap();
2965        let board = BoardState {
2966            columns: vec![
2967                BoardColumn {
2968                    name: "todo".into(),
2969                    cards: vec![],
2970                },
2971                BoardColumn {
2972                    name: "done".into(),
2973                    cards: vec![],
2974                },
2975            ],
2976            mismatch: BoardColumn {
2977                name: "Mismatched".into(),
2978                cards: vec![],
2979            },
2980            active_col: 0,
2981            selected: 0,
2982            filter: None,
2983            hover: None,
2984            hover_col: None,
2985            hidden: vec![],
2986            show_mismatch: true,
2987        };
2988        terminal
2989            .draw(|f| {
2990                render(
2991                    &board,
2992                    f,
2993                    f.area(),
2994                    &Some("test msg".into()),
2995                    None,
2996                    BoardDisplay::default(),
2997                    &crate::ui::color::Theme::default(),
2998                    None,
2999                    None,
3000                );
3001            })
3002            .unwrap();
3003    }
3004
3005    #[test]
3006    fn render_selected_multiline_card_highlights_all_wrapped_lines() {
3007        use ratatui::Terminal;
3008        use ratatui::backend::TestBackend;
3009
3010        let title = "This is a deliberately long card title that will not fit on a single line inside the column and must wrap more than once";
3011        let card = crate::ui::board::Card {
3012            health: None,
3013            number: 7,
3014            title: title.into(),
3015            priority: crate::model::Priority::Medium,
3016            labels: vec![],
3017            assignee: None,
3018            filename: "x.md".into(),
3019            commit: None,
3020            created: "2026-01-01".into(),
3021            due_date: None,
3022            status: "todo".into(),
3023            category: None,
3024            category_color: None,
3025            done: false,
3026            cycle: None,
3027            milestone: None,
3028            blocked: None,
3029            children: None,
3030            parent: None,
3031            depth: 0,
3032            archived: false,
3033            related: 0,
3034            projects: vec![],
3035            epics: Vec::new(),
3036        };
3037        let backend = TestBackend::new(80, 24);
3038        let mut terminal = Terminal::new(backend).unwrap();
3039        let board = BoardState {
3040            columns: vec![BoardColumn {
3041                name: "todo".into(),
3042                cards: vec![card],
3043            }],
3044            mismatch: BoardColumn {
3045                name: "Mismatched".into(),
3046                cards: vec![],
3047            },
3048            active_col: 0,
3049            selected: 0,
3050            filter: None,
3051            hover: None,
3052            hover_col: None,
3053            hidden: vec![],
3054            show_mismatch: false,
3055        };
3056        let display = BoardDisplay {
3057            wrap_titles: true,
3058            title_lines: 3,
3059        };
3060        terminal
3061            .draw(|f| {
3062                render(
3063                    &board,
3064                    f,
3065                    f.area(),
3066                    &None,
3067                    None,
3068                    display,
3069                    &crate::ui::color::Theme::default(),
3070                    None,
3071                    None,
3072                );
3073            })
3074            .unwrap();
3075        let buffer = terminal.backend().buffer();
3076
3077        // The card is the only item in the only column; its block top border
3078        // sits on y=0, so the ListItem starts at y=1 and each wrapped line
3079        // must carry the selected-card highlight (DarkGray bg + BOLD).
3080        let mut highlighted = 0;
3081        for y in 1..buffer.area().height - 1 {
3082            let cell = &buffer[(2, y)];
3083            if cell.bg != Color::DarkGray {
3084                break;
3085            }
3086            assert!(
3087                cell.modifier.contains(Modifier::BOLD),
3088                "highlighted continuation line must stay bold: {cell:?}"
3089            );
3090            highlighted += 1;
3091        }
3092        assert!(
3093            highlighted >= 2,
3094            "wrapped card highlights every line, got {highlighted}"
3095        );
3096        assert!(
3097            highlighted <= display.title_lines,
3098            "highlight never exceeds the line limit: {highlighted}"
3099        );
3100    }
3101
3102    #[test]
3103    fn render_grouped_mode_shows_section_headers() {
3104        use ratatui::Terminal;
3105        use ratatui::backend::TestBackend;
3106
3107        let backend = TestBackend::new(80, 24);
3108        let mut terminal = Terminal::new(backend).unwrap();
3109        let board = BoardState {
3110            columns: vec![BoardColumn {
3111                name: "todo".into(),
3112                cards: vec![
3113                    Card {
3114                        health: None,
3115                        number: 1,
3116                        title: "A".into(),
3117                        priority: crate::model::Priority::Medium,
3118                        labels: vec![],
3119                        assignee: None,
3120                        filename: "a.md".into(),
3121                        commit: None,
3122                        created: "2026-01-01".into(),
3123                        due_date: None,
3124                        status: "todo".into(),
3125                        category: None,
3126                        category_color: None,
3127                        done: false,
3128                        cycle: Some("Cycle 1".into()),
3129                        milestone: None,
3130                        blocked: None,
3131                        children: None,
3132                        parent: None,
3133                        depth: 0,
3134                        archived: false,
3135                        related: 0,
3136                        projects: vec![],
3137                        epics: Vec::new(),
3138                    },
3139                    Card {
3140                        health: None,
3141                        number: 2,
3142                        title: "B".into(),
3143                        priority: crate::model::Priority::Medium,
3144                        labels: vec![],
3145                        assignee: None,
3146                        filename: "b.md".into(),
3147                        commit: None,
3148                        created: "2026-01-01".into(),
3149                        due_date: None,
3150                        status: "todo".into(),
3151                        category: None,
3152                        category_color: None,
3153                        done: true,
3154                        cycle: Some("Cycle 1".into()),
3155                        milestone: None,
3156                        blocked: None,
3157                        children: None,
3158                        parent: None,
3159                        depth: 0,
3160                        archived: false,
3161                        related: 0,
3162                        projects: vec![],
3163                        epics: Vec::new(),
3164                    },
3165                    Card {
3166                        health: None,
3167                        number: 3,
3168                        title: "C".into(),
3169                        priority: crate::model::Priority::Medium,
3170                        labels: vec![],
3171                        assignee: None,
3172                        filename: "c.md".into(),
3173                        commit: None,
3174                        created: "2026-01-01".into(),
3175                        due_date: None,
3176                        status: "todo".into(),
3177                        category: None,
3178                        category_color: None,
3179                        done: false,
3180                        cycle: Some("Cycle 2".into()),
3181                        milestone: None,
3182                        blocked: None,
3183                        children: None,
3184                        parent: None,
3185                        depth: 0,
3186                        archived: false,
3187                        related: 0,
3188                        projects: vec![],
3189                        epics: Vec::new(),
3190                    },
3191                ],
3192            }],
3193            mismatch: BoardColumn {
3194                name: "Mismatched".into(),
3195                cards: vec![],
3196            },
3197            active_col: 0,
3198            selected: 0,
3199            filter: Some("group:cycle".into()),
3200            hover: None,
3201            hover_col: None,
3202            hidden: vec![],
3203            show_mismatch: false,
3204        };
3205        terminal
3206            .draw(|f| {
3207                render(
3208                    &board,
3209                    f,
3210                    f.area(),
3211                    &None,
3212                    None,
3213                    BoardDisplay::default(),
3214                    &crate::ui::color::Theme::default(),
3215                    None,
3216                    None,
3217                );
3218            })
3219            .unwrap();
3220
3221        let buffer = terminal.backend().buffer();
3222        let mut lines = Vec::new();
3223        for y in 0..buffer.area.height {
3224            let line = (0..buffer.area.width)
3225                .map(|x| buffer[(x, y)].symbol())
3226                .collect::<String>();
3227            lines.push(line);
3228        }
3229        let rendered = lines.join("\n");
3230        assert!(rendered.contains("-- cycle: Cycle 1 (1/2 done, 50%) --"));
3231        assert!(rendered.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"));
3232    }
3233
3234    #[test]
3235    fn build_filtered_keeps_only_fuzzy_matches() {
3236        use crate::model::Issue;
3237        use crate::search::Corpus;
3238
3239        let to_issue = |number: u32, title: &str| Issue {
3240            projects: None,
3241            id: None,
3242            number,
3243            title: title.to_string(),
3244            status: crate::model::Status::new("todo"),
3245            labels: vec![],
3246            priority: crate::model::Priority::Medium,
3247            assignee: None,
3248            estimate: None,
3249            commit: None,
3250            gitlab_id: None,
3251            linear_id: None,
3252            mission: None,
3253            task: None,
3254            relationships: vec![],
3255            related_issues: vec![],
3256            files: vec![],
3257            cycles: None,
3258            milestones: None,
3259            reviewer: None,
3260            start_date: None,
3261            due_date: None,
3262            created: "2026-01-01".into(),
3263            type_: None,
3264            updated: "2026-01-01".into(),
3265            filename: format!("2026-01-01-{number}.md"),
3266            body: String::new(),
3267            history: crate::model::History::new(),
3268            has_retired_links_key: false,
3269            time_entries: vec![],
3270            parent: None,
3271            series: None,
3272            health: None,
3273            epics: None,
3274            extra_frontmatter: std::collections::BTreeMap::new(),
3275            archived: false,
3276            entered_at: None,
3277            source: None,
3278        };
3279        let corpus = Corpus::with_texts(
3280            vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
3281            vec!["Fix login bug".to_string(), "Polish exports".to_string()],
3282        );
3283        let config = Config::default();
3284        let (_, resolution) = crate::workflow::active_resolution(&config);
3285        let board_config = config.default_config();
3286
3287        let board = BoardState::build_filtered(
3288            board_config,
3289            &config,
3290            &corpus,
3291            "log",
3292            &StructuredFilter::default(),
3293            &resolution,
3294        );
3295        assert_eq!(board.columns.len(), board_config.columns.len());
3296        assert_eq!(board.column_cards(0).len(), 1);
3297        assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
3298        assert_eq!(board.filter.as_deref(), Some("log"));
3299
3300        let none = BoardState::build_filtered(
3301            board_config,
3302            &config,
3303            &corpus,
3304            "zzw",
3305            &StructuredFilter::default(),
3306            &resolution,
3307        );
3308        assert!(none.is_empty());
3309
3310        let empty = BoardState::build_filtered(
3311            board_config,
3312            &config,
3313            &corpus,
3314            "",
3315            &StructuredFilter::default(),
3316            &resolution,
3317        );
3318        assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
3319        assert!(empty.filter.is_some());
3320    }
3321
3322    #[test]
3323    fn build_filtered_group_cycle_keeps_original_titles() {
3324        use crate::model::Issue;
3325        use crate::search::Corpus;
3326
3327        let issue = |number: u32, title: &str, status: &str, cycles: &[&str]| Issue {
3328            projects: None,
3329            id: None,
3330            number,
3331            title: title.to_string(),
3332            status: crate::model::Status::new(status),
3333            labels: vec![],
3334            priority: crate::model::Priority::Medium,
3335            assignee: None,
3336            estimate: None,
3337            commit: None,
3338            gitlab_id: None,
3339            linear_id: None,
3340            mission: None,
3341            task: None,
3342            relationships: vec![],
3343            related_issues: vec![],
3344            files: vec![],
3345            cycles: Some(cycles.iter().map(|c| (*c).to_string()).collect()),
3346            milestones: None,
3347            reviewer: None,
3348            start_date: None,
3349            due_date: None,
3350            created: "2026-01-01".into(),
3351            type_: None,
3352            updated: "2026-01-01".into(),
3353            filename: format!("2026-01-01-{number}.md"),
3354            body: String::new(),
3355            history: crate::model::History::new(),
3356            has_retired_links_key: false,
3357            time_entries: vec![],
3358            parent: None,
3359            series: None,
3360            health: None,
3361            epics: None,
3362            extra_frontmatter: std::collections::BTreeMap::new(),
3363            archived: false,
3364            entered_at: None,
3365            source: None,
3366        };
3367
3368        let corpus = Corpus::with_texts(
3369            vec![
3370                issue(1, "A", "todo", &["Cycle 1"]),
3371                issue(2, "B", "done", &["Cycle 1"]),
3372                issue(3, "C", "todo", &["Cycle 2"]),
3373            ],
3374            vec![
3375                "cycle: Cycle 1\nstatus: todo".to_string(),
3376                "cycle: Cycle 1\nstatus: done".to_string(),
3377                "cycle: Cycle 2\nstatus: todo".to_string(),
3378            ],
3379        );
3380
3381        let config = Config::default();
3382        let (_, resolution) = crate::workflow::active_resolution(&config);
3383        let board = BoardState::build_filtered(
3384            config.default_config(),
3385            &config,
3386            &corpus,
3387            "group:cycle",
3388            &StructuredFilter::default(),
3389            &resolution,
3390        );
3391        let titles: Vec<String> = board
3392            .columns
3393            .iter()
3394            .flat_map(|col| col.cards.iter().map(|c| c.title.clone()))
3395            .collect();
3396        assert!(titles.iter().any(|t| t == "A"));
3397        assert!(titles.iter().any(|t| t == "B"));
3398        assert!(titles.iter().any(|t| t == "C"));
3399    }
3400
3401    /// A config declaring `p-1`/`Platform` and `p-2`/`Infra`, and a corpus of four
3402    /// issues: one per project, one in both, one in none (WP06 T028).
3403    fn project_fixture() -> (Config, crate::search::Corpus) {
3404        use crate::config::ProjectConfiguration;
3405        use crate::model::Issue;
3406        use crate::search::Corpus;
3407
3408        let issue = |number: u32, title: &str, projects: &[&str]| Issue {
3409            projects: if projects.is_empty() {
3410                None
3411            } else {
3412                Some(projects.iter().map(|p| (*p).to_string()).collect())
3413            },
3414            id: None,
3415            number,
3416            title: title.to_string(),
3417            status: crate::model::Status::new("todo"),
3418            labels: vec![],
3419            priority: crate::model::Priority::Medium,
3420            assignee: None,
3421            estimate: None,
3422            commit: None,
3423            gitlab_id: None,
3424            linear_id: None,
3425            mission: None,
3426            task: None,
3427            relationships: vec![],
3428            related_issues: vec![],
3429            files: vec![],
3430            cycles: None,
3431            milestones: None,
3432            reviewer: None,
3433            start_date: None,
3434            due_date: None,
3435            created: "2026-01-01".into(),
3436            type_: None,
3437            updated: "2026-01-01".into(),
3438            filename: format!("2026-01-01-{number}.md"),
3439            body: String::new(),
3440            history: crate::model::History::new(),
3441            has_retired_links_key: false,
3442            time_entries: vec![],
3443            parent: None,
3444            series: None,
3445            health: None,
3446            epics: None,
3447            extra_frontmatter: std::collections::BTreeMap::new(),
3448            archived: false,
3449            entered_at: None,
3450            source: None,
3451        };
3452
3453        let issues = vec![
3454            issue(1, "Platform work", &["p-1"]),
3455            issue(2, "Infra work", &["p-2"]),
3456            issue(3, "Spans both", &["p-1", "p-2"]),
3457            issue(4, "Homeless", &[]),
3458        ];
3459        let texts: Vec<String> = issues.iter().map(Issue::frontmatter).collect();
3460        let corpus = Corpus::with_texts(issues, texts);
3461
3462        let mut config = Config::default();
3463        for (id, name) in [("p-1", "Platform"), ("p-2", "Infra")] {
3464            config.projects.insert(
3465                id.to_string(),
3466                ProjectConfiguration {
3467                    name: name.to_string(),
3468                    ..ProjectConfiguration::default()
3469                },
3470            );
3471        }
3472        (config, corpus)
3473    }
3474
3475    /// The epic analogue of [`project_fixture`]: two registered epics and four
3476    /// issues spanning them, the fixture the T030 tests read. Built from parsed
3477    /// frontmatter so the fixture exercises the model's own reader.
3478    fn epic_fixture() -> (Config, crate::search::Corpus) {
3479        use crate::config::EpicConfiguration;
3480        use crate::model::Issue;
3481        use crate::search::Corpus;
3482
3483        let make = |number: u32, title: &str, epics: &[&str]| {
3484            let list = if epics.is_empty() {
3485                String::new()
3486            } else {
3487                let ids = epics
3488                    .iter()
3489                    .map(|id| format!("\"{id}\""))
3490                    .collect::<Vec<_>>()
3491                    .join(", ");
3492                format!("epics: [{ids}]\n")
3493            };
3494            let text = format!(
3495                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: todo\nlabels: []\n\
3496                 priority: medium\n{list}created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
3497            );
3498            (Issue::parse(&text).unwrap(), text)
3499        };
3500        let (i1, t1) = make(1, "Alpha work", &["e-aaa111"]);
3501        let (i2, t2) = make(2, "Beta work", &["e-bbb222"]);
3502        let (i3, t3) = make(3, "Spans both", &["e-aaa111", "e-bbb222"]);
3503        let (i4, t4) = make(4, "Homeless", &[]);
3504        let corpus = Corpus::with_texts(vec![i1, i2, i3, i4], vec![t1, t2, t3, t4]);
3505
3506        let mut config = Config::default();
3507        for (id, name) in [("e-aaa111", "Alpha"), ("e-bbb222", "Beta")] {
3508            config.epics.insert(
3509                id.to_string(),
3510                EpicConfiguration {
3511                    name: name.to_string(),
3512                    ..EpicConfiguration::default()
3513                },
3514            );
3515        }
3516        (config, corpus)
3517    }
3518
3519    /// Draw a board and return the frame as text, for the tests that read what the
3520    /// grouped board actually prints.
3521    fn epic_board_text(board: &BoardState, width: u16, height: u16) -> String {
3522        use ratatui::backend::TestBackend;
3523        let display = BoardDisplay {
3524            wrap_titles: true,
3525            title_lines: 3,
3526        };
3527        let backend = TestBackend::new(width, height);
3528        let mut terminal = ratatui::Terminal::new(backend).unwrap();
3529        terminal
3530            .draw(|f| {
3531                render(
3532                    board,
3533                    f,
3534                    f.area(),
3535                    &None,
3536                    None,
3537                    display,
3538                    &crate::ui::color::Theme::default(),
3539                    None,
3540                    None,
3541                );
3542            })
3543            .unwrap();
3544        let buffer = terminal.backend().buffer();
3545        (0..height)
3546            .map(|y| {
3547                (0..width)
3548                    .map(|x| buffer[(x, y)].symbol())
3549                    .collect::<String>()
3550            })
3551            .collect::<Vec<_>>()
3552            .join("\n")
3553    }
3554
3555    #[test]
3556    fn t030_the_board_partitions_by_epic_with_a_labelled_no_epic_bucket() {
3557        let (config, corpus) = epic_fixture();
3558        let (_, resolution) = crate::workflow::active_resolution(&config);
3559        // `sort:epic` is what the sort derivation supplies when only `group:` is
3560        // typed; naming both here pins the documented spelling.
3561        let board = BoardState::build_filtered(
3562            config.default_config(),
3563            &config,
3564            &corpus,
3565            "group:epic sort:epic",
3566            &StructuredFilter::default(),
3567            &resolution,
3568        );
3569
3570        let cards = &board.columns[0].cards;
3571        let titles: Vec<&str> = cards.iter().map(|c| c.title.as_str()).collect();
3572        assert_eq!(
3573            titles,
3574            vec!["Alpha work", "Beta work", "Spans both", "Homeless"],
3575            "declaration order, then the multi-epic bucket, then no-epic last"
3576        );
3577
3578        let sections = BoardState::group_sections(cards, Some("epic"))
3579            .expect("group:epic must produce sections");
3580        let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3581        assert_eq!(
3582            names,
3583            vec!["e-aaa111", "e-bbb222", "(multiple epics)", "(no epic)"],
3584            "one bucket per epic in declaration order, then multi, then the \
3585             labelled no-epic bucket last"
3586        );
3587
3588        // The header the renderer draws names the epic's **id**, so a user can act
3589        // on it from the board (T030 step 4).
3590        let footer = BoardState::group_footer(cards, Some("epic")).expect("a footer");
3591        assert!(footer.contains("e-aaa111 0/1 0%"), "got {footer:?}");
3592        assert!(footer.contains("(no epic) 0/1 0%"), "got {footer:?}");
3593    }
3594
3595    #[test]
3596    fn t030_an_unresolved_epic_reference_is_diagnosed_not_a_bucket() {
3597        use crate::model::Issue;
3598        use crate::search::Corpus;
3599
3600        // One issue whose only reference names an epic the registry does not hold
3601        // (`tix epic delete` removes the registry entry and retains the reference).
3602        let text = "---\nnumber: 1\ntitle: Stale\nstatus: todo\nlabels: []\npriority: medium\nepics:\n- e-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.";
3603        let issue = Issue::parse(text).unwrap();
3604        let corpus = Corpus::with_texts(vec![issue], vec![text.to_string()]);
3605        let (config, _) = epic_fixture();
3606        let (_, resolution) = crate::workflow::active_resolution(&config);
3607
3608        // The **filter** finds it: the id as stored is the handle `tix epic remove
3609        // <stale-id>` takes, so a dangling reference must stay reachable.
3610        let filter = StructuredFilter {
3611            epic: Some("e-gone".to_string()),
3612            ..StructuredFilter::default()
3613        };
3614        let board = BoardState::build_filtered(
3615            config.default_config(),
3616            &config,
3617            &corpus,
3618            "",
3619            &filter,
3620            &resolution,
3621        );
3622        assert_eq!(
3623            board.columns[0].cards.len(),
3624            1,
3625            "a stored reference to a deleted epic must still be findable"
3626        );
3627
3628        // The **partition** does not invent a bucket for it: this issue is in no
3629        // *epic*, so it lands in the labelled no-epic bucket.
3630        let board = BoardState::build_filtered(
3631            config.default_config(),
3632            &config,
3633            &corpus,
3634            "group:epic sort:epic",
3635            &StructuredFilter::default(),
3636            &resolution,
3637        );
3638        assert_eq!(
3639            board.columns[0].cards[0].epics,
3640            [] as [std::string::String; 0]
3641        );
3642        let sections = BoardState::group_sections(&board.columns[0].cards, Some("epic"))
3643            .expect("group:epic must produce sections");
3644        let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3645        assert_eq!(
3646            names,
3647            vec!["(no epic)"],
3648            "an unresolvable id is not a bucket"
3649        );
3650    }
3651
3652    #[test]
3653    fn t030_the_epic_filter_narrows_the_board_to_the_members() {
3654        let (config, corpus) = epic_fixture();
3655        let (_, resolution) = crate::workflow::active_resolution(&config);
3656        let members = |id: &str| {
3657            let filter = StructuredFilter {
3658                epic: Some(id.to_string()),
3659                ..StructuredFilter::default()
3660            };
3661            let board = BoardState::build_filtered(
3662                config.default_config(),
3663                &config,
3664                &corpus,
3665                "",
3666                &filter,
3667                &resolution,
3668            );
3669            let mut titles: Vec<String> = board.columns[0]
3670                .cards
3671                .iter()
3672                .map(|c| c.title.clone())
3673                .collect();
3674            titles.sort();
3675            titles
3676        };
3677        assert_eq!(
3678            members("e-aaa111"),
3679            vec!["Alpha work", "Spans both"],
3680            "a member matches and an issue in two epics matches both"
3681        );
3682        assert_eq!(members("e-bbb222"), vec!["Beta work", "Spans both"]);
3683        // An id no reference carries matches nothing, and neither does one the
3684        // registry does not know.
3685        for unknown in ["e-ccc333", "e-does-not-exist"] {
3686            let filter = StructuredFilter {
3687                epic: Some(unknown.to_string()),
3688                ..StructuredFilter::default()
3689            };
3690            let board = BoardState::build_filtered(
3691                config.default_config(),
3692                &config,
3693                &corpus,
3694                "",
3695                &filter,
3696                &resolution,
3697            );
3698            assert!(board.is_empty(), "epic:{unknown} must match nothing");
3699        }
3700    }
3701
3702    #[test]
3703    fn t030_a_grouped_board_still_opens_the_member_issue_directly() {
3704        // FR-004: the epic is the section label, never a layer between the cursor
3705        // and the issue. The selected card is a real issue, and the drawn board
3706        // states the direct route rather than implying it.
3707        let (config, corpus) = epic_fixture();
3708        let (_, resolution) = crate::workflow::active_resolution(&config);
3709        let mut board = BoardState::build_filtered(
3710            config.default_config(),
3711            &config,
3712            &corpus,
3713            "group:epic sort:epic",
3714            &StructuredFilter::default(),
3715            &resolution,
3716        );
3717        board.selected = 2;
3718        let selected = board.get_selected_card().expect("a card under the cursor");
3719        assert_eq!(
3720            selected.number, 3,
3721            "the cursor addresses the issue itself, not an epic"
3722        );
3723        let text = epic_board_text(&board, 140, 40);
3724        assert!(text.contains("-- epic: e-aaa111"), "got {text:?}");
3725        let footer = BoardState::group_footer(&board.columns[0].cards, Some("epic"))
3726            .expect("a grouped board has a footer");
3727        assert!(
3728            footer.contains(GROUP_OPEN_AFFORDANCE),
3729            "the grouped board advertises the direct issue route: {footer:?}"
3730        );
3731    }
3732
3733    #[test]
3734    fn t028_the_board_partitions_by_project_with_a_labelled_no_project_bucket() {
3735        let (config, corpus) = project_fixture();
3736        let (_, resolution) = crate::workflow::active_resolution(&config);
3737        // `sort:project` is what the sort derivation supplies when only `group:` is
3738        // typed; naming both here pins the documented spelling.
3739        let board = BoardState::build_filtered(
3740            config.default_config(),
3741            &config,
3742            &corpus,
3743            "group:project sort:project",
3744            &StructuredFilter::default(),
3745            &resolution,
3746        );
3747
3748        let cards = &board.columns[0].cards;
3749        let titles: Vec<&str> = cards.iter().map(|c| c.title.as_str()).collect();
3750        assert_eq!(
3751            titles,
3752            vec!["Platform work", "Infra work", "Spans both", "Homeless"],
3753            "declaration order, then the multi-project bucket, then no-project last"
3754        );
3755
3756        let sections = BoardState::group_sections(cards, Some("project"))
3757            .expect("group:project must produce sections");
3758        let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3759        assert_eq!(
3760            names,
3761            vec!["p-1", "p-2", "(multiple projects)", "(no project)"],
3762            "one bucket per project in declaration order, then multi, then the \
3763             labelled no-project bucket last"
3764        );
3765
3766        // The header the renderer draws names the bucket, so the id is actionable
3767        // from the board (T028 step 4).
3768        let footer = BoardState::group_footer(cards, Some("project")).expect("a footer");
3769        assert!(footer.contains("p-1 0/1 0%"), "got {footer:?}");
3770        assert!(footer.contains("(no project) 0/1 0%"), "got {footer:?}");
3771    }
3772
3773    #[test]
3774    fn t028_an_unresolved_association_is_diagnosed_not_a_bucket() {
3775        use crate::model::Issue;
3776        use crate::search::Corpus;
3777
3778        // One issue whose only association names a project the registry does not hold
3779        // (`tix project delete` removes the registry entry and retains the reference).
3780        let text = "---\nnumber: 1\ntitle: Stale\nstatus: todo\nlabels: []\npriority: medium\nprojects:\n- p-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.";
3781        let issue = Issue::parse(text).unwrap();
3782        let corpus = Corpus::with_texts(vec![issue], vec![text.to_string()]);
3783        let (config, _) = project_fixture();
3784        let (_, resolution) = crate::workflow::active_resolution(&config);
3785
3786        // The **filter** finds it: the id as stored is the handle
3787        // `tix project remove <stale-id>` takes, so a dangling reference must stay
3788        // reachable (WP06 review item 4).
3789        let filter = StructuredFilter {
3790            project: Some("p-gone".to_string()),
3791            ..StructuredFilter::default()
3792        };
3793        let board = BoardState::build_filtered(
3794            config.default_config(),
3795            &config,
3796            &corpus,
3797            "",
3798            &filter,
3799            &resolution,
3800        );
3801        assert_eq!(
3802            board.columns[0].cards.len(),
3803            1,
3804            "a stored reference to a deleted project must still be findable"
3805        );
3806
3807        // The **partition** does not invent a bucket for it: this issue is in no
3808        // *project*, so it lands in the labelled no-project bucket, and the card
3809        // carries no resolved id.
3810        let board = BoardState::build_filtered(
3811            config.default_config(),
3812            &config,
3813            &corpus,
3814            "group:project sort:project",
3815            &StructuredFilter::default(),
3816            &resolution,
3817        );
3818        assert_eq!(
3819            board.columns[0].cards[0].projects,
3820            [] as [std::string::String; 0]
3821        );
3822        let sections = BoardState::group_sections(&board.columns[0].cards, Some("project"))
3823            .expect("group:project must produce sections");
3824        let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3825        assert_eq!(
3826            names,
3827            vec!["(no project)"],
3828            "an unresolvable id is diagnosed, not a bucket"
3829        );
3830    }
3831
3832    #[test]
3833    fn t028_the_project_filter_narrows_the_board_to_the_members() {
3834        let (config, corpus) = project_fixture();
3835        let (_, resolution) = crate::workflow::active_resolution(&config);
3836        let filter = StructuredFilter {
3837            project: Some("p-1".to_string()),
3838            ..StructuredFilter::default()
3839        };
3840        let board = BoardState::build_filtered(
3841            config.default_config(),
3842            &config,
3843            &corpus,
3844            "",
3845            &filter,
3846            &resolution,
3847        );
3848        let titles: Vec<&str> = board.columns[0]
3849            .cards
3850            .iter()
3851            .map(|c| c.title.as_str())
3852            .collect();
3853        assert_eq!(
3854            titles,
3855            vec!["Platform work", "Spans both"],
3856            "a member matches and an issue in two projects matches both — so the \
3857             multi-project issue is visible under either project"
3858        );
3859
3860        // The same criterion set on the other project returns the other member.
3861        let other = StructuredFilter {
3862            project: Some("p-2".to_string()),
3863            ..StructuredFilter::default()
3864        };
3865        let board = BoardState::build_filtered(
3866            config.default_config(),
3867            &config,
3868            &corpus,
3869            "",
3870            &other,
3871            &resolution,
3872        );
3873        let titles: Vec<&str> = board.columns[0]
3874            .cards
3875            .iter()
3876            .map(|c| c.title.as_str())
3877            .collect();
3878        assert_eq!(titles, vec!["Infra work", "Spans both"]);
3879
3880        // An id no association carries matches nothing, and neither does one the
3881        // registry does not know.
3882        for unknown in ["p-3", "p-does-not-exist"] {
3883            let filter = StructuredFilter {
3884                project: Some(unknown.to_string()),
3885                ..StructuredFilter::default()
3886            };
3887            let board = BoardState::build_filtered(
3888                config.default_config(),
3889                &config,
3890                &corpus,
3891                "",
3892                &filter,
3893                &resolution,
3894            );
3895            assert!(
3896                board.is_empty(),
3897                "project:{unknown} must match nothing, got cards"
3898            );
3899        }
3900    }
3901
3902    #[test]
3903    fn group_footer_reports_done_progress() {
3904        let cards = vec![
3905            Card {
3906                health: None,
3907                number: 1,
3908                title: "A".into(),
3909                priority: crate::model::Priority::Medium,
3910                labels: vec![],
3911                assignee: None,
3912                filename: "a.md".into(),
3913                commit: None,
3914                created: "2026-01-01".into(),
3915                due_date: None,
3916                status: "todo".into(),
3917                category: None,
3918                category_color: None,
3919                done: false,
3920                cycle: Some("Cycle 1".into()),
3921                milestone: None,
3922                blocked: None,
3923                children: None,
3924                parent: None,
3925                depth: 0,
3926                archived: false,
3927                related: 0,
3928                projects: vec![],
3929                epics: Vec::new(),
3930            },
3931            Card {
3932                health: None,
3933                number: 2,
3934                title: "B".into(),
3935                priority: crate::model::Priority::Medium,
3936                labels: vec![],
3937                assignee: None,
3938                filename: "b.md".into(),
3939                commit: None,
3940                created: "2026-01-01".into(),
3941                due_date: None,
3942                status: "done".into(),
3943                category: None,
3944                category_color: None,
3945                done: true,
3946                cycle: Some("Cycle 1".into()),
3947                milestone: None,
3948                blocked: None,
3949                children: None,
3950                parent: None,
3951                depth: 0,
3952                archived: false,
3953                related: 0,
3954                projects: vec![],
3955                epics: Vec::new(),
3956            },
3957        ];
3958        let text = BoardState::group_footer(&cards, Some("cycle")).unwrap();
3959        assert!(text.contains("Cycle 1 1/2 50%"));
3960    }
3961
3962    #[test]
3963    fn build_filtered_bare_number_matches_exact_issue_only() {
3964        use crate::model::Issue;
3965        use crate::search::Corpus;
3966
3967        let to_issue = |number: u32, title: &str, status: &str| Issue {
3968            projects: None,
3969            id: None,
3970            number,
3971            title: title.to_string(),
3972            status: crate::model::Status::new(status),
3973            labels: vec![],
3974            priority: crate::model::Priority::Medium,
3975            assignee: None,
3976            estimate: None,
3977            commit: None,
3978            gitlab_id: None,
3979            linear_id: None,
3980            mission: None,
3981            task: None,
3982            relationships: vec![],
3983            related_issues: vec![],
3984            files: vec![],
3985            cycles: None,
3986            milestones: None,
3987            reviewer: None,
3988            start_date: None,
3989            due_date: None,
3990            created: "2026-01-01".into(),
3991            type_: None,
3992            updated: "2026-01-01".into(),
3993            filename: format!("2026-01-01-{number}.md"),
3994            body: String::new(),
3995            history: crate::model::History::new(),
3996            has_retired_links_key: false,
3997            time_entries: vec![],
3998            parent: None,
3999            series: None,
4000            health: None,
4001            epics: None,
4002            extra_frontmatter: std::collections::BTreeMap::new(),
4003            archived: false,
4004            entered_at: None,
4005            source: None,
4006        };
4007        let corpus = Corpus::with_texts(
4008            vec![
4009                to_issue(55, "Concrete target", "todo"),
4010                to_issue(155, "Digit-rich title", "todo"),
4011                to_issue(3, "Login bug", "todo"),
4012            ],
4013            vec![
4014                "Concrete target".to_string(),
4015                "Digit-rich title".to_string(),
4016                "Login bug".to_string(),
4017            ],
4018        );
4019        let config = Config::default();
4020        let (_, resolution) = crate::workflow::active_resolution(&config);
4021        let board_config = config.default_config();
4022
4023        let exact = BoardState::build_filtered(
4024            board_config,
4025            &config,
4026            &corpus,
4027            "55",
4028            &StructuredFilter::default(),
4029            &resolution,
4030        );
4031        assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
4032        assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
4033        assert_eq!(
4034            exact.column_cards(0)[0].number,
4035            55,
4036            "issue 155's digit sequence must not qualify"
4037        );
4038        assert_eq!(exact.filter.as_deref(), Some("55"));
4039
4040        // Number miss -> empty board.
4041        let miss = BoardState::build_filtered(
4042            board_config,
4043            &config,
4044            &corpus,
4045            "999",
4046            &StructuredFilter::default(),
4047            &resolution,
4048        );
4049        assert!(miss.is_empty(), "no issue 999 => zero matches");
4050
4051        // Non-numeric query keeps fuzzy behavior (login finds issue 3 only).
4052        let fuzzy = BoardState::build_filtered(
4053            board_config,
4054            &config,
4055            &corpus,
4056            "login",
4057            &StructuredFilter::default(),
4058            &resolution,
4059        );
4060        assert_eq!(fuzzy.column_cards(0).len(), 1);
4061        assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
4062    }
4063
4064    #[test]
4065    fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
4066        use crate::model::Issue;
4067        use crate::search::Corpus;
4068
4069        let to_issue = |number: u32, title: &str| Issue {
4070            projects: None,
4071            id: None,
4072            number,
4073            title: title.to_string(),
4074            status: crate::model::Status::new("todo"),
4075            labels: vec![],
4076            priority: crate::model::Priority::Medium,
4077            assignee: None,
4078            estimate: None,
4079            commit: None,
4080            gitlab_id: None,
4081            linear_id: None,
4082            mission: None,
4083            task: None,
4084            relationships: vec![],
4085            related_issues: vec![],
4086            files: vec![],
4087            cycles: None,
4088            milestones: None,
4089            reviewer: None,
4090            start_date: None,
4091            due_date: None,
4092            created: "2026-01-01".into(),
4093            type_: None,
4094            updated: "2026-01-01".into(),
4095            filename: format!("2026-01-01-{number}.md"),
4096            body: String::new(),
4097            history: crate::model::History::new(),
4098            has_retired_links_key: false,
4099            time_entries: vec![],
4100            parent: None,
4101            series: None,
4102            health: None,
4103            epics: None,
4104            extra_frontmatter: std::collections::BTreeMap::new(),
4105            archived: false,
4106            entered_at: None,
4107            source: None,
4108        };
4109        let corpus = Corpus::with_texts(
4110            vec![
4111                to_issue(55, "Concrete target"),
4112                to_issue(155, "Digit-rich title"),
4113            ],
4114            vec![
4115                "Concrete target".to_string(),
4116                "Digit-rich title".to_string(),
4117            ],
4118        );
4119        let config = Config::default();
4120        let (_, resolution) = crate::workflow::active_resolution(&config);
4121        let board_config = config.default_config();
4122
4123        // "55bug" is not a bare number: fuzzy path applies (no such text, so
4124        // zero matches rather than exact issue 55).
4125        let mixed = BoardState::build_filtered(
4126            board_config,
4127            &config,
4128            &corpus,
4129            "55bug",
4130            &StructuredFilter::default(),
4131            &resolution,
4132        );
4133        assert!(mixed.is_empty(), "mixed query is not an exact-number query");
4134    }
4135
4136    #[test]
4137    fn build_filtered_number_composes_with_structured_filter_and() {
4138        use crate::model::Issue;
4139        use crate::search::Corpus;
4140
4141        let cases = [
4142            (55u32, "Issue 55 todo", "todo"),
4143            (156u32, "Issue 156 todo", "todo"),
4144            (57u32, "Issue 57 done", "done"),
4145        ];
4146        let corpus = Corpus::with_texts(
4147            cases
4148                .iter()
4149                .map(|&(number, title, status)| Issue {
4150                    projects: None,
4151                    id: None,
4152                    number,
4153                    title: title.to_string(),
4154                    status: crate::model::Status::new(status),
4155                    labels: vec![],
4156                    priority: crate::model::Priority::Medium,
4157                    assignee: None,
4158                    estimate: None,
4159                    commit: None,
4160                    gitlab_id: None,
4161                    linear_id: None,
4162                    mission: None,
4163                    task: None,
4164                    relationships: vec![],
4165                    related_issues: vec![],
4166                    files: vec![],
4167                    cycles: None,
4168                    milestones: None,
4169                    reviewer: None,
4170                    start_date: None,
4171                    due_date: None,
4172                    created: "2026-01-01".into(),
4173                    type_: None,
4174                    updated: "2026-01-01".into(),
4175                    filename: format!("2026-01-01-{}.md", number),
4176                    body: String::new(),
4177                    history: crate::model::History::new(),
4178                    has_retired_links_key: false,
4179                    time_entries: vec![],
4180                    parent: None,
4181                    series: None,
4182                    health: None,
4183                    epics: None,
4184                    extra_frontmatter: std::collections::BTreeMap::new(),
4185                    archived: false,
4186                    entered_at: None,
4187                    source: None,
4188                })
4189                .collect(),
4190            cases
4191                .iter()
4192                .map(|(_, title, _)| title.to_string())
4193                .collect(),
4194        );
4195        let config = Config::default();
4196        let (_, resolution) = crate::workflow::active_resolution(&config);
4197        let board_config = config.default_config();
4198        let status_todo = StructuredFilter {
4199            status: Some("todo".to_string()),
4200            ..Default::default()
4201        };
4202        let status_done = StructuredFilter {
4203            status: Some("done".to_string()),
4204            ..Default::default()
4205        };
4206
4207        // AND holds on the positive side: 55 must match number AND todo;
4208        // 156 matches the criteria but is dropped by the number gate.
4209        let matches = BoardState::build_filtered(
4210            board_config,
4211            &config,
4212            &corpus,
4213            "55",
4214            &status_todo,
4215            &resolution,
4216        );
4217        assert_eq!(matches.column_cards(0).len(), 1);
4218        assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
4219
4220        // Requirement scenario: number matches but the criterion fails
4221        // (issue 55 is "todo" here) => board shows nothing.
4222        let done_criteria = BoardState::build_filtered(
4223            board_config,
4224            &config,
4225            &corpus,
4226            "55",
4227            &status_done,
4228            &resolution,
4229        );
4230        assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
4231    }
4232
4233    #[test]
4234    fn is_empty_true_only_with_no_cards_anywhere() {
4235        let board = BoardState {
4236            columns: vec![BoardColumn {
4237                name: "a".into(),
4238                cards: vec![],
4239            }],
4240            mismatch: BoardColumn {
4241                name: "Mismatched".into(),
4242                cards: vec![],
4243            },
4244            active_col: 0,
4245            selected: 0,
4246            filter: None,
4247            hover: None,
4248            hover_col: None,
4249            hidden: vec![],
4250            show_mismatch: true,
4251        };
4252        assert!(board.is_empty());
4253
4254        let populated = BoardState {
4255            columns: vec![BoardColumn {
4256                name: "a".into(),
4257                cards: vec![Card {
4258                    health: None,
4259                    number: 1,
4260                    title: "X".into(),
4261                    priority: crate::model::Priority::Medium,
4262                    labels: vec![],
4263                    assignee: None,
4264                    filename: "x.md".into(),
4265                    commit: None,
4266                    created: "2026-01-01".into(),
4267                    due_date: None,
4268                    status: "todo".into(),
4269                    category: None,
4270                    category_color: None,
4271                    done: false,
4272                    cycle: None,
4273                    milestone: None,
4274                    blocked: None,
4275                    children: None,
4276                    parent: None,
4277                    depth: 0,
4278                    archived: false,
4279                    related: 0,
4280                    projects: vec![],
4281                    epics: Vec::new(),
4282                }],
4283            }],
4284            mismatch: BoardColumn {
4285                name: "Mismatched".into(),
4286                cards: vec![],
4287            },
4288            active_col: 0,
4289            selected: 0,
4290            filter: None,
4291            hover: None,
4292            hover_col: None,
4293            hidden: vec![],
4294            show_mismatch: true,
4295        };
4296        assert!(!populated.is_empty());
4297    }
4298
4299    #[test]
4300    fn build_splits_hidden_columns_out_of_visible_surface() {
4301        use crate::config::{BoardConfiguration, Config};
4302
4303        let mut config = Config::default();
4304        config.kanban.configurations.insert(
4305            "default".to_string(),
4306            BoardConfiguration {
4307                columns: vec!["todo".into(), "done".into()],
4308                hidden_columns: vec!["done".into()],
4309                ..BoardConfiguration::default()
4310            },
4311        );
4312        let store = crate::storage::Store::open(&config).unwrap();
4313        let board = BoardState::build(&store, config.default_config());
4314        assert_eq!(board.columns.len(), 1);
4315        assert_eq!(board.columns[0].name, "todo");
4316        assert_eq!(board.hidden.len(), 1);
4317        assert_eq!(board.hidden[0].name, "done");
4318        assert_eq!(board.total_cols(), 1);
4319    }
4320
4321    #[test]
4322    fn move_left_skips_hidden_column() {
4323        let mut board = BoardState {
4324            columns: vec![BoardColumn {
4325                name: "visible-a".into(),
4326                cards: vec![],
4327            }],
4328            hidden: vec![BoardColumn {
4329                name: "hidden-b".into(),
4330                cards: vec![],
4331            }],
4332            mismatch: BoardColumn {
4333                name: "Mismatched".into(),
4334                cards: vec![],
4335            },
4336            active_col: 0,
4337            selected: 0,
4338            filter: None,
4339            hover: None,
4340            hover_col: None,
4341            show_mismatch: true,
4342        };
4343        board.move_left();
4344        assert_eq!(board.active_col, 0, "single visible column wraps to itself");
4345    }
4346
4347    #[test]
4348    fn move_right_skips_hidden_column() {
4349        let mut board = BoardState {
4350            columns: vec![BoardColumn {
4351                name: "visible-a".into(),
4352                cards: vec![],
4353            }],
4354            hidden: vec![BoardColumn {
4355                name: "hidden-b".into(),
4356                cards: vec![],
4357            }],
4358            mismatch: BoardColumn {
4359                name: "Mismatched".into(),
4360                cards: vec![],
4361            },
4362            active_col: 0,
4363            selected: 0,
4364            filter: None,
4365            hover: None,
4366            hover_col: None,
4367            show_mismatch: true,
4368        };
4369        board.move_right();
4370        assert_eq!(board.active_col, 0, "single visible column wraps to itself");
4371    }
4372
4373    #[test]
4374    fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
4375        let mut board = BoardState {
4376            columns: vec![],
4377            hidden: vec![BoardColumn {
4378                name: "only-hidden".into(),
4379                cards: vec![],
4380            }],
4381            mismatch: BoardColumn {
4382                name: "Mismatched".into(),
4383                cards: vec![],
4384            },
4385            active_col: 0,
4386            selected: 0,
4387            filter: None,
4388            hover: None,
4389            hover_col: None,
4390            show_mismatch: false,
4391        };
4392        assert_eq!(board.total_cols(), 0);
4393        board.move_left();
4394        board.move_right();
4395        assert_eq!(board.active_col, 0, "zero-column board must not panic");
4396        let board2 = BoardState {
4397            columns: vec![],
4398            hidden: vec![],
4399            mismatch: BoardColumn {
4400                name: "Mismatched".into(),
4401                cards: vec![],
4402            },
4403            active_col: 0,
4404            selected: 0,
4405            filter: None,
4406            hover: None,
4407            hover_col: None,
4408            show_mismatch: false,
4409        };
4410        assert_eq!(board2.total_cols(), 0);
4411    }
4412
4413    fn write_issue_md(
4414        dir: &std::path::Path,
4415        name: &str,
4416        number: u32,
4417        title: &str,
4418        status: &str,
4419        created: &str,
4420    ) {
4421        let contents = format!(
4422            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
4423        );
4424        std::fs::write(dir.join(name), contents).unwrap();
4425    }
4426
4427    #[test]
4428    fn order_by_sort_desc_asc_none() {
4429        let mk = |created: &str| Card {
4430            health: None,
4431            number: 1,
4432            title: created.to_string(),
4433            priority: crate::model::Priority::Medium,
4434            labels: vec![],
4435            assignee: None,
4436            filename: "x.md".into(),
4437            commit: None,
4438            created: created.to_string(),
4439            due_date: None,
4440            status: "todo".into(),
4441            category: None,
4442            category_color: None,
4443            done: false,
4444            cycle: None,
4445            milestone: None,
4446            blocked: None,
4447            children: None,
4448            parent: None,
4449            depth: 0,
4450            archived: false,
4451            related: 0,
4452            projects: vec![],
4453            epics: Vec::new(),
4454        };
4455        let dates =
4456            |cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
4457
4458        let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
4459        crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
4460        assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
4461
4462        let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
4463        crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
4464        assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
4465
4466        let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
4467        crate::config::OrderBy::None.sort(&mut cards);
4468        assert_eq!(
4469            dates(&cards),
4470            ["2026-03-01", "2026-01-01"],
4471            "none is a no-op"
4472        );
4473    }
4474
4475    #[test]
4476    fn build_orders_most_recent_first_by_default() {
4477        use crate::config::Config;
4478        use std::path::PathBuf;
4479        let tmp = tempfile::TempDir::new().unwrap();
4480        let dir = tmp.path().join("issues");
4481        std::fs::create_dir_all(&dir).unwrap();
4482        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4483        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4484        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4485        let config = Config {
4486            issues_dir: PathBuf::from(&dir),
4487            ..Config::default()
4488        };
4489        let store = crate::storage::Store::open(&config).unwrap();
4490        let board = BoardState::build(&store, config.default_config());
4491        let titles: Vec<&str> = board
4492            .column_cards(0)
4493            .iter()
4494            .map(|c| c.title.as_str())
4495            .collect();
4496        assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
4497    }
4498
4499    #[test]
4500    fn build_applies_created_at_asc_and_none() {
4501        use crate::config::OrderBy;
4502        use std::path::PathBuf;
4503        let tmp = tempfile::TempDir::new().unwrap();
4504        let dir = tmp.path().join("issues");
4505        std::fs::create_dir_all(&dir).unwrap();
4506        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4507        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4508        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4509        let config = crate::config::Config {
4510            issues_dir: PathBuf::from(&dir),
4511            ..crate::config::Config::default()
4512        };
4513        let store = crate::storage::Store::open(&config).unwrap();
4514
4515        let mut cfg_asc = config.clone();
4516        cfg_asc
4517            .kanban
4518            .configurations
4519            .get_mut("default")
4520            .unwrap()
4521            .order_by = OrderBy::CreatedAtAsc;
4522        let board_asc = BoardState::build(&store, cfg_asc.default_config());
4523        let titles: Vec<&str> = board_asc
4524            .column_cards(0)
4525            .iter()
4526            .map(|c| c.title.as_str())
4527            .collect();
4528        assert_eq!(titles, ["old", "mid", "new"]);
4529
4530        let mut cfg_none = config;
4531        cfg_none
4532            .kanban
4533            .configurations
4534            .get_mut("default")
4535            .unwrap()
4536            .order_by = OrderBy::None;
4537        let board_none = BoardState::build(&store, cfg_none.default_config());
4538        let titles: Vec<&str> = board_none
4539            .column_cards(0)
4540            .iter()
4541            .map(|c| c.title.as_str())
4542            .collect();
4543        assert_eq!(
4544            titles,
4545            ["old", "mid", "new"],
4546            "none keeps the store's filename-ascending order"
4547        );
4548    }
4549
4550    #[test]
4551    fn build_applies_sparse_manual_card_order_when_present() {
4552        use crate::config::Config;
4553        use std::path::PathBuf;
4554        let tmp = tempfile::TempDir::new().unwrap();
4555        let dir = tmp.path().join("issues");
4556        std::fs::create_dir_all(&dir).unwrap();
4557        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4558        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4559        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4560        let config = Config {
4561            issues_dir: PathBuf::from(&dir),
4562            ..Config::default()
4563        };
4564        let store = crate::storage::Store::open(&config).unwrap();
4565        let mut board_cfg = config.default_config().clone();
4566        board_cfg.card_order = vec!["2026-02-01-mid.md".into(), "2026-03-01-new.md".into()];
4567        let board = BoardState::build(&store, &board_cfg);
4568        let titles: Vec<&str> = board
4569            .column_cards(0)
4570            .iter()
4571            .map(|c| c.title.as_str())
4572            .collect();
4573        assert_eq!(titles, ["mid", "new", "old"], "manual order wins");
4574    }
4575
4576    #[test]
4577    fn sparse_card_order_only_returns_changed_cards() {
4578        use crate::config::Config;
4579        use std::path::PathBuf;
4580
4581        let tmp = tempfile::TempDir::new().unwrap();
4582        let dir = tmp.path().join("issues");
4583        std::fs::create_dir_all(&dir).unwrap();
4584        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4585        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4586        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4587        let config = Config {
4588            issues_dir: PathBuf::from(&dir),
4589            ..Config::default()
4590        };
4591        let store = crate::storage::Store::open(&config).unwrap();
4592        let board_cfg = config.default_config().clone();
4593        let mut cards = BoardState::build(&store, &board_cfg)
4594            .column_cards(0)
4595            .to_vec();
4596        cards.swap(0, 1);
4597        let sparse = sparse_card_order(&board_cfg, &cards);
4598        assert_eq!(sparse, ["2026-02-01-mid.md", "2026-03-01-new.md"]);
4599    }
4600
4601    #[test]
4602    fn build_filtered_applies_order_by() {
4603        use crate::model::Issue;
4604        use crate::search::{Corpus, StructuredFilter};
4605
4606        let to_issue = |number: u32, title: &str, created: &str| Issue {
4607            projects: None,
4608            id: None,
4609            number,
4610            title: title.to_string(),
4611            status: crate::model::Status::new("todo"),
4612            labels: vec![],
4613            priority: crate::model::Priority::Medium,
4614            assignee: None,
4615            estimate: None,
4616            commit: None,
4617            gitlab_id: None,
4618            linear_id: None,
4619            mission: None,
4620            task: None,
4621            relationships: vec![],
4622            related_issues: vec![],
4623            files: vec![],
4624            cycles: None,
4625            milestones: None,
4626            reviewer: None,
4627            start_date: None,
4628            due_date: None,
4629            created: created.into(),
4630            type_: None,
4631            updated: "2026-01-01".into(),
4632            filename: format!("2026-01-01-{number}.md"),
4633            body: String::new(),
4634            history: crate::model::History::new(),
4635            has_retired_links_key: false,
4636            time_entries: vec![],
4637            parent: None,
4638            series: None,
4639            health: None,
4640            epics: None,
4641            extra_frontmatter: std::collections::BTreeMap::new(),
4642            archived: false,
4643            entered_at: None,
4644            source: None,
4645        };
4646        let corpus = Corpus::with_texts(
4647            vec![
4648                to_issue(1, "old", "2026-01-01"),
4649                to_issue(2, "new", "2026-03-01"),
4650                to_issue(3, "mid", "2026-02-01"),
4651            ],
4652            vec!["old".into(), "new".into(), "mid".into()],
4653        );
4654        let config = crate::config::Config::default();
4655        let (_, resolution) = crate::workflow::active_resolution(&config);
4656        let board = BoardState::build_filtered(
4657            config.default_config(),
4658            &config,
4659            &corpus,
4660            "",
4661            &StructuredFilter::default(),
4662            &resolution,
4663        );
4664        let titles: Vec<&str> = board
4665            .column_cards(0)
4666            .iter()
4667            .map(|c| c.title.as_str())
4668            .collect();
4669        assert_eq!(
4670            titles,
4671            ["new", "mid", "old"],
4672            "default desc applies to filtered results too"
4673        );
4674    }
4675
4676    /// A card for the tree-ordering tests: only the fields the ordering and the
4677    /// renderer read are spelled out.
4678    fn tree_card(number: u32, parent: Option<u32>) -> Card {
4679        Card {
4680            health: None,
4681            number,
4682            title: format!("Issue {number}"),
4683            priority: Priority::Medium,
4684            labels: vec![],
4685            assignee: None,
4686            filename: format!("{number}.md"),
4687            commit: None,
4688            created: "2026-01-01".into(),
4689            due_date: None,
4690            status: "todo".into(),
4691            category: None,
4692            category_color: None,
4693            done: false,
4694            cycle: None,
4695            milestone: None,
4696            blocked: None,
4697            children: None,
4698            parent,
4699            depth: 0,
4700            archived: false,
4701            related: 0,
4702            projects: vec![],
4703            epics: Vec::new(),
4704        }
4705    }
4706
4707    #[test]
4708    fn nest_children_places_a_child_after_its_parent() {
4709        let mut cards = vec![
4710            tree_card(2, Some(1)),
4711            tree_card(1, None),
4712            tree_card(3, None),
4713        ];
4714        nest_children(&mut cards);
4715        let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4716        assert_eq!(
4717            shape,
4718            vec![(1, 0), (2, 1), (3, 0)],
4719            "the child moves up under its parent and is marked depth 1"
4720        );
4721    }
4722
4723    #[test]
4724    fn nest_children_keeps_adopted_siblings_in_their_existing_order() {
4725        let mut cards = vec![
4726            tree_card(1, None),
4727            tree_card(3, Some(1)),
4728            tree_card(2, Some(1)),
4729        ];
4730        nest_children(&mut cards);
4731        let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4732        assert_eq!(shape, vec![(1, 0), (3, 1), (2, 1)]);
4733    }
4734
4735    #[test]
4736    fn nest_children_does_not_nest_a_grandchild() {
4737        // #2 is a child of #1; #3 is a child of #2. Only one level is shown, so #3
4738        // stays top level instead of stacking under the child.
4739        let mut cards = vec![
4740            tree_card(1, None),
4741            tree_card(2, Some(1)),
4742            tree_card(3, Some(2)),
4743        ];
4744        nest_children(&mut cards);
4745        let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4746        assert_eq!(shape, vec![(1, 0), (2, 1), (3, 0)]);
4747    }
4748
4749    #[test]
4750    fn nest_children_leaves_a_child_whose_parent_is_in_another_column() {
4751        // #1 is not in this column, so #2 keeps its position and `depth`.
4752        let mut cards = vec![tree_card(2, Some(1)), tree_card(5, None)];
4753        nest_children(&mut cards);
4754        let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4755        assert_eq!(shape, vec![(2, 0), (5, 0)]);
4756    }
4757
4758    #[test]
4759    fn nest_children_keeps_every_card_exactly_once() {
4760        let mut cards = vec![
4761            tree_card(4, None),
4762            tree_card(3, Some(1)),
4763            tree_card(1, None),
4764            tree_card(2, Some(1)),
4765        ];
4766        nest_children(&mut cards);
4767        let mut numbers: Vec<u32> = cards.iter().map(|c| c.number).collect();
4768        numbers.sort_unstable();
4769        assert_eq!(
4770            numbers,
4771            vec![1, 2, 3, 4],
4772            "no card is duplicated or dropped"
4773        );
4774    }
4775
4776    #[test]
4777    fn render_indents_a_sub_issue_without_changing_its_line_count() {
4778        use ratatui::Terminal;
4779        use ratatui::backend::TestBackend;
4780
4781        let width = 80u16;
4782        let height = 30u16;
4783        let mut child = tree_card(2, Some(1));
4784        child.title = "a sub-issue whose deliberately long title wraps across more \
4785                       than a single rendered row inside the column"
4786            .into();
4787        child.depth = 1;
4788        let parent = tree_card(1, None);
4789
4790        let mut flat_child = child.clone();
4791        flat_child.depth = 0;
4792        assert_eq!(
4793            widgets::card_line_count(&child, width as usize, true, 3),
4794            widgets::card_line_count(&flat_child, width as usize, true, 3),
4795            "the indent must not change the card's rendered line count"
4796        );
4797
4798        let board = BoardState {
4799            columns: vec![BoardColumn {
4800                name: "todo".into(),
4801                cards: vec![parent, child],
4802            }],
4803            hidden: vec![],
4804            mismatch: BoardColumn {
4805                name: "Mismatched".into(),
4806                cards: vec![],
4807            },
4808            active_col: 0,
4809            selected: 0,
4810            filter: None,
4811            hover: None,
4812            hover_col: None,
4813            show_mismatch: false,
4814        };
4815        let display = BoardDisplay {
4816            wrap_titles: true,
4817            title_lines: 3,
4818        };
4819        let backend = TestBackend::new(width, height);
4820        let mut terminal = Terminal::new(backend).unwrap();
4821        terminal
4822            .draw(|f| {
4823                render(
4824                    &board,
4825                    f,
4826                    f.area(),
4827                    &None,
4828                    None,
4829                    display,
4830                    &crate::ui::color::Theme::default(),
4831                    None,
4832                    None,
4833                );
4834            })
4835            .unwrap();
4836        let buffer = terminal.backend().buffer();
4837        let row = |y: u16| -> String { (0..width).map(|x| buffer[(x, y)].symbol()).collect() };
4838
4839        let symbol = |x: u16, y: u16| buffer[(x, y)].symbol().to_string();
4840        assert!(
4841            row(1).contains("#1"),
4842            "the parent draws first: {:?}",
4843            row(1)
4844        );
4845        assert_eq!(
4846            symbol(1, 1),
4847            "#",
4848            "a top-level card is flush with the column border: {:?}",
4849            row(1)
4850        );
4851        assert_eq!(
4852            (symbol(1, 2), symbol(2, 2)),
4853            (" ".to_string(), "2".to_string()),
4854            "the sub-issue draws a one-character ASCII whitespace margin and drops \
4855             the `#` (tix #1536): {:?}",
4856            row(2)
4857        );
4858        // The indent is exactly the `#`'s width and the sub-issue's `#` is gone,
4859        // so the two numbers start in the same column: `#1` and ` 2`, not `#1` and
4860        // ` #2`.
4861        assert_eq!(
4862            (symbol(2, 1), symbol(2, 2)),
4863            ("1".to_string(), "2".to_string()),
4864            "the parent's and sub-issue's digits align at the same column: {:?} / {:?}",
4865            row(1),
4866            row(2)
4867        );
4868
4869        // Hit-testing resolves the rows the renderer drew to the same card
4870        // indices: row 1 is card 0 (the parent), row 2 is card 1 (the child).
4871        let area = Rect::new(0, 0, width, height);
4872        assert_eq!(
4873            board.hit_test(area, display, false, 3, 1),
4874            Some(HitTarget::Card { col: 0, row: 0 })
4875        );
4876        assert_eq!(
4877            board.hit_test(area, display, false, 3, 2),
4878            Some(HitTarget::Card { col: 0, row: 1 })
4879        );
4880    }
4881
4882    /// Write an issue with an optional `parent:` frontmatter line, for the
4883    /// build-time tree tests.
4884    fn write_issue_with_parent_md(
4885        dir: &std::path::Path,
4886        name: &str,
4887        number: u32,
4888        title: &str,
4889        status: &str,
4890        created: &str,
4891        parent: Option<u32>,
4892    ) {
4893        let parent_line = parent.map(|p| format!("parent: {p}\n")).unwrap_or_default();
4894        let contents = format!(
4895            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n{parent_line}---\n\nBody text."
4896        );
4897        std::fs::write(dir.join(name), contents).unwrap();
4898    }
4899
4900    #[test]
4901    fn build_with_show_children_nests_a_sub_issue_below_its_parent() {
4902        use crate::config::Config;
4903        use std::path::PathBuf;
4904
4905        let tmp = tempfile::TempDir::new().unwrap();
4906        let dir = tmp.path().join("issues");
4907        std::fs::create_dir_all(&dir).unwrap();
4908        write_issue_with_parent_md(
4909            &dir,
4910            "2026-01-01-parent.md",
4911            1,
4912            "parent",
4913            "todo",
4914            "2026-01-01",
4915            None,
4916        );
4917        write_issue_with_parent_md(
4918            &dir,
4919            "2026-01-02-child.md",
4920            2,
4921            "child",
4922            "todo",
4923            "2026-01-02",
4924            Some(1),
4925        );
4926        let config = Config {
4927            issues_dir: PathBuf::from(&dir),
4928            ..Config::default()
4929        };
4930        let store = crate::storage::Store::open(&config).unwrap();
4931        let board_cfg = config.default_config().clone();
4932
4933        // The default order puts the newer child before the older parent, so the
4934        // toggle has to reorder them.
4935        let flat = BoardState::build(&store, &board_cfg);
4936        let flat_shape: Vec<(u32, u8)> = flat
4937            .column_cards(0)
4938            .iter()
4939            .map(|c| (c.number, c.depth))
4940            .collect();
4941        assert_eq!(flat_shape, vec![(2, 0), (1, 0)]);
4942
4943        let tree = BoardState::build_with(&store, &board_cfg, false, true);
4944        let tree_shape: Vec<(u32, u8)> = tree
4945            .column_cards(0)
4946            .iter()
4947            .map(|c| (c.number, c.depth))
4948            .collect();
4949        assert_eq!(
4950            tree_shape,
4951            vec![(1, 0), (2, 1)],
4952            "the sub-issue follows its parent, one level deep"
4953        );
4954        assert_eq!(tree.column_cards(0)[1].parent, Some(1));
4955    }
4956
4957    /// `build_from_issues` is the single build path: for the same store it must
4958    /// produce exactly the board `build_with` produces (issue #10 follow-up).
4959    #[test]
4960    fn build_from_issues_matches_build_with() {
4961        use crate::config::Config;
4962
4963        let tmp = tempfile::TempDir::new().unwrap();
4964        let dir = tmp.path().join("issues");
4965        std::fs::create_dir_all(&dir).unwrap();
4966        write_issue_with_parent_md(
4967            &dir,
4968            "2026-01-01-parent.md",
4969            1,
4970            "parent",
4971            "todo",
4972            "2026-01-01",
4973            None,
4974        );
4975        write_issue_with_parent_md(
4976            &dir,
4977            "2026-01-02-child.md",
4978            2,
4979            "child",
4980            "todo",
4981            "2026-01-02",
4982            Some(1),
4983        );
4984        write_issue_md(
4985            &dir,
4986            "2026-01-03-other.md",
4987            3,
4988            "other",
4989            "in progress",
4990            "2026-01-03",
4991        );
4992        let config = Config {
4993            issues_dir: dir.clone(),
4994            ..Config::default()
4995        };
4996        let store = crate::storage::Store::open(&config).unwrap();
4997        let board_cfg = config.default_config().clone();
4998
4999        let shape = |state: &BoardState| -> Vec<(String, Vec<(String, u8)>)> {
5000            state
5001                .columns
5002                .iter()
5003                .chain(state.hidden.iter())
5004                .chain(std::iter::once(&state.mismatch))
5005                .map(|col| {
5006                    (
5007                        col.name.clone(),
5008                        col.cards
5009                            .iter()
5010                            .map(|c| (c.filename.clone(), c.depth))
5011                            .collect(),
5012                    )
5013                })
5014                .collect()
5015        };
5016
5017        for show_children in [false, true] {
5018            let from_store = BoardState::build_with(&store, &board_cfg, false, show_children);
5019            let issues = store.list().unwrap();
5020            let resolution = BoardState::resolution_for(&store, &board_cfg);
5021            let from_issues = BoardState::build_from_issues(
5022                &issues,
5023                &board_cfg,
5024                &config,
5025                &resolution,
5026                show_children,
5027            );
5028            assert_eq!(
5029                shape(&from_issues),
5030                shape(&from_store),
5031                "show_children={show_children}"
5032            );
5033        }
5034    }
5035}