use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use assert_cmd::Command;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::{Value, json};
use tempfile::TempDir;
use tix::app::App;
use tix::config::{Config, EpicConfiguration, InitiativeConfiguration, ProjectConfiguration};
use tix::epics::EpicIndex;
use tix::health::HealthPolicy;
use tix::model::Issue;
use tix::projects::ProjectIndex;
use tix::relationships::{IssueView, RelationshipGraph};
use tix::roadmap::portable::PortableRoadmap;
use tix::roadmap::projection::{ProgressValue, RoadmapProjection, RoadmapSources, SummaryState};
use tix::roadmap::{RoadmapConsumed, RoadmapFilter};
use tix::storage::Store;
fn tix() -> Command {
let mut c = Command::cargo_bin("tix").expect("the tix binary must build");
c.env("NO_COLOR", "1").env("TERM", "dumb");
c
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn cli(tmp: &TempDir, args: &[&str]) -> (String, String, bool) {
cli_args(
tmp,
&args.iter().map(|a| (*a).to_string()).collect::<Vec<_>>(),
)
}
fn cli_args(tmp: &TempDir, args: &[String]) -> (String, String, bool) {
let out = tix()
.current_dir(tmp.path())
.args(args)
.output()
.expect("the CLI must run");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn mcp(config: &Config, store: &Store, name: &str, args: Value) -> Value {
tix::mcp::tools::call(
config,
store,
tix::mcp::protocol::Id::Number(1),
name,
&args,
)
.expect("a well-formed call must not fail at the protocol level")
}
fn mcp_is_error(value: &Value) -> bool {
value.get("isError").and_then(Value::as_bool) == Some(true)
}
fn mcp_structured(value: &Value) -> &Value {
value
.get("structuredContent")
.expect("a success result carries structuredContent")
}
fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(next) = stack.pop() {
for entry in fs::read_dir(&next).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
let rel = path.strip_prefix(root).unwrap().to_path_buf();
out.insert(rel, fs::read(&path).unwrap());
}
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn write_issue(
dir: &Path,
number: u32,
title: &str,
status: &str,
projects: &[&str],
epics: &[&str],
milestones: &[&str],
assignee: Option<&str>,
) {
let mut raw = format!("---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\n");
for (key, values) in [
("projects", projects),
("epics", epics),
("milestones", milestones),
] {
if values.is_empty() {
continue;
}
raw.push_str(&format!("{key}:\n"));
for value in values {
raw.push_str(&format!("- \"{value}\"\n"));
}
}
if let Some(assignee) = assignee {
raw.push_str(&format!("assignee: \"{assignee}\"\n"));
}
raw.push_str("created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n");
fs::write(
dir.join(format!("2026-01-{number:02}-issue-{number}.md")),
raw,
)
.unwrap();
}
fn write_milestone(root: &Path, name: &str) {
let dir = root.join("docs/milestones");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(format!("2026-01-01-{}.md", name.to_lowercase())),
format!(
"---\nname: {name}\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\n{name}.\n"
),
)
.unwrap();
}
fn parity_fixture() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let issues = tmp.path().join("issues");
fs::create_dir_all(&issues).unwrap();
let mut toml = format!(
"issues_dir = \"{}\"\n\n[kanban.default]\ncolumns = [\"todo\", \"in progress\", \"done\"]\n\
done_column = \"done\"\n\n",
issues.display()
);
for (id, name) in [
("p-alpha", "Alpha program"),
("p-beta", "Beta program"),
("p-zero", "Zero program"),
] {
toml.push_str(&format!("[projects.\"{id}\"]\nname = \"{name}\"\n\n"));
}
toml.push_str("[epics.\"e-alpha\"]\nname = \"Alpha epic\"\n\n");
toml.push_str(
"[initiatives.\"i-alpha\"]\nname = \"Alpha portfolio\"\noutcome = \"Ship the alpha launch\"\n\
owner = \"alice\"\nstatus = \"active\"\nlabels = [\"strategic\"]\ntarget_date = \"2026-01-01\"\n\
projects = [\"p-alpha\"]\nepics = [\"e-alpha\"]\nmilestones = [\"M-live\"]\n\n",
);
toml.push_str(
"[initiatives.\"i-partial\"]\nname = \"Beta plus ghost\"\nowner = \"bob\"\n\
status = \"planning\"\nlabels = [\"strategic\", \"apac\"]\ntarget_date = \"2026-02-15\"\n\
projects = [\"p-alpha\"]\nepics = [\"e-ghost\"]\n\n",
);
toml.push_str(
"[initiatives.\"i-missing\"]\nname = \"All ghosts\"\nlabels = [\"legacy\"]\n\
target_date = \"2026-03-10\"\nprojects = [\"p-ghost-initiative\"]\nepics = [\"e-ghost-2\"]\n\n",
);
toml.push_str(
"[initiatives.\"i-empty\"]\nname = \"Empty slate\"\nowner = \"carol\"\n\
labels = [\"legacy\"]\ntarget_date = \"2026-04-01\"\n\n",
);
toml.push_str(
"[initiatives.\"i-zero\"]\nname = \"Zero progress\"\ntarget_date = \"2026-05-20\"\n\
projects = [\"p-zero\"]\n\n",
);
toml.push_str(
"[initiatives.\"i-unknown\"]\nname = \"Unknown milestone\"\ntarget_date = \"2026-06-30\"\n\
milestones = [\"M-empty\"]\n\n",
);
toml.push_str(
"[initiatives.\"i-undated\"]\nname = \"Undated work\"\nowner = \"alice\"\n\
projects = [\"p-beta\"]\n",
);
fs::write(tmp.path().join("tix.toml"), toml).unwrap();
write_milestone(tmp.path(), "M-live");
write_milestone(tmp.path(), "M-empty");
write_issue(
&issues,
1,
"Alpha done",
"done",
&["p-alpha"],
&["e-alpha"],
&["M-live"],
Some("alice"),
);
write_issue(
&issues,
2,
"Alpha todo",
"todo",
&["p-alpha"],
&[],
&[],
Some("alice"),
);
write_issue(
&issues,
3,
"Beta work",
"todo",
&["p-beta"],
&[],
&[],
Some("bob"),
);
write_issue(
&issues,
4,
"Dangling project",
"todo",
&["p-ghost-issue"],
&[],
&[],
None,
);
let config = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
(tmp, config)
}
fn store_for(config: &Config) -> Store {
Store::open(config).expect("the fixture store opens")
}
fn projection_for(config: &Config, filter: &RoadmapFilter) -> RoadmapProjection {
let store = store_for(config);
let issues = store.list_all().unwrap();
let sources = tix::commands::roadmap::build_sources(config, &store, &issues).unwrap();
tix::roadmap::project(config, &sources, filter).expect("the projection succeeds")
}
fn tui_projection(config: &Config, filter: &RoadmapFilter) -> RoadmapProjection {
let mut app = App::new(config).expect("the app builds");
let _ = app.handle_key(press(KeyCode::F(10))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.portfolio_state().tab,
tix::ui::portfolio::PortfolioTab::Roadmap,
"two `Tab` presses reach the Roadmap tab"
);
app.roadmap_filter = filter.clone();
let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
app.portfolio_roadmap()
.expect("the Roadmap tab built a projection")
.clone()
}
fn portable_document(config: &Config, filter: &RoadmapFilter) -> PortableRoadmap {
let store = store_for(config);
let issues = store.list_all().unwrap();
let sources = tix::commands::roadmap::build_sources(config, &store, &issues).unwrap();
PortableRoadmap::build(config, &sources, filter).expect("the document builds")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Contribution {
source_entity: String,
resolution: String,
policy_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Diagnostic {
kind: String,
field: Option<String>,
reference: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RowView {
id: String,
target_date: String,
state: String,
contributions: Vec<Contribution>,
diagnostics: Vec<Diagnostic>,
}
impl RowView {
fn diagnostic_kinds(&self) -> Vec<&str> {
self.diagnostics.iter().map(|d| d.kind.as_str()).collect()
}
}
fn views_from_projection(projection: &RoadmapProjection) -> Vec<RowView> {
projection
.initiatives
.iter()
.map(|row| RowView {
id: row.id.clone(),
target_date: row.target_date.clone().unwrap_or_else(|| "undated".into()),
state: row.summary.state.as_str().to_string(),
contributions: row
.summary
.contributions
.iter()
.map(|c| Contribution {
source_entity: c.source_entity(),
resolution: c.resolution.as_str().to_string(),
policy_version: c.policy_version.clone(),
})
.collect(),
diagnostics: row
.diagnostics
.iter()
.map(|d| Diagnostic {
kind: d.kind.as_str().to_string(),
field: d.field.clone(),
reference: d.reference.clone(),
})
.collect(),
})
.collect()
}
fn views_from_document(document: &PortableRoadmap) -> Vec<RowView> {
document
.rows
.iter()
.map(|row| RowView {
id: row.id.clone(),
target_date: row.target_date.clone().unwrap_or_else(|| "undated".into()),
state: row.summary.state.clone(),
contributions: row
.summary
.contributions
.iter()
.map(|c| Contribution {
source_entity: c.source_entity.clone(),
resolution: c.resolution.clone(),
policy_version: c.policy_version.clone(),
})
.collect(),
diagnostics: row
.diagnostics
.iter()
.map(|d| Diagnostic {
kind: d.kind.clone(),
field: d.field.clone(),
reference: d.reference.clone(),
})
.collect(),
})
.collect()
}
fn views_from_mcp(config: &Config, store: &Store, filter: &RoadmapFilter) -> Vec<RowView> {
let result = mcp(config, store, "roadmap_list", mcp_filter_args(filter));
assert!(!mcp_is_error(&result), "roadmap_list failed");
let structured = mcp_structured(&result);
structured["initiatives"]
.as_array()
.expect("initiatives is an array")
.iter()
.map(|row| RowView {
id: row["id"].as_str().unwrap().to_string(),
target_date: row["target_date"].as_str().unwrap_or("undated").to_string(),
state: row["summary"]["state"].as_str().unwrap().to_string(),
contributions: row["summary"]["contributions"]
.as_array()
.unwrap()
.iter()
.map(|c| Contribution {
source_entity: c["source_entity"].as_str().unwrap().to_string(),
resolution: c["resolution"].as_str().unwrap().to_string(),
policy_version: c["policy_version"].as_str().map(str::to_string),
})
.collect(),
diagnostics: row["diagnostics"]
.as_array()
.unwrap()
.iter()
.map(|d| Diagnostic {
kind: d["kind"].as_str().unwrap().to_string(),
field: d["field"].as_str().map(str::to_string),
reference: d["reference"].as_str().map(str::to_string),
})
.collect(),
})
.collect()
}
fn views_from_cli(stdout: &str) -> Vec<RowView> {
let mut rows: Vec<RowView> = Vec::new();
for line in stdout.lines() {
let columns: Vec<&str> = line.split('\t').collect();
if columns.len() == 4 {
rows.push(RowView {
id: columns[0].to_string(),
target_date: columns[2].to_string(),
state: columns[3].to_string(),
contributions: Vec::new(),
diagnostics: Vec::new(),
});
continue;
}
let Some(row) = rows.last_mut() else {
continue;
};
let trimmed = line.trim_start();
if trimmed.starts_with('(') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("! ") {
row.diagnostics.push(Diagnostic {
kind: rest
.split(':')
.next()
.unwrap_or_default()
.trim()
.to_string(),
field: None,
reference: None,
});
continue;
}
if line.starts_with(" ") {
let source_entity = trimmed.split_whitespace().next().unwrap_or_default();
let resolution = trimmed
.split('[')
.nth(1)
.and_then(|rest| rest.split(']').next())
.unwrap_or_default();
let policy_version = trimmed
.rsplit("policy=")
.next()
.map(str::trim)
.filter(|value| *value != "-")
.map(str::to_string);
row.contributions.push(Contribution {
source_entity: source_entity.to_string(),
resolution: resolution.to_string(),
policy_version,
});
}
}
rows
}
fn cli_filter_args(filter: &RoadmapFilter) -> Vec<String> {
let mut args: Vec<String> = vec!["roadmap".into(), "list".into()];
let mut push = |flag: &str, value: &str| {
args.push(flag.to_string());
args.push(value.to_string());
};
if let Some(value) = &filter.period_from {
push("--from", value);
}
if let Some(value) = &filter.period_to {
push("--to", value);
}
if let Some(value) = &filter.owner {
push("--owner", value);
}
if let Some(value) = &filter.status {
push("--status", value);
}
for label in &filter.labels {
push("--label", label);
}
if let Some(value) = &filter.query {
push("--query", value);
}
args
}
fn mcp_filter_args(filter: &RoadmapFilter) -> Value {
let mut map = serde_json::Map::new();
if let Some(value) = &filter.period_from {
map.insert("period_from".into(), json!(value));
}
if let Some(value) = &filter.period_to {
map.insert("period_to".into(), json!(value));
}
if let Some(value) = &filter.owner {
map.insert("owner".into(), json!(value));
}
if let Some(value) = &filter.status {
map.insert("status".into(), json!(value));
}
if !filter.labels.is_empty() {
map.insert("labels".into(), json!(filter.labels));
}
if let Some(value) = &filter.query {
map.insert("query".into(), json!(value));
}
Value::Object(map)
}
fn ids(rows: &[RowView]) -> Vec<&str> {
rows.iter().map(|row| row.id.as_str()).collect()
}
#[test]
fn nfr003_cli_tui_mcp_and_portable_report_the_same_ordered_initiative_ids() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let filter = RoadmapFilter::default();
let (stdout, stderr, ok) = cli_args(&tmp, &cli_filter_args(&filter));
assert!(ok, "tix roadmap list failed: {stderr}");
let cli_rows = views_from_cli(&stdout);
let tui_rows = views_from_projection(&tui_projection(&config, &filter));
let mcp_rows = views_from_mcp(&config, &store, &filter);
let portable_rows = views_from_document(&portable_document(&config, &filter));
let expected = vec![
"i-alpha",
"i-partial",
"i-missing",
"i-empty",
"i-zero",
"i-unknown",
"i-undated",
];
assert_eq!(ids(&cli_rows), expected, "CLI order: {stdout}");
assert_eq!(ids(&tui_rows), expected, "TUI order");
assert_eq!(ids(&mcp_rows), expected, "MCP order");
assert_eq!(ids(&portable_rows), expected, "portable order");
let core = |row: &RowView| {
(
row.id.clone(),
row.target_date.clone(),
row.state.clone(),
row.contributions.clone(),
)
};
for (cli, tui, mcp_row, portable) in zip_four(&cli_rows, &tui_rows, &mcp_rows, &portable_rows) {
assert_eq!(
core(&cli),
core(&tui),
"TUI differs from the CLI for {}",
cli.id
);
assert_eq!(
core(&cli),
core(&mcp_row),
"MCP differs from the CLI for {}",
cli.id
);
assert_eq!(
core(&cli),
core(&portable),
"the portable document differs from the CLI for {}",
cli.id
);
}
}
fn zip_four<'a>(
cli: &'a [RowView],
tui: &'a [RowView],
mcp: &'a [RowView],
portable: &'a [RowView],
) -> impl Iterator<Item = (RowView, RowView, RowView, RowView)> + 'a {
assert!(
cli.len() == tui.len() && cli.len() == mcp.len() && cli.len() == portable.len(),
"surface row counts differ: cli={} tui={} mcp={} portable={}",
cli.len(),
tui.len(),
mcp.len(),
portable.len()
);
let zipped: Vec<_> = cli
.iter()
.zip(tui)
.zip(mcp)
.zip(portable)
.map(|(((a, b), c), d)| (a.clone(), b.clone(), c.clone(), d.clone()))
.collect();
zipped.into_iter()
}
#[test]
fn nfr003_every_filter_dimension_and_a_combination_agrees_across_the_surfaces() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let cases: Vec<(RoadmapFilter, Vec<&str>)> = vec![
(
RoadmapFilter {
period_from: Some("2026-02-01".into()),
period_to: Some("2026-05-31".into()),
..RoadmapFilter::default()
},
vec!["i-partial", "i-missing", "i-empty", "i-zero"],
),
(
RoadmapFilter {
owner: Some("BOB".into()), ..RoadmapFilter::default()
},
vec!["i-partial"],
),
(
RoadmapFilter {
status: Some("planning".into()),
..RoadmapFilter::default()
},
vec!["i-partial"],
),
(
RoadmapFilter {
labels: vec!["strategic".into(), "apac".into()], ..RoadmapFilter::default()
},
vec!["i-partial"],
),
(
RoadmapFilter {
query: Some("ghost".into()),
..RoadmapFilter::default()
},
vec!["i-partial", "i-missing"],
),
(
RoadmapFilter {
period_from: Some("2026-01-01".into()),
period_to: Some("2026-01-31".into()),
owner: Some("alice".into()),
status: Some("active".into()),
labels: vec!["strategic".into()],
..RoadmapFilter::default()
},
vec!["i-alpha"],
),
];
for (filter, expected) in cases {
let (stdout, stderr, ok) = cli_args(&tmp, &cli_filter_args(&filter));
assert!(ok, "tix roadmap list {filter:?} failed: {stderr}");
let cli_rows = views_from_cli(&stdout);
let tui_rows = views_from_projection(&tui_projection(&config, &filter));
let mcp_rows = views_from_mcp(&config, &store, &filter);
let portable = portable_document(&config, &filter);
let portable_rows = views_from_document(&portable);
assert_eq!(ids(&cli_rows), expected, "CLI ids for {filter:?}: {stdout}");
assert_eq!(ids(&tui_rows), expected, "TUI ids for {filter:?}");
assert_eq!(ids(&mcp_rows), expected, "MCP ids for {filter:?}");
assert_eq!(ids(&portable_rows), expected, "portable ids for {filter:?}");
let mcp_filters: RoadmapFilter =
serde_json::from_value(mcp_filters_json(&config, &store, &filter)).unwrap();
assert_eq!(mcp_filters, filter, "MCP reports the normalized filter");
assert_eq!(
portable.filters, filter,
"the document reports the normalized filter"
);
}
}
fn mcp_filters_json(config: &Config, store: &Store, filter: &RoadmapFilter) -> Value {
let result = mcp(config, store, "roadmap_list", mcp_filter_args(filter));
assert!(!mcp_is_error(&result), "roadmap_list failed");
mcp_structured(&result)["filters"].clone()
}
#[test]
fn nfr003_every_surface_reports_the_same_summary_provenance_and_policy_versions() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let filter = RoadmapFilter::default();
let (stdout, stderr, ok) = cli_args(&tmp, &cli_filter_args(&filter));
assert!(ok, "tix roadmap list failed: {stderr}");
let cli_rows = views_from_cli(&stdout);
let tui_rows = views_from_projection(&tui_projection(&config, &filter));
let mcp_rows = views_from_mcp(&config, &store, &filter);
let portable_rows = views_from_document(&portable_document(&config, &filter));
let projection = projection_for(&config, &filter);
let expected_states = vec![
("i-alpha", "complete"),
("i-partial", "partial"),
("i-missing", "missing_reference"),
("i-empty", "empty"),
("i-zero", "complete"),
("i-unknown", "unknown"),
("i-undated", "complete"),
];
assert_eq!(
tui_rows
.iter()
.map(|r| (r.id.as_str(), r.state.as_str()))
.collect::<Vec<_>>(),
expected_states
);
for (cli, tui, mcp_row, portable) in zip_four(&cli_rows, &tui_rows, &mcp_rows, &portable_rows) {
assert_eq!(
cli.contributions, tui.contributions,
"contributions differ for `{}`",
cli.id
);
assert_eq!(cli.contributions, mcp_row.contributions, "{}", cli.id);
assert_eq!(cli.contributions, portable.contributions, "{}", cli.id);
}
for row in &projection.initiatives {
let view = tui_rows.iter().find(|v| v.id == row.id).unwrap();
let expected: Vec<Contribution> = row
.summary
.contributions
.iter()
.map(|c| Contribution {
source_entity: c.source_entity(),
resolution: c.resolution.as_str().to_string(),
policy_version: c.policy_version.clone(),
})
.collect();
assert_eq!(view.contributions, expected, "provenance for `{}`", row.id);
assert!(
row.summary
.policy_versions
.iter()
.all(|version| version == tix::health::HEALTH_POLICY_VERSION),
"every policy version is the shared health policy's: {:?}",
row.summary.policy_versions
);
}
for (cli, tui, mcp_row, portable) in zip_four(&cli_rows, &tui_rows, &mcp_rows, &portable_rows) {
assert_eq!(
cli.diagnostic_kinds(),
tui.diagnostic_kinds(),
"diagnostic kinds for `{}`",
cli.id
);
assert_eq!(
cli.diagnostic_kinds(),
mcp_row.diagnostic_kinds(),
"{}",
cli.id
);
assert_eq!(
cli.diagnostic_kinds(),
portable.diagnostic_kinds(),
"{}",
cli.id
);
assert_eq!(tui.diagnostics, mcp_row.diagnostics, "{}", cli.id);
assert_eq!(tui.diagnostics, portable.diagnostics, "{}", cli.id);
}
}
#[test]
fn c004_each_contribution_equals_its_owning_service_and_no_surface_adds_a_figure() {
let (_tmp, config) = parity_fixture();
let store = store_for(&config);
let issues = store.list_all().unwrap();
let sources = tix::commands::roadmap::build_sources(&config, &store, &issues).unwrap();
let projection = tix::roadmap::project(&config, &sources, &RoadmapFilter::default()).unwrap();
let fresh_projects = ProjectIndex::build(&config, &issues);
let fresh_epics = EpicIndex::build(&config, &issues);
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &sources.consumed.done_column);
let mut checked = 0usize;
for row in &projection.initiatives {
for contribution in &row.summary.contributions {
match &contribution.progress {
Some(ProgressValue::Project(report)) => {
let direct = fresh_projects
.health(
&config,
&contribution.target,
&sources.consumed.assigned,
&sources.consumed.overdue,
&sources.consumed.blocked,
)
.expect("the owning project service values the reference");
assert_eq!(
(report.progress.done, report.progress.total),
(direct.progress.done, direct.progress.total),
"the projection's project figure is the service's, for `{}`",
contribution.target
);
assert_eq!(
report.health.overdue, direct.health.overdue,
"the projection forwards the service's health counts"
);
checked += 1;
}
Some(ProgressValue::Epic(rollup)) => {
let direct = fresh_epics
.rollup(
&config,
&contribution.target,
&sources.consumed.targets,
&graph,
)
.expect("the owning epic service values the reference");
assert_eq!(
(rollup.progress.done, rollup.progress.total),
(direct.progress.done, direct.progress.total),
"the projection's epic figure is the service's, for `{}`",
contribution.target
);
checked += 1;
}
None => {}
}
}
}
assert!(checked >= 4, "the fixture exercises both owning services");
for row in &projection.initiatives {
let knowable = row
.summary
.contributions
.iter()
.filter(|c| c.is_knowable())
.count();
assert_eq!(
row.summary.knowable_contributions(),
knowable,
"the summary's only aggregate is a count of knowable contributions"
);
let resolved = row
.summary
.contributions
.iter()
.filter(|c| c.resolution.is_resolved())
.count();
assert_eq!(
row.summary.source_entities.len(),
resolved,
"one provenance entry per resolved contribution, no more"
);
}
let document = portable_document(&config, &RoadmapFilter::default());
for (row, portable_row) in projection.initiatives.iter().zip(&document.rows) {
assert_eq!(row.id, portable_row.id);
for (contribution, portable) in row
.summary
.contributions
.iter()
.zip(&portable_row.summary.contributions)
{
let expected = match &contribution.progress {
Some(ProgressValue::Project(report)) => {
Some((report.progress.done, report.progress.total))
}
Some(ProgressValue::Epic(rollup)) => {
Some((rollup.progress.done, rollup.progress.total))
}
None => None,
};
let actual = portable.value.map(|value| (value.done, value.total));
assert_eq!(
actual, expected,
"the document's value for `{}` is the service's, not a blend",
portable.source_entity
);
}
}
}
#[test]
fn nfr002_a_stable_id_survives_a_round_trip_and_a_rename() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let (stdout, stderr, ok) = cli(&tmp, &["initiative", "show", "i-alpha"]);
assert!(ok, "initiative show failed: {stderr}");
assert!(stdout.contains("id: i-alpha"), "{stdout}");
assert!(stdout.contains("projects: p-alpha"), "{stdout}");
assert!(stdout.contains("epics: e-alpha"), "{stdout}");
let renamed = mcp(
&config,
&store,
"update_initiative",
json!({ "initiative": "i-alpha", "name": "Alpha renamed" }),
);
assert!(!mcp_is_error(&renamed), "update_initiative failed");
assert_eq!(
mcp_structured(&renamed)["id"].as_str(),
Some("i-alpha"),
"the id is unchanged by a rename"
);
let reloaded = Config::load(tmp.path()).unwrap();
let record = reloaded
.initiatives
.get("i-alpha")
.expect("the id is still the registry key");
assert_eq!(record.name, "Alpha renamed");
assert_eq!(record.projects, vec!["p-alpha".to_string()]);
assert_eq!(record.epics, vec!["e-alpha".to_string()]);
assert_eq!(record.milestones, vec!["M-live".to_string()]);
let (listing, stderr, ok) = cli(&tmp, &["initiative", "list"]);
assert!(ok, "initiative list failed: {stderr}");
assert!(
listing.contains("i-alpha\tAlpha renamed"),
"the id is a column of its own: {listing}"
);
let (shown, _, ok) = cli(&tmp, &["initiative", "show", "Alpha renamed"]);
assert!(ok);
assert!(shown.contains("id: i-alpha"), "{shown}");
}
#[test]
fn nfr002_repeated_associations_and_dereferences_are_idempotent_on_every_surface() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let (_, stderr, ok) = cli(
&tmp,
&["initiative", "associate", "i-alpha", "--project", "p-beta"],
);
assert!(ok, "associate failed: {stderr}");
let after_first = snapshot(tmp.path());
let (_, _, ok) = cli(
&tmp,
&["initiative", "associate", "i-alpha", "--project", "p-beta"],
);
assert!(ok);
assert_eq!(
snapshot(tmp.path()),
after_first,
"a repeated CLI association writes nothing"
);
let repeat = mcp(
&config,
&store,
"initiative_associate",
json!({ "initiative": "i-alpha", "kind": "project", "target": "p-alpha" }),
);
assert!(!mcp_is_error(&repeat));
assert_eq!(mcp_structured(&repeat)["changed"], json!(false));
assert_eq!(
snapshot(tmp.path()),
after_first,
"MCP repeat writes nothing"
);
let absent = mcp(
&config,
&store,
"initiative_dereference",
json!({ "initiative": "i-alpha", "kind": "project", "target": "p-gamma" }),
);
assert!(!mcp_is_error(&absent));
assert_eq!(mcp_structured(&absent)["changed"], json!(false));
assert_eq!(
snapshot(tmp.path()),
after_first,
"a no-op dereference writes nothing"
);
}
fn large_fixture(count: u32) -> (Config, Vec<Issue>) {
let mut config = Config::default();
let mut issues = Vec::with_capacity(count as usize);
for index in 0..count {
let project = format!("p-{index:05}");
let epic = format!("e-{index:05}");
config.projects.insert(
project.clone(),
ProjectConfiguration {
name: format!("Project {index}"),
..ProjectConfiguration::default()
},
);
config.epics.insert(
epic.clone(),
EpicConfiguration {
name: format!("Epic {index}"),
..EpicConfiguration::default()
},
);
config.initiatives.insert(
format!("i-{index:05}"),
InitiativeConfiguration {
name: format!("Initiative {index}"),
target_date: Some(format!(
"2026-{:02}-{:02}",
(index % 12) + 1,
(index % 28) + 1
)),
projects: vec![project.clone()],
epics: vec![epic.clone()],
..InitiativeConfiguration::default()
},
);
let number = index + 1;
let raw = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\n\
projects: [\"{project}\"]\nepics: [\"{epic}\"]\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
);
let mut parsed = Issue::parse(&raw).expect("the fixture parses");
parsed.filename = format!("issue-{number}.md");
issues.push(parsed);
}
(config, issues)
}
#[test]
fn nfr001_aggregation_over_ten_thousand_entities_is_one_indexed_pass() {
let _performance = super::serialized_performance_test();
const COUNT: usize = 10_000;
let (config, issues) = large_fixture(COUNT as u32);
let filter = RoadmapFilter::default();
let started = Instant::now();
let sources = RoadmapSources::new(
&config,
&issues,
&HealthPolicy::v1(),
RoadmapConsumed::default(),
);
let projection = tix::roadmap::project(&config, &sources, &filter).expect("the projection");
let full_read = started.elapsed();
assert_eq!(projection.total, COUNT, "every initiative is a row");
assert_eq!(projection.initiatives.len(), COUNT);
let contributions: usize = projection
.initiatives
.iter()
.map(|row| row.summary.contributions.len())
.sum();
assert_eq!(contributions, COUNT * 2, "one contribution per reference");
let sources_reported: usize = projection
.initiatives
.iter()
.map(|row| row.summary.source_entities.len())
.sum();
assert_eq!(sources_reported, COUNT * 2, "every contribution resolves");
assert!(
projection
.initiatives
.iter()
.all(|row| row.summary.state == SummaryState::Complete),
"every reference resolves to a source with a knowable value"
);
assert!(
projection.diagnostics.is_empty(),
"no findings in this fixture"
);
let started = Instant::now();
let joined = tix::roadmap::project(&config, &sources, &filter).expect("the projection");
let join = started.elapsed();
assert_eq!(joined.total, projection.total);
let started = Instant::now();
let one_build = ProjectIndex::build(&config, &issues);
let build = started.elapsed();
assert_eq!(
one_build.buckets().len(),
COUNT,
"one build over 10,000 projects"
);
assert!(
join < build * 500,
"the join over {COUNT} entities took {join:?} against one index build in {build:?}; \
it is rebuilding the index per row rather than joining once"
);
let (half_config, half_issues) = large_fixture((COUNT / 2) as u32);
let half_sources = RoadmapSources::new(
&half_config,
&half_issues,
&HealthPolicy::v1(),
RoadmapConsumed::default(),
);
let started = Instant::now();
let half = tix::roadmap::project(&half_config, &half_sources, &filter).expect("the projection");
let half_join = started.elapsed();
assert_eq!(half.total, COUNT / 2, "the half-size read is a full read");
assert!(
join < half_join * 3,
"the join over {COUNT} entities took {join:?} against {half_join:?} over {}; doubling \
the entity count must roughly double the work, so this is super-linear — a per-row \
rescan rather than one indexed pass",
COUNT / 2
);
assert!(
full_read.as_millis() < 500,
"the full 10,000-entity in-memory read (index load + join + sort) took {full_read:?}, \
past the NFR-001 budget of 500 ms excluding file I/O"
);
println!(
"NFR-001: 10,000 entities ({COUNT} initiatives, {contributions} contributions) — \
full in-memory read {full_read:?}, join alone {join:?}, half-size join {half_join:?}, \
one ProjectIndex build {build:?} (budget 500 ms, file I/O excluded)"
);
}
#[test]
fn c005_a_missing_reference_stays_visible_on_every_surface() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let filter = RoadmapFilter::default();
let (stdout, stderr, ok) = cli_args(&tmp, &cli_filter_args(&filter));
assert!(ok, "{stderr}");
assert!(
stdout.contains("project:p-ghost-initiative") && stdout.contains("[missing]"),
"the CLI row shows the missing reference: {stdout}"
);
let (shown, _, ok) = cli(&tmp, &["roadmap", "show", "i-missing"]);
assert!(ok);
assert!(shown.contains("[missing]"), "{shown}");
let tui = tui_projection(&config, &filter);
let row = tui
.initiatives
.iter()
.find(|row| row.id == "i-missing")
.expect("the missing-reference row is present");
assert_eq!(row.summary.state, SummaryState::MissingReference);
assert!(
row.references.iter().all(|r| !r.resolution.is_resolved()),
"every reference is missing, none dropped"
);
let detail = tix::commands::roadmap::roadmap_row_lines(row).join("\n");
assert!(detail.contains("[missing]"), "{detail}");
let mcp_rows = views_from_mcp(&config, &store, &filter);
let mcp_row = mcp_rows.iter().find(|r| r.id == "i-missing").unwrap();
assert!(
mcp_row
.contributions
.iter()
.all(|c| c.resolution == "missing"),
"{mcp_row:?}"
);
assert!(
mcp_row
.diagnostics
.iter()
.any(|d| d.kind == "missing_reference"
&& d.reference.as_deref() == Some("p-ghost-initiative")),
"{mcp_row:?}"
);
let document = portable_document(&config, &filter);
let portable_row = document.rows.iter().find(|r| r.id == "i-missing").unwrap();
assert_eq!(portable_row.summary.state, "missing_reference");
assert!(
portable_row
.references
.iter()
.all(|r| r.resolution == "missing")
);
assert!(
portable_row
.diagnostics
.iter()
.any(|d| d.kind == "missing_reference"
&& d.reference.as_deref() == Some("p-ghost-initiative")),
"{:?}",
portable_row.diagnostics
);
for rows in [
views_from_cli(&stdout),
views_from_projection(&tui),
mcp_rows,
views_from_document(&document),
] {
assert!(ids(&rows).contains(&"i-missing"), "{rows:?}");
}
}
#[test]
fn c006_empty_unknown_and_zero_are_distinct_states_on_every_surface() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let filter = RoadmapFilter::default();
let (stdout, stderr, ok) = cli_args(&tmp, &cli_filter_args(&filter));
assert!(ok, "{stderr}");
let cli_rows = views_from_cli(&stdout);
let tui_rows = views_from_projection(&tui_projection(&config, &filter));
let mcp_rows = views_from_mcp(&config, &store, &filter);
let portable_rows = views_from_document(&portable_document(&config, &filter));
let projection = projection_for(&config, &filter);
let state_of = |rows: &[RowView], id: &str| -> String {
rows.iter().find(|r| r.id == id).unwrap().state.clone()
};
for rows in [&cli_rows, &tui_rows, &mcp_rows, &portable_rows] {
assert_eq!(
state_of(rows, "i-empty"),
"empty",
"the empty state is explicit"
);
assert_eq!(state_of(rows, "i-unknown"), "unknown");
assert_eq!(
state_of(rows, "i-zero"),
"complete",
"zero progress is a value"
);
}
let empty_row = tui_rows.iter().find(|r| r.id == "i-empty").unwrap();
assert!(empty_row.contributions.is_empty());
assert!(empty_row.diagnostic_kinds().contains(&"empty_scope"));
assert!(
stdout.contains("empty: no source references"),
"the CLI prints its explicit empty state: {stdout}"
);
assert!(
tui_rows
.iter()
.find(|r| r.id == "i-unknown")
.unwrap()
.diagnostic_kinds()
.contains(&"unknown_value"),
"every reference resolving to nothing knowable is `unknown`"
);
let zero_row = projection
.initiatives
.iter()
.find(|row| row.id == "i-zero")
.unwrap();
let zero_contribution = &zero_row.summary.contributions[0];
match &zero_contribution.progress {
Some(ProgressValue::Project(report)) => {
assert_eq!((report.progress.done, report.progress.total), (0, 0));
}
other => panic!("the zero-progress project carries a value, got {other:?}"),
}
assert!(
stdout.contains("progress=0/0"),
"the CLI prints the zero figure rather than a dash: {stdout}"
);
let mcp_zero = mcp_rows.iter().find(|r| r.id == "i-zero").unwrap();
assert_eq!(mcp_zero.contributions.len(), 1);
let document = portable_document(&config, &filter);
let portable_zero = document.rows.iter().find(|r| r.id == "i-zero").unwrap();
assert_eq!(
portable_zero.summary.contributions[0].value,
Some(tix::roadmap::portable::PortableValue { done: 0, total: 0 }),
"the document carries the genuine zero, not `None`"
);
let portable_empty = document.rows.iter().find(|r| r.id == "i-empty").unwrap();
assert!(portable_empty.summary.contributions.is_empty());
}
#[test]
fn c008_every_rejected_mutation_leaves_the_tree_byte_identical() {
let (tmp, config) = parity_fixture();
let store = store_for(&config);
let before = snapshot(tmp.path());
let cli_rejections: Vec<(&str, Vec<&str>)> = vec![
(
"create duplicate name",
vec!["initiative", "create", "Alpha portfolio"],
),
(
"create malformed project id",
vec!["initiative", "create", "New", "--projects", "not an id!"],
),
(
"edit with no field flag",
vec!["initiative", "edit", "i-alpha"],
),
(
"edit malformed target date",
vec![
"initiative",
"edit",
"i-alpha",
"--target-date",
"2026-13-01",
],
),
(
"delete unknown id",
vec!["initiative", "delete", "nosuch", "--yes"],
),
(
"associate malformed target",
vec![
"initiative",
"associate",
"i-alpha",
"--project",
"not an id!",
],
),
(
"associate unknown initiative",
vec!["initiative", "associate", "nosuch", "--project", "p-alpha"],
),
(
"associate with no kind flag",
vec!["initiative", "associate", "i-alpha"],
),
(
"dereference malformed target",
vec![
"initiative",
"dereference",
"i-alpha",
"--project",
"not an id!",
],
),
];
for (label, args) in cli_rejections {
let (_, stderr, ok) = cli(&tmp, &args);
assert!(!ok, "refusal expected: {label}");
assert!(!stderr.is_empty(), "refusal must say why: {label}");
assert_eq!(
snapshot(tmp.path()),
before,
"{label}: wrote a byte (tix.toml must stay identical and append nothing)"
);
}
let mcp_rejections: Vec<(&str, &str, Value)> = vec![
(
"create_initiative duplicate",
"create_initiative",
json!({ "name": "Alpha portfolio" }),
),
(
"update_initiative with no fields",
"update_initiative",
json!({ "initiative": "i-alpha" }),
),
(
"delete_initiative without confirm",
"delete_initiative",
json!({ "initiative": "i-alpha" }),
),
(
"delete_initiative unknown id",
"delete_initiative",
json!({ "initiative": "nosuch", "confirm": true }),
),
(
"initiative_associate bad kind",
"initiative_associate",
json!({ "initiative": "i-alpha", "kind": "label", "target": "x" }),
),
(
"initiative_associate malformed target",
"initiative_associate",
json!({ "initiative": "i-alpha", "kind": "project", "target": "not an id!" }),
),
(
"initiative_dereference unknown initiative",
"initiative_dereference",
json!({ "initiative": "nosuch", "kind": "project", "target": "p-alpha" }),
),
];
for (label, tool, args) in mcp_rejections {
let result = mcp(&config, &store, tool, args);
assert!(mcp_is_error(&result), "refusal expected: {label}: {result}");
assert_eq!(snapshot(tmp.path()), before, "{label}: wrote a byte");
}
}
#[test]
fn boundary_lint_reports_issue_held_references_but_not_initiative_references() {
let (_tmp, config) = parity_fixture();
let store = store_for(&config);
let findings = tix::commands::lint::collect(&config, &store).unwrap();
assert!(
findings.iter().any(|finding| {
finding.kind == tix::projects::MISSING_REFERENCE_KIND
&& finding.value == "p-ghost-issue"
}),
"lint reports the issue-held dangling project: {findings:#?}"
);
assert!(
!findings
.iter()
.any(|finding| finding.value == "p-ghost-initiative"
|| finding.message.contains("p-ghost-initiative")),
"lint does not report an initiative's config-held reference: {findings:#?}"
);
let row = projection_for(&config, &RoadmapFilter::default())
.initiatives
.into_iter()
.find(|row| row.id == "i-missing")
.unwrap();
assert!(row.diagnostics.iter().any(|finding| {
finding.kind.as_str() == "missing_reference"
&& finding.reference.as_deref() == Some("p-ghost-initiative")
}));
}