Skip to main content

tix/ui/
roadmap.rs

1//! The `F10` **Portfolio** page's **Roadmap** tab (WP05): the shared
2//! [`RoadmapProjection`] the CLI renders, as a tab on the page `#1593` gives the
3//! project registry.
4//!
5//! # A tab, not a mode
6//!
7//! The mission's earlier planning sketch described the roadmap as a top-level
8//! page; the surface that actually shipped is an **additional tab on the `F10`
9//! Portfolio page** (`switch_to_portfolio`), exactly as the Epics tab is. So there
10//! is no new function key, no new [`crate::keybindings::InputContext`] and no new
11//! `TopMode`: `Tab`/`Shift+Tab` cycle `Projects -> Epics -> Roadmap`, `j`/`k` walk
12//! the rows, and `q`/`esc` close the page. `F9` stays reserved for `#1593`'s Data
13//! page.
14//!
15//! # Exactly the CLI's rows
16//!
17//! Nothing here re-renders a figure. The list headline and the detail pane are the
18//! CLI's own [`crate::commands::roadmap::roadmap_row_lines`] (the same lines
19//! `tix roadmap show` prints, contributions and diagnostics included), the empty
20//! state is [`crate::commands::roadmap::roadmap_list_lines`]'s own sentence, and
21//! the active criteria line is
22//! [`crate::commands::roadmap::roadmap_filter_line`]. The projection itself is the
23//! shared [`crate::roadmap::projection::project`] over a once-per-refresh source
24//! snapshot ([`projection_over`]); this module owns no progress, risk or ownership
25//! rule, so the tab cannot disagree with the CLI about one (C-004, C-007).
26//!
27//! # Filters are the one DTO
28//!
29//! The filter editor ([`RoadmapOverlay::Filter`]) edits a [`FilterDraft`] and
30//! normalizes it into the **same** [`RoadmapFilter`] the CLI builds; the predicate
31//! and the ordering stay in [`crate::roadmap`]. There is no second filter grammar.
32//!
33//! # Actions go through the registry
34//!
35//! `c`/`e`/`a`/`x` open the create, rename, associate and dereference overlays.
36//! Applying one calls [`crate::roadmap::registry`]'s own atomic boundary with the
37//! live `tix.toml` path — the same writer `tix initiative …` uses — so a refusal
38//! leaves the file byte-identical and no second writer exists (C-008).
39
40use std::collections::HashSet;
41
42use ratatui::Frame;
43use ratatui::layout::{Constraint, Direction, Layout, Rect};
44use ratatui::style::{Color, Modifier, Style};
45use ratatui::text::{Line, Span};
46use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
47
48use crate::app::App;
49use crate::config::Config;
50use crate::error::Result;
51use crate::model::Issue;
52use crate::roadmap::registry::{AssociationKind, InitiativeEdit, NewInitiative};
53use crate::roadmap::{
54    RoadmapConsumed, RoadmapFilter, RoadmapProjection, RoadmapRow, RoadmapSources,
55};
56use crate::storage::Store;
57
58// ---------------------------------------------------------------------------
59// Tab registration (read by `crate::ui::portfolio`)
60// ---------------------------------------------------------------------------
61
62/// The tab's label in the Portfolio strip.
63pub const TAB_LABEL: &str = "Roadmap";
64
65/// The registry the tab reads, as `tix.toml` spells it.
66///
67/// The roadmap is a **projection** over `[initiatives.<id>]`, so this names the
68/// section the tab's data is declared in rather than a section of its own.
69pub const TAB_SECTION: &str = "initiatives";
70
71/// The tab's noun, for a detail pane with nothing to describe.
72pub const TAB_NOUN: &str = "initiative";
73
74/// What to say when the initiative registry is empty.
75pub const TAB_EMPTY_HINT: &str =
76    "No initiatives yet — run `tix initiative create <name>`, or press `c` to add one here.";
77
78/// The one-line hint under the strip: the tab's keys, matching what the handler
79/// reads.
80pub const TAB_HINT: &str =
81    "j/k row · c create · e rename · a associate · x dereference · f filter · Tab tabs · q close";
82
83// ---------------------------------------------------------------------------
84// The read adapter (T022)
85// ---------------------------------------------------------------------------
86
87/// The shared projection for `filter`, over an already-read issue snapshot.
88///
89/// This is the TUI's counterpart of the CLI's `build_sources` + `projection::project`
90/// pair: the **consumed** facts (assigned, overdue, blocked, reference targets) are
91/// gathered from their owning services — never derived here — and the join, the
92/// predicate and the sort all stay in [`crate::roadmap`]. `issues` is passed in so
93/// the page can reuse the `store.list_all()` read it already made for the Projects
94/// and Epics tabs instead of reading the tree a second time (NFR-001).
95pub fn projection_over(
96    config: &Config,
97    store: &Store,
98    issues: &[Issue],
99    filter: &RoadmapFilter,
100) -> Result<RoadmapProjection> {
101    filter.validate()?;
102    let policy = config.health.policy()?;
103    let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
104    let planner = crate::planning::Planner::from_config(config.clone(), root);
105    let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
106    let overdue: HashSet<u32> = planner
107        .overdue_batch(store, &crate::model::today())
108        .iter()
109        .map(|issue| issue.number)
110        .collect();
111    let assigned = crate::projects::assigned_keys(issues);
112    let (graph_issues, done_column) = store.graph_source()?;
113    let views: Vec<crate::relationships::IssueView<'_>> =
114        graph_issues.iter().map(Issue::issue_view).collect();
115    let graph = crate::relationships::RelationshipGraph::new(&views, &done_column);
116    let blocked: HashSet<u32> = graph_issues
117        .iter()
118        .filter(|issue| graph.blocked_state(issue.number).is_blocked)
119        .map(|issue| issue.number)
120        .collect();
121    let consumed = RoadmapConsumed {
122        targets,
123        done_column,
124        assigned,
125        overdue,
126        blocked,
127    };
128    let sources = RoadmapSources::new(config, issues, &policy, consumed);
129    crate::roadmap::projection::project(config, &sources, filter)
130}
131
132// ---------------------------------------------------------------------------
133// Pure render data (T022, T025)
134// ---------------------------------------------------------------------------
135
136/// One row's headline: the **CLI's own** header line, its tab separators spaced so
137/// the TUI list reads as columns.
138///
139/// The content is [`crate::commands::roadmap::roadmap_row_lines`]'s first line
140/// verbatim (`id`, name, target date or `undated`, summary state); replacing the
141/// separator with spaces is presentation only.
142#[must_use]
143pub fn headline(row: &RoadmapRow) -> String {
144    crate::commands::roadmap::roadmap_row_lines(row)
145        .into_iter()
146        .next()
147        .unwrap_or_default()
148        .replace('\t', "  ")
149}
150
151/// One row's detail lines: the CLI's `roadmap show` output, **verbatim**.
152///
153/// Every provenance, resolution (`[missing]`), progress, risk, policy version,
154/// empty-summary sentence and diagnostic the CLI prints is here, because it is the
155/// CLI's string.
156#[must_use]
157pub fn detail_lines(row: &RoadmapRow) -> Vec<String> {
158    crate::commands::roadmap::roadmap_row_lines(row)
159}
160
161/// The CLI's explicit empty state for a projection that selected nothing.
162#[must_use]
163pub fn empty_state_lines(projection: &RoadmapProjection) -> Vec<String> {
164    crate::commands::roadmap::roadmap_list_lines(projection)
165}
166
167/// The active normalized criteria, exactly as `tix roadmap list` prints them, or
168/// `None` when the filter narrows nothing.
169#[must_use]
170pub fn filter_context(filter: &RoadmapFilter) -> Option<String> {
171    crate::commands::roadmap::roadmap_filter_line(filter)
172}
173
174// ---------------------------------------------------------------------------
175// Overlays: the filter editor and the initiative actions (T023, T024)
176// ---------------------------------------------------------------------------
177
178/// The verb an association overlay applies.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum AssocVerb {
181    /// Record the target on the selected initiative.
182    Associate,
183    /// Drop the target from the selected initiative.
184    Dereference,
185}
186
187/// The overlay open over the Roadmap tab: the filter editor, or one initiative
188/// action. All are text surfaces, so the page assigns them raw keys ahead of the
189/// binding layer (`j` typed into an owner name must stay a letter).
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum RoadmapOverlay {
192    /// The normalized filter editor (T023).
193    Filter(FilterDraft),
194    /// Create an initiative; `c`.
195    Create(NameDraft),
196    /// Rename the selected initiative; `e`.
197    Rename(NameDraft),
198    /// Associate or dereference a reference on the selected initiative; `a`/`x`.
199    Associate(AssociationDraft),
200}
201
202impl RoadmapOverlay {
203    /// The overlay's title.
204    #[must_use]
205    pub fn title(&self) -> &'static str {
206        match self {
207            Self::Filter(_) => "Roadmap filter",
208            Self::Create(_) => "New initiative",
209            Self::Rename(_) => "Rename initiative",
210            Self::Associate(draft) => match draft.verb {
211                AssocVerb::Associate => "Associate reference",
212                AssocVerb::Dereference => "Dereference reference",
213            },
214        }
215    }
216
217    /// The overlay's footer hint.
218    #[must_use]
219    pub fn hint(&self) -> &'static str {
220        match self {
221            Self::Filter(_) => "type to edit · tab/↑↓ field · enter apply · esc cancel",
222            Self::Create(_) => "type a name · enter create · esc cancel",
223            Self::Rename(_) => "type a name · enter rename · esc cancel",
224            Self::Associate(_) => "type a target · ←/→ kind · enter apply · esc cancel",
225        }
226    }
227
228    /// Move the focused field by `delta`, wrapping.
229    pub fn focus(&mut self, delta: isize) {
230        match self {
231            Self::Filter(draft) => draft.focus(delta),
232            Self::Associate(draft) => draft.focus(delta),
233            Self::Create(_) | Self::Rename(_) => {}
234        }
235    }
236
237    /// Cycle the association **kind** by `delta` (a no-op on every other overlay).
238    pub fn cycle_kind(&mut self, delta: isize) {
239        if let Self::Associate(draft) = self {
240            draft.cycle_kind(delta);
241        }
242    }
243
244    /// Append `ch` to the focused text field.
245    pub fn push(&mut self, ch: char) {
246        if let Some(field) = self.field_mut() {
247            field.push(ch);
248        }
249    }
250
251    /// Delete the focused field's last character.
252    pub fn backspace(&mut self) {
253        if let Some(field) = self.field_mut() {
254            field.pop();
255        }
256    }
257
258    /// The focused text field, or `None` when the overlay has none.
259    fn field_mut(&mut self) -> Option<&mut String> {
260        match self {
261            Self::Filter(draft) => Some(draft.field_mut()),
262            Self::Create(draft) | Self::Rename(draft) => Some(&mut draft.value),
263            Self::Associate(draft) => draft.field_mut(),
264        }
265    }
266}
267
268/// A single text field — the create and rename overlays.
269#[derive(Debug, Clone, Default, PartialEq, Eq)]
270pub struct NameDraft {
271    /// The typed value.
272    pub value: String,
273}
274
275/// The normalized filter editor: one text row per criterion, in the DTO's own
276/// field order.
277///
278/// The DTO the draft normalizes into is [`RoadmapFilter`]; the labels are a
279/// comma-separated list in **this** control only, normalized to the DTO's
280/// `Vec<String>` on apply.
281#[derive(Debug, Clone, Default, PartialEq, Eq)]
282pub struct FilterDraft {
283    /// The `period_from` row.
284    pub from: String,
285    /// The `period_to` row.
286    pub to: String,
287    /// The `owner` row.
288    pub owner: String,
289    /// The `status` row.
290    pub status: String,
291    /// The `labels` row, comma-separated.
292    pub labels: String,
293    /// The `query` row.
294    pub query: String,
295    /// The focused row.
296    pub field: usize,
297}
298
299impl FilterDraft {
300    /// The rows in display order; index is [`Self::field`].
301    pub const ROWS: [&'static str; 6] = ["from", "to", "owner", "status", "labels", "query"];
302
303    /// Seed the editor from an installed filter.
304    #[must_use]
305    pub fn from_filter(filter: &RoadmapFilter) -> Self {
306        Self {
307            from: filter.period_from.clone().unwrap_or_default(),
308            to: filter.period_to.clone().unwrap_or_default(),
309            owner: filter.owner.clone().unwrap_or_default(),
310            status: filter.status.clone().unwrap_or_default(),
311            labels: filter.labels.join(","),
312            query: filter.query.clone().unwrap_or_default(),
313            field: 0,
314        }
315    }
316
317    /// Normalize the draft into **the one** filter DTO (contract §Normalized
318    /// filter). A blank row is `None`/empty; `labels` splits on `,` and trims.
319    #[must_use]
320    pub fn to_filter(&self) -> RoadmapFilter {
321        let opt = |value: &str| {
322            let trimmed = value.trim();
323            (!trimmed.is_empty()).then(|| trimmed.to_string())
324        };
325        RoadmapFilter {
326            period_from: opt(&self.from),
327            period_to: opt(&self.to),
328            owner: opt(&self.owner),
329            status: opt(&self.status),
330            labels: self
331                .labels
332                .split(',')
333                .map(str::trim)
334                .filter(|label| !label.is_empty())
335                .map(ToString::to_string)
336                .collect(),
337            query: opt(&self.query),
338        }
339    }
340
341    /// The focused row's value.
342    #[must_use]
343    pub fn value(&self) -> &str {
344        self.value_at(self.field)
345    }
346
347    /// The `index`-th row's value.
348    #[must_use]
349    pub fn value_at(&self, index: usize) -> &str {
350        match index % Self::ROWS.len() {
351            0 => &self.from,
352            1 => &self.to,
353            2 => &self.owner,
354            3 => &self.status,
355            4 => &self.labels,
356            _ => &self.query,
357        }
358    }
359
360    /// Move the focused row by `delta`, wrapping.
361    pub fn focus(&mut self, delta: isize) {
362        let len = Self::ROWS.len() as isize;
363        self.field = (self.field as isize + delta).rem_euclid(len) as usize;
364    }
365
366    fn field_mut(&mut self) -> &mut String {
367        match self.field % Self::ROWS.len() {
368            0 => &mut self.from,
369            1 => &mut self.to,
370            2 => &mut self.owner,
371            3 => &mut self.status,
372            4 => &mut self.labels,
373            _ => &mut self.query,
374        }
375    }
376}
377
378/// The association overlay's draft: the verb, the kind and the target.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct AssociationDraft {
381    /// Whether applying the overlay associates or dereferences.
382    pub verb: AssocVerb,
383    /// The reference kind; cycled with `←`/`→`.
384    pub kind: AssociationKind,
385    /// The typed target.
386    pub target: String,
387    /// The focused field (`0` = kind, `1` = target).
388    pub field: usize,
389}
390
391impl AssociationDraft {
392    /// The kinds in cycle order.
393    pub const KINDS: [AssociationKind; 3] = [
394        AssociationKind::Project,
395        AssociationKind::Epic,
396        AssociationKind::Milestone,
397    ];
398
399    /// A fresh draft for `verb`.
400    #[must_use]
401    pub fn new(verb: AssocVerb) -> Self {
402        Self {
403            verb,
404            kind: AssociationKind::Project,
405            target: String::new(),
406            field: 1,
407        }
408    }
409
410    /// Cycle the kind by `delta`, wrapping.
411    pub fn cycle_kind(&mut self, delta: isize) {
412        let current = Self::KINDS
413            .iter()
414            .position(|kind| *kind == self.kind)
415            .unwrap_or(0);
416        let len = Self::KINDS.len() as isize;
417        self.kind = Self::KINDS[(current as isize + delta).rem_euclid(len) as usize];
418    }
419
420    /// The kind's label in the overlay.
421    #[must_use]
422    pub fn kind_label(&self) -> &'static str {
423        self.kind.field()
424    }
425
426    fn focus(&mut self, delta: isize) {
427        let len = 2isize;
428        self.field = (self.field as isize + delta).rem_euclid(len) as usize;
429    }
430
431    fn field_mut(&mut self) -> Option<&mut String> {
432        (self.field % 2 == 1).then_some(&mut self.target)
433    }
434}
435
436/// The initiative the create overlay builds: a name; every other field is the
437/// CLI's own default and is edited later (or from `tix initiative edit`).
438#[must_use]
439pub fn new_initiative(draft: &NameDraft) -> NewInitiative {
440    NewInitiative {
441        name: draft.value.trim().to_string(),
442        ..NewInitiative::default()
443    }
444}
445
446/// The rename edit a [`RoadmapOverlay::Rename`] draft applies: a **label** change
447/// only, so the stable id — and every association — survives (C-002).
448#[must_use]
449pub fn rename_edit(draft: &NameDraft) -> InitiativeEdit {
450    InitiativeEdit {
451        name: crate::projects::Patch::Set(draft.value.trim().to_string()),
452        ..InitiativeEdit::default()
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Rendering
458// ---------------------------------------------------------------------------
459
460/// The style of everything secondary.
461fn dim() -> Style {
462    Style::default().fg(Color::DarkGray)
463}
464
465/// Draw the Roadmap tab into `area`: the active criteria line, then the ordered
466/// rows beside the selected row's CLI detail.
467///
468/// The whole tab is one function so the Portfolio page's renderer only has to
469/// dispatch to it — the tab cannot drift from the page's other tabs, and the
470/// page's own layout code stays untouched beyond the dispatch.
471pub fn render_tab(app: &App, f: &mut Frame, area: Rect, selected: usize) {
472    let split = Layout::default()
473        .direction(Direction::Vertical)
474        .constraints([Constraint::Length(1), Constraint::Min(1)])
475        .split(area);
476
477    let criteria = filter_context(app.roadmap_filter());
478    let line = match criteria {
479        Some(text) => Line::from(Span::styled(text, Style::default().fg(Color::Cyan))),
480        None => Line::from(Span::styled(
481            "filter: none (no criterion set) · f to edit",
482            dim(),
483        )),
484    };
485    f.render_widget(Paragraph::new(line), split[0]);
486
487    let body = Layout::default()
488        .direction(Direction::Horizontal)
489        .constraints([Constraint::Percentage(55), Constraint::Min(24)])
490        .split(split[1]);
491
492    let projection = app.portfolio_roadmap();
493    let rows: &[RoadmapRow] =
494        projection.map_or(&[], |projection| projection.initiatives.as_slice());
495
496    let list_title = format!("{} ({})", TAB_LABEL, rows.len());
497    let list_block = Block::default().borders(Borders::ALL).title(list_title);
498    if rows.is_empty() {
499        // Two distinct empty states, never one blank pane: a registry with no
500        // initiatives names how to fill it, a filter that selected nothing prints
501        // the CLI's own sentence for that case, and a projection that could not be
502        // built says so rather than panicking on a missing value.
503        let text = if app.roadmap_registry_is_empty() {
504            TAB_EMPTY_HINT.to_string()
505        } else {
506            match projection {
507                Some(projection) => empty_state_lines(projection).join("\n"),
508                None => "The roadmap could not be read — check the filter and the registries."
509                    .to_string(),
510            }
511        };
512        f.render_widget(
513            Paragraph::new(text)
514                .block(list_block)
515                .wrap(Wrap { trim: true })
516                .style(dim()),
517            body[0],
518        );
519    } else {
520        let lines: Vec<Line<'static>> = rows
521            .iter()
522            .enumerate()
523            .map(|(index, row)| {
524                let selected = index == selected;
525                let style = if selected {
526                    Style::default().add_modifier(Modifier::BOLD)
527                } else {
528                    Style::default()
529                };
530                Line::from(vec![
531                    Span::raw(if selected { "> " } else { "  " }),
532                    Span::styled(headline(row), style),
533                ])
534            })
535            .collect();
536        f.render_widget(Paragraph::new(lines).block(list_block), body[0]);
537    }
538
539    let detail_block = Block::default()
540        .borders(Borders::ALL)
541        .title(format!("{} detail", TAB_NOUN));
542    match rows.get(selected) {
543        Some(row) => {
544            let lines: Vec<Line<'static>> = detail_lines(row)
545                .into_iter()
546                .map(|line| Line::from(line.replace('\t', "  ")))
547                .collect();
548            f.render_widget(
549                Paragraph::new(lines)
550                    .block(detail_block)
551                    .wrap(Wrap { trim: false }),
552                body[1],
553            );
554        }
555        None => f.render_widget(
556            Paragraph::new(Span::styled(
557                "Nothing selected. An initiative has to exist before the page can describe it.",
558                dim(),
559            ))
560            .block(detail_block)
561            .wrap(Wrap { trim: true }),
562            body[1],
563        ),
564    }
565
566    render_overlay(app, f, area);
567}
568
569/// Draw the open overlay, if any, centered over `area`.
570pub fn render_overlay(app: &App, f: &mut Frame, area: Rect) {
571    let Some(overlay) = app.roadmap_overlay() else {
572        return;
573    };
574    let rect = crate::ui::modal::modal_rect(area);
575    f.render_widget(Clear, rect);
576
577    let mut lines: Vec<Line<'static>> = Vec::new();
578    let row = |focused: bool, key: &str, value: &str| {
579        let marker = if focused { "> " } else { "  " };
580        let style = if focused {
581            Style::default().add_modifier(Modifier::BOLD)
582        } else {
583            Style::default()
584        };
585        Line::from(vec![
586            Span::styled(format!("{marker}{key}: "), dim()),
587            Span::styled(value.to_string(), style),
588        ])
589    };
590    match overlay {
591        RoadmapOverlay::Filter(draft) => {
592            for (index, key) in FilterDraft::ROWS.iter().enumerate() {
593                let focused = index == draft.field % FilterDraft::ROWS.len();
594                let value = if focused {
595                    format!("{}_", draft.value())
596                } else {
597                    draft.value_at(index).to_string()
598                };
599                lines.push(row(focused, key, &value));
600            }
601        }
602        RoadmapOverlay::Create(draft) | RoadmapOverlay::Rename(draft) => {
603            lines.push(row(true, "name", &format!("{}_", draft.value)));
604        }
605        RoadmapOverlay::Associate(draft) => {
606            let kind = draft.kind_label().to_string();
607            lines.push(row(draft.field % 2 == 0, "kind", &kind));
608            let target = if draft.field % 2 == 1 {
609                format!("{}_", draft.target)
610            } else {
611                draft.target.clone()
612            };
613            lines.push(row(draft.field % 2 == 1, "target", &target));
614        }
615    }
616    lines.push(Line::from(""));
617    lines.push(Line::from(Span::styled(overlay.hint(), dim())));
618
619    f.render_widget(
620        Paragraph::new(lines)
621            .block(
622                Block::default()
623                    .borders(Borders::ALL)
624                    .title(overlay.title()),
625            )
626            .wrap(Wrap { trim: false }),
627        rect,
628    );
629}
630
631#[cfg(test)]
632mod tests {
633    #[allow(clippy::wildcard_imports)]
634    use super::*;
635
636    #[test]
637    fn the_filter_draft_normalizes_into_the_one_shared_dto() {
638        let mut draft = FilterDraft::from_filter(&RoadmapFilter::default());
639        assert_eq!(draft.to_filter(), RoadmapFilter::default());
640        draft.from = " 2026-01-01 ".to_string();
641        draft.owner = "alice".to_string();
642        draft.labels = "one, two ,, three".to_string();
643        draft.query = "  ".to_string();
644        let filter = draft.to_filter();
645        assert_eq!(filter.period_from.as_deref(), Some("2026-01-01"));
646        assert_eq!(filter.owner.as_deref(), Some("alice"));
647        assert_eq!(filter.labels, vec!["one", "two", "three"]);
648        assert_eq!(filter.query, None, "a blank row narrows nothing");
649        // The DTO's own validator owns the syntax rule.
650        assert!(filter.validate().is_ok());
651        let mut bad = FilterDraft::from_filter(&RoadmapFilter::default());
652        bad.from = "not-a-date".to_string();
653        assert!(bad.to_filter().validate().is_err());
654    }
655
656    #[test]
657    fn the_filter_draft_seeds_from_an_installed_filter() {
658        let filter = RoadmapFilter {
659            period_to: Some("2026-12-31".to_string()),
660            status: Some("active".to_string()),
661            labels: vec!["a".to_string(), "b".to_string()],
662            ..RoadmapFilter::default()
663        };
664        let draft = FilterDraft::from_filter(&filter);
665        assert_eq!(draft.to, "2026-12-31");
666        assert_eq!(draft.status, "active");
667        assert_eq!(draft.labels, "a,b");
668        assert_eq!(draft.to_filter(), filter, "a round trip through the editor");
669        // The rows are the DTO's own field order.
670        assert_eq!(
671            FilterDraft::ROWS,
672            ["from", "to", "owner", "status", "labels", "query"]
673        );
674    }
675
676    #[test]
677    fn focus_wraps_and_the_association_kind_cycles() {
678        let mut draft = FilterDraft::from_filter(&RoadmapFilter::default());
679        draft.focus(-1);
680        assert_eq!(draft.field, FilterDraft::ROWS.len() - 1, "backward wraps");
681        draft.focus(1);
682        assert_eq!(draft.field, 0, "forward wraps");
683
684        let mut association = AssociationDraft::new(AssocVerb::Associate);
685        assert_eq!(association.kind, AssociationKind::Project);
686        association.cycle_kind(1);
687        assert_eq!(association.kind, AssociationKind::Epic);
688        association.cycle_kind(1);
689        assert_eq!(association.kind, AssociationKind::Milestone);
690        association.cycle_kind(1);
691        assert_eq!(association.kind, AssociationKind::Project, "forward wraps");
692        association.cycle_kind(-1);
693        assert_eq!(
694            association.kind,
695            AssociationKind::Milestone,
696            "backward wraps"
697        );
698        // Only the target row takes text; the overlay routes characters to it.
699        let mut association = AssociationDraft::new(AssocVerb::Associate);
700        let mut overlay = RoadmapOverlay::Associate(association.clone());
701        overlay.push('p');
702        let RoadmapOverlay::Associate(focused_target) = &overlay else {
703            unreachable!("the overlay is an association");
704        };
705        assert_eq!(focused_target.target, "p");
706        overlay.backspace();
707        let RoadmapOverlay::Associate(cleared) = &overlay else {
708            unreachable!("the overlay is an association");
709        };
710        assert_eq!(cleared.target, "");
711        // The kind row is not a text row: a character typed there is dropped.
712        association.field = 0;
713        overlay = RoadmapOverlay::Associate(association);
714        overlay.push('p');
715        let RoadmapOverlay::Associate(kind_row) = &overlay else {
716            unreachable!("the overlay is an association");
717        };
718        assert!(kind_row.target.is_empty(), "the kind row takes no text");
719    }
720
721    #[test]
722    fn the_action_drafts_build_the_services_own_specs() {
723        let new = new_initiative(&NameDraft {
724            value: "  Alpha  ".to_string(),
725        });
726        assert_eq!(
727            new.name, "Alpha",
728            "the name is trimmed, nothing else is invented"
729        );
730        assert!(new.outcome.is_none() && new.projects.is_empty());
731
732        let edit = rename_edit(&NameDraft {
733            value: "Renamed".to_string(),
734        });
735        assert_eq!(
736            edit,
737            InitiativeEdit {
738                name: crate::projects::Patch::Set("Renamed".to_string()),
739                ..InitiativeEdit::default()
740            },
741            "a rename touches the label only, never the stable id (C-002)"
742        );
743    }
744
745    #[test]
746    fn the_tab_registration_names_how_to_fill_an_empty_registry() {
747        assert_eq!(TAB_LABEL, "Roadmap");
748        assert_eq!(TAB_SECTION, "initiatives");
749        assert_eq!(TAB_NOUN, "initiative");
750        assert!(TAB_EMPTY_HINT.contains("tix initiative create"));
751        for key in [
752            "c create",
753            "e rename",
754            "a associate",
755            "x dereference",
756            "f filter",
757        ] {
758            assert!(TAB_HINT.contains(key), "the hint advertises `{key}`");
759        }
760    }
761
762    #[test]
763    fn the_overlay_titles_and_hints_are_stable() {
764        assert_eq!(
765            RoadmapOverlay::Create(NameDraft::default()).title(),
766            "New initiative"
767        );
768        assert_eq!(
769            RoadmapOverlay::Associate(AssociationDraft::new(AssocVerb::Dereference)).title(),
770            "Dereference reference"
771        );
772        assert_eq!(
773            RoadmapOverlay::Filter(FilterDraft::default()).hint(),
774            "type to edit · tab/↑↓ field · enter apply · esc cancel"
775        );
776    }
777}