use std::fs;
use tempfile::TempDir;
use tix::analytics::diagnostics::AnalyticsDiagnosticKind;
use tix::analytics::events::{AnalyticsScope, Period};
use tix::analytics::projection::{
PROJECTION_FORMULA_VERSION, Projection, ProjectionStatus, project,
};
use tix::analytics::series::{
BurndownSeries, Coverage, DailyPoint, ESTIMATE_FORMULA_VERSION, Mode, ScopeSnapshot,
};
use tix::analytics::units::{EstimateConversion, declared_units, estimate_series};
use tix::capacity::estimate::EstimateValue;
use tix::config::Config;
use tix::cycle_schedule::identity::ScheduleIndex;
use tix::model::{History, HistoryEvent, HistoryEventKind, Issue};
use tix::storage::Store;
fn unit_config(units: &str) -> (TempDir, Config) {
let dir = TempDir::new().unwrap();
let issues = dir.path().join("issues");
fs::create_dir_all(&issues).unwrap();
fs::write(
dir.path().join("tix.toml"),
format!(
"issues_dir = {issues:?}\n\
[kanban.default]\ncolumns = [\"todo\", \"doing\", \"done\"]\n\
[kanban.default.wip.units]\n{units}\n",
issues = issues.to_string_lossy(),
),
)
.unwrap();
let config = Config::load(dir.path()).expect("the fixture loads");
(dir, config)
}
fn config() -> (TempDir, Config) {
unit_config("points = 1\nh = 5")
}
fn index() -> ScheduleIndex {
ScheduleIndex::default()
}
fn recorded(timestamp: &str, kind: HistoryEventKind, detail: &str) -> HistoryEvent {
HistoryEvent::new(timestamp, "alice", kind, detail)
}
fn scope_addition(timestamp: &str) -> HistoryEvent {
recorded(timestamp, HistoryEventKind::ParentSet, "parent none -> #1")
}
fn completion(timestamp: &str) -> HistoryEvent {
recorded(
timestamp,
HistoryEventKind::TransitionAccepted,
"todo -> shipped (unstarted -> completed)",
)
}
fn estimate_change(before: Option<&str>, after: Option<&str>) -> HistoryEvent {
recorded(
"2026-01-07T09:00:00Z",
HistoryEventKind::EstimateChanged,
&format!(
"estimate: {} -> {}",
before.unwrap_or("none"),
after.unwrap_or("none")
),
)
}
fn issue_markdown(number: u32, estimate: Option<&str>, events: &[HistoryEvent]) -> String {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
cycles:\n - Sprint 7\n"
);
if let Some(estimate) = estimate {
frontmatter.push_str(&format!("estimate: \"{estimate}\"\n"));
}
frontmatter.push_str("---\n");
let mut history = History::new();
for event in events {
history.append(event.clone());
}
format!("{frontmatter}{}", history.render())
}
fn issue(number: u32, estimate: Option<&str>, events: &[HistoryEvent]) -> Issue {
Issue::parse(&issue_markdown(number, estimate, events)).unwrap()
}
fn unestimated(number: u32, events: &[HistoryEvent]) -> Issue {
Issue::parse(&issue_markdown(number, None, events)).unwrap()
}
fn cycle_scope(start: Option<&str>, end: Option<&str>) -> AnalyticsScope {
AnalyticsScope::cycle(
"Sprint 7",
Period::new(start.map(str::to_string), end.map(str::to_string)).unwrap(),
)
.unwrap()
}
fn snapshot_of(issues: &[Issue], scope: &AnalyticsScope) -> ScopeSnapshot {
ScopeSnapshot::build(&Config::default(), &index(), issues, scope)
}
fn estimate_of(
issues: &[Issue],
scope: &AnalyticsScope,
config: &Config,
unit: &str,
) -> BurndownSeries {
estimate_series(&snapshot_of(issues, scope), issues, config, unit)
}
fn diagnostics_of_kind(series: &BurndownSeries, kind: AnalyticsDiagnosticKind) -> usize {
series.diagnostics_of(kind).count()
}
fn point(series: &BurndownSeries, date: &str) -> DailyPoint {
series
.point_on(date)
.unwrap_or_else(|| panic!("the series covers {date}"))
.clone()
}
#[test]
fn t013_the_estimate_fold_converts_through_the_shared_unit_table() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-06"));
let issues = [
issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T09:00:00Z")],
),
issue(2, Some("2hours"), &[scope_addition("2026-01-05T10:00:00Z")]),
issue(3, Some("4"), &[scope_addition("2026-01-05T11:00:00Z")]),
];
let series = estimate_of(&issues, &scope, &config, "points");
assert_eq!(series.mode, Mode::Estimate);
assert_eq!(series.unit.as_deref(), Some("points"));
assert_eq!(series.formula_version, ESTIMATE_FORMULA_VERSION);
let day = point(&series, "2026-01-05");
assert_eq!(day.scope_added, Some(17));
assert_eq!(day.scope_total, Some(17));
assert_eq!(day.remaining, Some(17));
assert_eq!(day.coverage, Coverage::Known);
for (number, stated, expected) in [(1u32, "3points", 3u32), (2, "2hours", 10), (3, "4", 4)] {
let direct = EstimateValue::parse(number, stated).converted_to(
number,
&config.default_config().wip,
"points",
);
assert_eq!(direct.converted_value, Some(expected));
let via_resolver = EstimateConversion::resolve(&config, "points")
.unwrap()
.value_of(number, Some(stated));
assert_eq!(via_resolver.converted_value, direct.converted_value);
}
assert!(
EstimateConversion::resolve(&config, "weeks").is_none(),
"a unit the board does not declare does not resolve"
);
let mut declared = declared_units(&config);
declared.sort();
assert_eq!(declared, vec!["h", "points"]);
}
#[test]
fn t014_the_estimate_series_is_the_count_series_scaled() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [
issue(
1,
Some("2points"),
&[
scope_addition("2026-01-05T09:00:00Z"),
completion("2026-01-07T09:00:00Z"),
],
),
issue(
2,
Some("2points"),
&[
scope_addition("2026-01-05T10:00:00Z"),
completion("2026-01-08T09:00:00Z"),
],
),
];
let snapshot = snapshot_of(&issues, &scope);
let count = snapshot.series(Mode::Count);
let estimate = estimate_series(&snapshot, &issues, &config, "points");
assert_eq!(count.len(), 5);
assert_eq!(estimate.len(), count.len());
for finding in &count.diagnostics {
assert!(
estimate.diagnostics.contains(finding),
"the estimate series keeps the count series' context: {finding}"
);
}
assert_eq!(
count.diagnostics.len(),
estimate.diagnostics.len(),
"an all-compatible estimate series adds no finding of its own"
);
for (counted, valued) in count.points.iter().zip(&estimate.points) {
assert_eq!(counted.date, valued.date);
assert_eq!(counted.event_ids, valued.event_ids, "same provenance");
assert_eq!(counted.coverage, valued.coverage, "same coverage states");
let day = counted.date.as_str();
assert_eq!(
valued.scope_total,
counted.scope_total.map(|value| value * 2),
"total on {day}"
);
assert_eq!(
valued.remaining,
counted.remaining.map(|value| value * 2),
"remaining on {day}"
);
assert_eq!(
valued.completed,
counted.completed.map(|value| value * 2),
"completed on {day}"
);
assert_eq!(
valued.scope_added,
counted.scope_added.map(|value| value * 2),
"added on {day}"
);
assert_eq!(
valued.completions,
counted.completions.map(|value| value * 2),
"completions on {day}"
);
}
}
#[test]
fn t014_an_estimate_change_is_reported_and_never_replayed() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("3points"),
&[
scope_addition("2026-01-05T09:00:00Z"),
estimate_change(Some("1points"), Some("3points")),
],
)];
let series = estimate_of(&issues, &scope, &config, "points");
assert_eq!(point(&series, "2026-01-05").scope_added, Some(3));
assert_eq!(point(&series, "2026-01-09").scope_total, Some(3));
let day = point(&series, "2026-01-07");
assert_eq!(day.coverage, Coverage::CoverageLimited);
let findings: Vec<&str> = series
.diagnostics_of(AnalyticsDiagnosticKind::MissingHistory)
.map(|finding| finding.message.as_str())
.collect();
assert_eq!(
findings
.iter()
.filter(|message| message.contains("estimate change"))
.count(),
1,
"the recorded re-sizing is reported once: {findings:?}"
);
}
#[test]
fn t015_an_undeclared_unit_folds_no_points_and_one_finding() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T09:00:00Z")],
)];
let series = estimate_of(&issues, &scope, &config, "weeks");
assert!(series.is_empty(), "no unit, no estimate point");
assert_eq!(series.unit.as_deref(), Some("weeks"));
assert_eq!(series.formula_version, ESTIMATE_FORMULA_VERSION);
assert_eq!(
diagnostics_of_kind(&series, AnalyticsDiagnosticKind::MissingConversion),
1
);
let finding = series
.diagnostics_of(AnalyticsDiagnosticKind::MissingConversion)
.next()
.unwrap();
assert!(finding.message.contains("`weeks`"), "{}", finding.message);
assert!(
finding.message.contains("`points`"),
"the declared table is named: {}",
finding.message
);
assert!(finding.remedy.is_some(), "the finding is actionable");
}
#[test]
fn t015_a_mixed_unit_series_is_a_lower_bound_with_a_finding_and_a_marker() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-06"));
let issues = [
issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T09:00:00Z")],
),
issue(2, Some("2weeks"), &[scope_addition("2026-01-05T09:00:00Z")]),
];
let series = estimate_of(&issues, &scope, &config, "points");
let day = point(&series, "2026-01-05");
assert_eq!(day.scope_added, Some(3), "the valued contribution only");
assert_ne!(day.scope_added, Some(5), "the mixed unit is never summed");
assert_eq!(
day.coverage,
Coverage::CoverageLimited,
"a partial day is limited"
);
assert_eq!(
diagnostics_of_kind(&series, AnalyticsDiagnosticKind::MissingConversion),
1
);
let finding = series
.diagnostics_of(AnalyticsDiagnosticKind::MissingConversion)
.next()
.unwrap();
assert!(
finding.message.contains("`scope_added`"),
"the finding is field-aware: {}",
finding.message
);
assert!(
finding.message.contains("`weeks` uses estimate unit")
|| finding.message.contains("`2weeks`"),
"the shared reason is quoted: {}",
finding.message
);
assert_eq!(
finding.event_id.as_deref(),
Some("2#0"),
"the finding names the contribution it is about"
);
}
#[test]
fn t015_an_unestimated_member_is_a_finding_not_a_zero() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-06"));
let issues = [
issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T09:00:00Z")],
),
unestimated(2, &[scope_addition("2026-01-05T09:00:00Z")]),
];
let series = estimate_of(&issues, &scope, &config, "points");
let day = point(&series, "2026-01-05");
assert_eq!(day.scope_added, Some(3));
assert_ne!(
day.scope_added,
Some(6),
"an unestimated member is not a guess"
);
assert_eq!(day.coverage, Coverage::CoverageLimited);
let finding = series
.diagnostics_of(AnalyticsDiagnosticKind::MissingHistory)
.find(|finding| finding.message.contains("unestimated"))
.expect("the unestimated member is reported");
assert_eq!(finding.event_id.as_deref(), Some("2#0"));
assert!(
finding
.remedy
.as_deref()
.unwrap_or("")
.contains("state the estimate"),
"the remedy is the action: {:?}",
finding.remedy
);
}
#[test]
fn t015_the_coverage_summary_counts_the_limited_days() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T09:00:00Z")],
)];
let series = estimate_of(&issues, &scope, &config, "points");
let coverage = series.coverage();
assert_eq!(coverage.total(), 5);
assert_eq!(coverage.known, 1, "the observed, fully valued day");
assert_eq!(
coverage.coverage_limited, 4,
"the quiet days carry the level"
);
assert_eq!(coverage.unknown, 0);
}
#[test]
fn t016_the_projection_follows_the_documented_formula() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [
issue(
1,
Some("1points"),
&[
scope_addition("2026-01-05T09:00:00Z"),
completion("2026-01-07T09:00:00Z"),
],
),
issue(
2,
Some("1points"),
&[
scope_addition("2026-01-05T10:00:00Z"),
completion("2026-01-07T10:00:00Z"),
],
),
issue(
3,
Some("1points"),
&[scope_addition("2026-01-05T11:00:00Z")],
),
issue(
4,
Some("1points"),
&[scope_addition("2026-01-05T12:00:00Z")],
),
];
let series = estimate_of(&issues, &scope, &config, "points");
assert_eq!(series.point_on("2026-01-07").unwrap().remaining, Some(2));
let projection = project(&series, "2026-01-07", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::Projected);
assert_eq!(projection.remaining, Some(2));
assert_eq!(projection.observed_rate, Some(1.0));
assert_eq!(
projection.projected_completion.as_deref(),
Some("2026-01-09")
);
assert_eq!(projection.meets_end_date, Some(true));
assert_eq!(projection.formula_version, PROJECTION_FORMULA_VERSION);
assert_eq!(projection.as_of, "2026-01-07");
assert_eq!(projection.end_date, "2026-01-09");
assert_eq!(projection.unit.as_deref(), Some("points"));
assert!(
projection.diagnostics.is_empty(),
"a projection needs no excuse"
);
assert!(projection.is_projected());
assert_eq!(projection.projected_completion(), Some("2026-01-09"));
let early = project(&series, "2026-01-07", "2026-01-08").unwrap();
assert_eq!(early.projected_completion.as_deref(), Some("2026-01-09"));
assert_eq!(early.meets_end_date, Some(false));
assert_eq!(early.status, ProjectionStatus::Projected);
}
#[test]
fn t016_a_completed_scope_projects_on_its_origin() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("2points"),
&[
scope_addition("2026-01-05T09:00:00Z"),
completion("2026-01-06T09:00:00Z"),
],
)];
let series = estimate_of(&issues, &scope, &config, "points");
assert_eq!(series.point_on("2026-01-06").unwrap().remaining, Some(0));
let projection = project(&series, "2026-01-06", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::Projected);
assert_eq!(projection.remaining, Some(0));
assert_eq!(
projection.projected_completion.as_deref(),
Some("2026-01-06")
);
assert_eq!(projection.meets_end_date, Some(true));
}
#[test]
fn t016_a_fractional_rate_rounds_the_projected_day_up() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-12"));
let mut issues = Vec::new();
for number in 1..=7u32 {
let mut events = vec![scope_addition("2026-01-05T09:00:00Z")];
if number <= 2 {
events.push(completion("2026-01-08T09:00:00Z"));
}
issues.push(issue(number, Some("1points"), &events));
}
let series = estimate_of(&issues, &scope, &config, "points");
assert_eq!(series.point_on("2026-01-08").unwrap().remaining, Some(5));
let projection = project(&series, "2026-01-08", "2026-01-12").unwrap();
assert_eq!(projection.status, ProjectionStatus::Projected);
assert_eq!(projection.remaining, Some(5));
assert_eq!(projection.observed_rate, Some(2.0 / 3.0));
assert_eq!(
projection.projected_completion.as_deref(),
Some("2026-01-16")
);
assert_eq!(projection.meets_end_date, Some(false));
}
#[test]
fn t017_nothing_recorded_is_insufficient_data_not_a_finished_scope() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("3points"),
&[scope_addition("2025-12-01T09:00:00Z")],
)];
let series = estimate_of(&issues, &scope, &config, "points");
let last = point(&series, "2026-01-09");
assert_eq!(last.remaining, Some(0), "the empty levels read zero");
assert_eq!(last.completed, Some(0));
assert_eq!(
last.coverage,
Coverage::CoverageLimited,
"and it is not claimed as known"
);
let projection = project(&series, "2026-01-09", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::InsufficientData);
assert_eq!(
projection.projected_completion, None,
"an empty scope is not finished"
);
assert_eq!(projection.meets_end_date, None);
assert!(
projection.diagnostics[0]
.message
.contains("empty rather than finished"),
"{}",
projection.diagnostics[0].message
);
assert!(projection.diagnostics[0].remedy.is_some());
}
#[test]
fn t017_an_unshiftable_magnitude_is_unknown_and_never_a_crash() {
let (_dir, config) = config();
let scope = cycle_scope(Some("1970-01-01"), Some("2170-01-01"));
let absurd = [
issue(
1,
Some("2000000000points"),
&[scope_addition("1970-01-01T09:00:00Z")],
),
issue(
2,
Some("1points"),
&[
scope_addition("1970-01-01T10:00:00Z"),
completion("2170-01-01T09:00:00Z"),
],
),
];
let series = estimate_of(&absurd, &scope, &config, "points");
assert_eq!(series.len(), 73_050, "the whole window is enumerated");
assert_eq!(
series.point_on("2170-01-01").unwrap().remaining,
Some(2_000_000_000)
);
let projection = project(&series, "2170-01-01", "2170-01-01").unwrap();
assert_eq!(projection.status, ProjectionStatus::Unknown);
assert_eq!(
projection.projected_completion, None,
"no date, and no panic"
);
assert_eq!(projection.meets_end_date, None);
assert_eq!(projection.remaining, Some(2_000_000_000));
assert_eq!(projection.observed_rate, Some(1.0 / 73_049.0));
assert!(
projection.diagnostics[0]
.message
.contains("outside the calendar"),
"{}",
projection.diagnostics[0].message
);
let modest = [
issue(
3,
Some("20points"),
&[scope_addition("1970-01-01T09:00:00Z")],
),
issue(
4,
Some("1points"),
&[
scope_addition("1970-01-01T10:00:00Z"),
completion("2170-01-01T09:00:00Z"),
],
),
];
let series = estimate_of(&modest, &scope, &config, "points");
let projection = project(&series, "2170-01-01", "2170-01-01").unwrap();
assert_eq!(projection.status, ProjectionStatus::Projected);
assert_eq!(projection.remaining, Some(20));
assert_eq!(
projection.projected_completion.as_deref(),
Some("6170-01-11")
);
assert_eq!(projection.meets_end_date, Some(false));
}
#[test]
fn t016_a_date_that_is_not_a_calendar_day_is_refused() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let series = estimate_of(
&[issue(
1,
Some("1points"),
&[scope_addition("2026-01-05T09:00:00Z")],
)],
&scope,
&config,
"points",
);
for (as_of, end_date, field) in [
("2026-1-7", "2026-01-09", "as_of"),
("2026-01-07", "next week", "end_date"),
("not-a-day", "2026-01-09", "as_of"),
] {
let error = project(&series, as_of, end_date).expect_err("refused");
let rendered = error.to_string();
assert!(
rendered.contains("invalid_period") && rendered.contains(field),
"{rendered}"
);
}
}
#[test]
fn t017_an_origin_outside_the_series_is_insufficient_data() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let series = estimate_of(
&[issue(
1,
Some("1points"),
&[scope_addition("2026-01-05T09:00:00Z")],
)],
&scope,
&config,
"points",
);
let projection = project(&series, "2026-01-20", "2026-01-31").unwrap();
assert_eq!(projection.status, ProjectionStatus::InsufficientData);
assert_eq!(projection.projected_completion, None);
assert_eq!(projection.meets_end_date, None);
assert_eq!(projection.observed_rate, None);
assert_eq!(
diagnostics_of_projection(&projection, AnalyticsDiagnosticKind::ProjectionUnknown),
1
);
let finding = &projection.diagnostics[0];
assert!(
finding.message.contains("2026-01-05..2026-01-09"),
"{}",
finding.message
);
assert!(finding.remedy.is_some());
}
#[test]
fn t017_an_origin_with_no_elapsed_day_is_insufficient_data() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let series = estimate_of(
&[issue(
1,
Some("1points"),
&[scope_addition("2026-01-05T09:00:00Z")],
)],
&scope,
&config,
"points",
);
let projection = project(&series, "2026-01-05", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::InsufficientData);
assert_eq!(projection.projected_completion, None);
assert_eq!(projection.remaining, Some(1), "the level itself is known");
assert!(projection.observed_rate.is_none(), "no window, no rate");
assert!(
projection.diagnostics[0].message.contains("no elapsed day"),
"{}",
projection.diagnostics[0].message
);
}
#[test]
fn t017_no_recorded_completion_is_unknown_not_a_date() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let series = estimate_of(
&[issue(
1,
Some("4points"),
&[scope_addition("2026-01-05T09:00:00Z")],
)],
&scope,
&config,
"points",
);
let projection = project(&series, "2026-01-09", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::Unknown);
assert_eq!(projection.observed_rate, Some(0.0), "observed, and zero");
assert_eq!(projection.remaining, Some(4));
assert_eq!(projection.projected_completion, None);
assert_eq!(projection.meets_end_date, None);
assert_eq!(
diagnostics_of_projection(&projection, AnalyticsDiagnosticKind::ProjectionUnknown),
1
);
assert!(!projection.is_projected());
}
#[test]
fn t017_an_unanswerable_remaining_is_insufficient_data() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let series = estimate_of(
&[issue(
1,
Some("3points"),
&[completion("2026-01-07T09:00:00Z")],
)],
&scope,
&config,
"points",
);
let day = point(&series, "2026-01-07");
assert_eq!(day.completed, Some(3));
assert_eq!(day.remaining, None, "no origin in the period, no remaining");
assert_eq!(day.coverage, Coverage::CoverageLimited);
assert!(
series
.diagnostics_of(AnalyticsDiagnosticKind::MissingHistory)
.any(|finding| finding
.message
.contains("cannot be answered from the period alone")),
"the level's own reason is reported"
);
let projection = project(&series, "2026-01-07", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::InsufficientData);
assert_eq!(projection.remaining, None);
assert_eq!(projection.projected_completion, None);
assert!(
projection.diagnostics[0]
.message
.contains("is not known from the record"),
"{}",
projection.diagnostics[0].message
);
}
#[test]
fn t017_an_empty_series_cannot_be_projected() {
let (_dir, config) = config();
let scope = AnalyticsScope::cycle(
"Sprint 7",
Period {
start: Some("2026-01-09".to_string()),
end: Some("2026-01-05".to_string()),
},
)
.unwrap();
let issues = [issue(
1,
Some("1points"),
&[scope_addition("2026-01-07T09:00:00Z")],
)];
let series = estimate_of(&issues, &scope, &config, "points");
assert!(series.is_empty());
assert_eq!(
diagnostics_of_kind(&series, AnalyticsDiagnosticKind::InvalidPeriod),
1,
"the period's own refusal travels with the estimate series"
);
let projection = project(&series, "2026-01-07", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::InsufficientData);
assert_eq!(projection.projected_completion, None);
assert!(
projection.diagnostics[0].message.contains("no day at all"),
"{}",
projection.diagnostics[0].message
);
}
#[test]
fn t018_count_and_estimate_projections_agree_on_the_date() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [
issue(
1,
Some("2points"),
&[
scope_addition("2026-01-05T09:00:00Z"),
completion("2026-01-07T09:00:00Z"),
],
),
issue(
2,
Some("2points"),
&[scope_addition("2026-01-05T10:00:00Z")],
),
issue(
3,
Some("2points"),
&[scope_addition("2026-01-05T11:00:00Z")],
),
];
let snapshot = snapshot_of(&issues, &scope);
let counted = snapshot.series(Mode::Count);
let valued = estimate_series(&snapshot, &issues, &config, "points");
let count_projection = project(&counted, "2026-01-07", "2026-01-09").unwrap();
let estimate_projection = project(&valued, "2026-01-07", "2026-01-09").unwrap();
assert_eq!(count_projection.remaining, Some(2));
assert_eq!(count_projection.observed_rate, Some(0.5));
assert_eq!(
count_projection.projected_completion.as_deref(),
Some("2026-01-11")
);
assert_eq!(count_projection.unit, None, "counts are unitless");
assert_eq!(estimate_projection.remaining, Some(4));
assert_eq!(estimate_projection.observed_rate, Some(1.0));
assert_eq!(
estimate_projection.projected_completion, count_projection.projected_completion,
"the rate and the remaining work scale together, so the date does not move"
);
assert_eq!(estimate_projection.unit.as_deref(), Some("points"));
assert_eq!(estimate_projection.mode, Mode::Estimate);
assert_eq!(estimate_projection.meets_end_date, Some(false));
assert_eq!(
count_projection.meets_end_date,
estimate_projection.meets_end_date
);
assert_eq!(
project(&valued, "2026-01-07", "2026-01-09").unwrap(),
estimate_projection
);
}
#[test]
fn c007_two_runs_and_a_reversed_input_order_agree() {
let (_dir, config) = config();
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [
issue(
2,
Some("2points"),
&[
scope_addition("2026-01-06T10:00:00Z"),
estimate_change(Some("1points"), Some("2points")),
],
),
issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T09:00:00Z")],
),
unestimated(3, &[scope_addition("2026-01-07T10:00:00Z")]),
];
let reversed: Vec<Issue> = issues.iter().rev().cloned().collect();
let forward = estimate_of(&issues, &scope, &config, "points");
let backward = estimate_of(&reversed, &scope, &config, "points");
let again = estimate_of(&issues, &scope, &config, "points");
assert_eq!(forward.points, backward.points);
assert_eq!(forward.diagnostics, backward.diagnostics);
assert_eq!(forward.points, again.points);
assert_eq!(forward.diagnostics, again.diagnostics);
assert_eq!(forward.unit, backward.unit);
assert_eq!(forward.mode, backward.mode);
let first = project(&forward, "2026-01-08", "2026-01-09").unwrap();
let second = project(&backward, "2026-01-08", "2026-01-09").unwrap();
assert_eq!(first, second);
let rendered: Vec<String> = forward.diagnostics.iter().map(|d| d.render()).collect();
let rerendered: Vec<String> = again.diagnostics.iter().map(|d| d.render()).collect();
assert_eq!(rendered, rerendered);
}
fn diagnostics_of_projection(projection: &Projection, kind: AnalyticsDiagnosticKind) -> usize {
projection
.diagnostics
.iter()
.filter(|finding| finding.kind == kind)
.count()
}
#[test]
fn the_real_exercise_folds_a_temp_project_into_an_estimate_series_and_a_projection() {
let tmp = TempDir::new().unwrap();
let issues_dir = tmp.path().join("issues");
fs::create_dir_all(&issues_dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut file_config = Config {
issues_dir: issues_dir.clone(),
milestones_dir: Some(tmp.path().join("docs/milestones")),
..Config::default()
};
file_config.layout.directory = "{assignee}".to_string();
file_config.layout.move_on_change = false;
file_config
.kanban
.configurations
.get_mut("default")
.expect("the default board")
.wip
.units
.insert("points".to_string(), 1);
file_config
.kanban
.configurations
.get_mut("default")
.expect("the default board")
.wip
.units
.insert("h".to_string(), 5);
file_config.save(&config_path).unwrap();
let store = Store {
issues_dir,
config: Config::load_with_warning(&config_path, |_| {}).unwrap(),
};
let write = |number: u32, body: String| {
fs::write(
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md")),
body,
)
.unwrap();
};
write(
1,
issue_markdown(
1,
Some("3points"),
&[
scope_addition("2026-01-05T09:00:00Z"),
completion("2026-01-08T09:00:00Z"),
],
),
);
write(
2,
issue_markdown(2, Some("2weeks"), &[scope_addition("2026-01-06T09:00:00Z")]),
);
write(
3,
issue_markdown(3, Some("2hours"), &[scope_addition("2026-01-06T10:00:00Z")]),
);
write(
4,
issue_markdown(4, None, &[scope_addition("2026-01-07T09:00:00Z")]),
);
let issues = store.list_all().unwrap();
assert_eq!(issues.len(), 4, "the project's four issues were read once");
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let snapshot = ScopeSnapshot::build(&store.config, &index(), &issues, &scope);
let series = estimate_series(&snapshot, &issues, &store.config, "points");
assert_eq!(series.mode, Mode::Estimate);
assert_eq!(series.unit.as_deref(), Some("points"));
assert_eq!(point(&series, "2026-01-06").scope_added, Some(10));
assert_eq!(point(&series, "2026-01-07").scope_added, Some(0));
assert_eq!(point(&series, "2026-01-09").scope_total, Some(13));
assert_eq!(
diagnostics_of_kind(&series, AnalyticsDiagnosticKind::MissingConversion),
1
);
assert_eq!(
series
.diagnostics_of(AnalyticsDiagnosticKind::MissingHistory)
.filter(|finding| finding.message.contains("unestimated"))
.count(),
1,
"the unestimated issue is reported"
);
assert!(
series
.diagnostics_of(AnalyticsDiagnosticKind::MissingHistory)
.any(|finding| finding.message.contains("carry no recorded history")),
"the trailing quiet day is reported in the count fold's words"
);
let projection = project(&series, "2026-01-05", "2026-01-09").unwrap();
assert_eq!(projection.status, ProjectionStatus::InsufficientData);
assert_eq!(projection.projected_completion, None);
assert_eq!(projection.remaining, Some(3), "the level is known even so");
let answerable = project(&series, "2026-01-08", "2026-01-09").unwrap();
assert_eq!(answerable.status, ProjectionStatus::Projected);
assert_eq!(answerable.remaining, Some(10));
assert_eq!(answerable.observed_rate, Some(1.0));
assert_eq!(
answerable.projected_completion.as_deref(),
Some("2026-01-18")
);
assert_eq!(answerable.meets_end_date, Some(false));
for day in &series.points {
println!("{}", render_point(day));
}
for finding in &series.diagnostics {
println!("{}", finding.render());
}
println!(
"projection: status={} as_of={} end={} remaining={:?} rate={:?} completion={:?}",
projection.status,
projection.as_of,
projection.end_date,
projection.remaining,
projection.observed_rate,
projection.projected_completion,
);
for finding in &projection.diagnostics {
println!("{}", finding.render());
}
}
fn render_point(point: &DailyPoint) -> String {
let value = |number: Option<i64>| match number {
Some(number) => number.to_string(),
None => "?".to_string(),
};
format!(
"{date} total={total} remaining={remaining} completed={completed} added={added} \
completions={completions} coverage={coverage}",
date = point.date,
total = value(point.scope_total),
remaining = value(point.remaining),
completed = value(point.completed),
added = value(point.scope_added),
completions = value(point.completions),
coverage = point.coverage,
)
}