use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tix::analytics::chart::{
ChartExport, FORMAT, SCHEMA_VERSION, UNSUPPORTED_REMOTE_FIELD, VERSION_MISMATCH, precision_of,
};
use tix::analytics::diagnostics::AnalyticsDiagnosticKind;
use tix::analytics::events::{AnalyticsScope, Period, ScopeKind};
use tix::analytics::projection::{PROJECTION_FORMULA_VERSION, Projection, ProjectionStatus};
use tix::analytics::series::{
BurndownSeries, COUNT_FORMULA_VERSION, Coverage, CoverageSummary, ESTIMATE_FORMULA_VERSION,
Mode, ScopeSnapshot,
};
use tix::analytics::units::estimate_series;
use tix::commands::export::{chart_document, cycle_scope_for, run, run_chart_core};
use tix::config::Config;
use tix::cycle_schedule::identity::ScheduleIndex;
use tix::cycle_store::{CycleRecord, CycleStore};
use tix::model::{History, HistoryEvent, HistoryEventKind, Issue};
use tix::storage::Store;
use tix::sync::Tool;
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_text(number: u32, cycle: &str, estimate: Option<&str>, events: &[HistoryEvent]) -> String {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
cycles:\n - {cycle}\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_markdown(number: u32, estimate: Option<&str>, events: &[HistoryEvent]) -> String {
issue_text(number, "Sprint 7", estimate, events)
}
fn issue(number: u32, estimate: Option<&str>, events: &[HistoryEvent]) -> Issue {
Issue::parse(&issue_markdown(number, estimate, 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 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 sprint_series() -> BurndownSeries {
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [
issue(
1,
None,
&[
scope_addition("2026-01-05T10:00:00Z"),
completion("2026-01-07T10:00:00Z"),
],
),
issue(
2,
None,
&[
scope_addition("2026-01-06T10:00:00Z"),
completion("2026-01-08T10:00:00Z"),
],
),
];
snapshot_of(&issues, &scope).series(Mode::Count)
}
fn value<'a>(json: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut current = json;
for key in path.split('.') {
current = match current {
serde_json::Value::Array(items) => items.get(key.parse::<usize>().ok()?)?,
_ => current.get(key)?,
};
}
Some(current)
}
fn findings_of_kind<'a>(
document: &'a ChartExport,
kind: &str,
) -> Vec<&'a tix::analytics::chart::ChartDiagnostic> {
document
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.kind() == kind)
.collect()
}
#[test]
fn t019_the_document_carries_the_versioned_metadata() {
let series = sprint_series();
let document = ChartExport::build(&series, None, &[]);
assert_eq!(document.format(), FORMAT);
assert_eq!(document.schema_version(), SCHEMA_VERSION);
assert_eq!(document.scope.kind, "cycle");
assert_eq!(document.scope.id, "Sprint 7");
assert_eq!(document.scope.period.start.as_deref(), Some("2026-01-05"));
assert_eq!(document.scope.period.end.as_deref(), Some("2026-01-09"));
assert_eq!(
document.scope.snapshot_id, None,
"an open scope is not bound to a snapshot"
);
assert_eq!(document.mode, Mode::Count);
assert_eq!(document.unit, None, "count mode is unitless");
assert_eq!(document.precision, precision_of(Mode::Count));
assert_eq!(document.formula_version, COUNT_FORMULA_VERSION);
assert_eq!(
document.coverage,
CoverageSummary {
known: 4,
unknown: 0,
coverage_limited: 1
},
"coverage is the shared series' own tally"
);
assert_eq!(document.series.points, series.points);
assert_eq!(document.series.diagnostics, series.diagnostics);
assert_eq!(document.series.formula_version, COUNT_FORMULA_VERSION);
assert!(document.projection.is_none(), "no forecast was asked for");
let json: serde_json::Value =
serde_json::from_str(&document.to_json_pretty().unwrap()).unwrap();
assert_eq!(value(&json, "format"), Some(&serde_json::json!(FORMAT)));
assert_eq!(
value(&json, "schema_version"),
Some(&serde_json::json!(SCHEMA_VERSION))
);
assert_eq!(value(&json, "mode"), Some(&serde_json::json!("count")));
assert_eq!(
value(&json, "series.points.0.coverage"),
Some(&serde_json::json!("known"))
);
assert_eq!(
value(&json, "coverage.coverage_limited"),
Some(&serde_json::json!(1))
);
}
#[test]
fn t019_an_absent_value_is_null_and_never_zero() {
let scope = cycle_scope(None, Some("2026-01-06"));
let issues = [issue(1, None, &[scope_addition("2026-01-05T10:00:00Z")])];
let series = snapshot_of(&issues, &scope).series(Mode::Count);
let document = ChartExport::build(&series, None, &[]);
assert_eq!(
document.scope.period.start, None,
"an unbounded start stays absent"
);
let json: serde_json::Value =
serde_json::from_str(&document.to_json_pretty().unwrap()).unwrap();
assert!(
value(&json, "scope.period.start").unwrap().is_null(),
"an absent bound serializes as null: {}",
value(&json, "scope.period.start").unwrap()
);
assert_eq!(
value(&json, "unit"),
Some(&serde_json::Value::Null),
"a unitless document says null, not an empty string"
);
assert_eq!(value(&json, "projection"), Some(&serde_json::Value::Null));
let first = document.series.points.first().unwrap();
assert_eq!(first.scope_total, None);
assert_eq!(first.remaining, None);
assert_eq!(
value(&json, "series.points.0.scope_total"),
Some(&serde_json::Value::Null),
"an unanswerable level is null, never 0"
);
assert_eq!(
first.completions,
Some(0),
"a delta the day was read for is a known zero, not a null"
);
assert_eq!(
value(&json, "series.points.0.completions"),
Some(&serde_json::json!(0)),
"the known zero is serialized as 0, with the unanswerable level beside it as null"
);
}
#[test]
fn t020_repeated_export_is_byte_identical_and_a_fixpoint() {
let series = sprint_series();
let document = ChartExport::build(&series, None, &[]);
let pretty = document.to_json_pretty().unwrap();
assert_eq!(
document.to_json_pretty().unwrap(),
pretty,
"a second serialization of one document is byte-identical"
);
assert_eq!(
ChartExport::build(&series, None, &[])
.to_json_pretty()
.unwrap(),
pretty,
"two documents built from one series are byte-identical"
);
let read = ChartExport::from_json(&pretty).unwrap();
assert_eq!(read, document, "the document reads back as itself");
assert_eq!(
read.to_json_pretty().unwrap(),
pretty,
"re-serializing what was read reproduces the bytes exactly"
);
let compact = document.to_json().unwrap();
let read = ChartExport::from_json(&compact).unwrap();
assert_eq!(read, document);
assert_eq!(read.to_json().unwrap(), compact);
}
#[test]
fn t020_precision_is_stated_for_the_mode() {
assert_eq!(precision_of(Mode::Count), "whole_issues");
assert_eq!(precision_of(Mode::Estimate), "whole_units");
assert_ne!(precision_of(Mode::Count), precision_of(Mode::Estimate));
}
#[test]
fn t019_the_extension_bag_is_nested_and_never_shadows_a_projected_field() {
let series = sprint_series();
let mut document = ChartExport::build(&series, None, &[]);
document.insert_extension("format", serde_json::json!("evil"));
document.insert_extension("future_field", serde_json::json!({"a": 1}));
let json: serde_json::Value =
serde_json::from_str(&document.to_json_pretty().unwrap()).unwrap();
assert_eq!(
value(&json, "format"),
Some(&serde_json::json!(FORMAT)),
"the projected field keeps its value"
);
assert_eq!(
value(&json, "extensions.format"),
Some(&serde_json::json!("evil")),
"the preserved key lands in the nested object"
);
assert_eq!(
value(&json, "extensions.future_field.a"),
Some(&serde_json::json!(1))
);
let read = ChartExport::from_json(&document.to_json_pretty().unwrap()).unwrap();
assert_eq!(read, document, "the preserved data round trips");
assert_eq!(read.extensions().count(), 2);
assert_eq!(
read.extensions().next().map(|(key, _)| key),
Some("format"),
"the bag is emitted in canonical (sorted) order"
);
}
#[test]
fn t019_the_mirrors_copy_every_field_of_their_source() {
let folded = sprint_series();
let scope = AnalyticsScope {
kind: ScopeKind::Cycle,
id: "Sprint 7".to_string(),
period: Period {
start: Some("2026-01-05".to_string()),
end: Some("2026-01-09".to_string()),
},
snapshot_id: Some("cs-1:000007".to_string()),
};
let series = BurndownSeries {
scope: scope.clone(),
mode: Mode::Estimate,
unit: Some("points".to_string()),
points: folded.points.clone(),
diagnostics: folded.diagnostics.clone(),
formula_version: "burndown.estimate.v1".to_string(),
};
let projection = Projection {
scope: scope.clone(),
mode: series.mode,
unit: series.unit.clone(),
as_of: "2026-01-09".to_string(),
end_date: "2026-01-12".to_string(),
remaining: Some(2),
observed_rate: Some(0.75),
projected_completion: Some("2026-01-12".to_string()),
meets_end_date: Some(true),
status: ProjectionStatus::Projected,
formula_version: "burndown.projection.v1".to_string(),
diagnostics: folded.diagnostics.clone(),
};
let document = ChartExport::build(&series, Some(&projection), &[]);
assert_eq!(document.scope.kind, scope.kind.as_str());
assert_eq!(document.scope.id, scope.id);
assert_eq!(document.scope.period.start, scope.period.start);
assert_eq!(document.scope.period.end, scope.period.end);
assert_eq!(document.scope.snapshot_id, scope.snapshot_id);
assert_eq!(document.series.scope, document.scope);
assert_eq!(document.series.mode, series.mode);
assert_eq!(document.series.unit, series.unit);
assert_eq!(document.series.points, series.points);
assert_eq!(document.series.diagnostics, series.diagnostics);
assert_eq!(document.series.formula_version, series.formula_version);
assert_eq!(document.mode, series.mode);
assert_eq!(document.unit, series.unit);
assert_eq!(document.formula_version, series.formula_version);
assert_eq!(document.coverage, series.coverage());
let mirrored = document
.projection
.as_ref()
.expect("a projection was given");
assert_eq!(mirrored.scope, document.scope);
assert_eq!(mirrored.mode, projection.mode);
assert_eq!(mirrored.unit, projection.unit);
assert_eq!(mirrored.as_of, projection.as_of);
assert_eq!(mirrored.end_date, projection.end_date);
assert_eq!(mirrored.remaining, projection.remaining);
assert_eq!(mirrored.observed_rate, projection.observed_rate);
assert_eq!(
mirrored.projected_completion,
projection.projected_completion
);
assert_eq!(mirrored.meets_end_date, projection.meets_end_date);
assert_eq!(mirrored.status, projection.status);
assert_eq!(mirrored.formula_version, projection.formula_version);
assert_eq!(mirrored.diagnostics, projection.diagnostics);
}
#[test]
fn t019_a_foreign_document_is_refused_by_name() {
let foreign = serde_json::json!({
"format": "tix-capacity",
"version": 1,
"scopes": [],
})
.to_string();
let error = ChartExport::from_json(&foreign).unwrap_err().to_string();
assert!(
error.contains("tix-capacity"),
"the foreign format is named: {error}"
);
assert!(
error.contains(FORMAT),
"the format this build reads is named: {error}"
);
let malformed = ChartExport::from_json("{ not json")
.unwrap_err()
.to_string();
assert!(
malformed.contains("not a portable chart document"),
"a non-JSON payload is refused as such: {malformed}"
);
let document = ChartExport::build(&sprint_series(), None, &[]);
assert!(ChartExport::from_json(&document.to_json_pretty().unwrap()).is_ok());
}
#[test]
fn t019_a_version_mismatch_is_reported_once_and_stabilises() {
let document = ChartExport::build(&sprint_series(), None, &[]);
let mut wire: serde_json::Value =
serde_json::from_str(&document.to_json_pretty().unwrap()).unwrap();
wire["schema_version"] = serde_json::json!(99);
let raw = serde_json::to_string_pretty(&wire).unwrap();
let read = ChartExport::from_json(&raw).unwrap();
assert_eq!(
read.schema_version, 99,
"the document's own version is preserved"
);
assert_eq!(
findings_of_kind(&read, VERSION_MISMATCH).len(),
1,
"the mismatch is reported once"
);
assert_eq!(
read.series.points, document.series.points,
"its own fields are kept rather than dropped"
);
let once = read.to_json_pretty().unwrap();
let again = ChartExport::from_json(&once).unwrap();
assert_eq!(findings_of_kind(&again, VERSION_MISMATCH).len(), 1);
assert_eq!(again.to_json_pretty().unwrap(), once);
assert!(findings_of_kind(&document, VERSION_MISMATCH).is_empty());
}
#[test]
fn t019_an_unprojected_document_key_survives_the_round_trip() {
let document = ChartExport::build(&sprint_series(), None, &[]);
assert!(
document.unknown().is_empty(),
"a document this build builds projects every key it carries"
);
let bands = serde_json::json!([{"day": "2026-01-07", "low": 1, "high": 2}]);
let mut wire: serde_json::Value =
serde_json::from_str(&document.to_json_pretty().unwrap()).unwrap();
wire["schema_version"] = serde_json::json!(2);
wire["confidence_bands"] = bands.clone();
let raw = serde_json::to_string_pretty(&wire).unwrap();
let read = ChartExport::from_json(&raw).unwrap();
assert_eq!(
read.unknown().get("confidence_bands"),
Some(&bands),
"the unprojected key is preserved on read: {:?}",
read.unknown().keys().collect::<Vec<_>>()
);
assert_eq!(read.schema_version, 2, "the newer version is kept as read");
let once = read.to_json_pretty().unwrap();
let emitted: serde_json::Value = serde_json::from_str(&once).unwrap();
assert_eq!(
value(&emitted, "confidence_bands"),
Some(&bands),
"the preserved key is written back at the document level"
);
assert_eq!(
once.matches("\"confidence_bands\"").count(),
1,
"the preserved key appears exactly once in the emitted bytes: {once}"
);
assert_eq!(
value(&emitted, "format"),
Some(&serde_json::json!(FORMAT)),
"the projected format marker is still the projected one"
);
let again = ChartExport::from_json(&once).expect("the emitted document is re-readable");
assert_eq!(again, read);
assert_eq!(again.to_json_pretty().unwrap(), once);
assert_eq!(again.unknown().get("confidence_bands"), Some(&bands));
}
#[test]
fn t021_coverage_and_the_series_findings_are_exported() {
let series = sprint_series();
assert_eq!(
series
.diagnostics_of(AnalyticsDiagnosticKind::MissingHistory)
.count(),
1,
"the fixture has one unbroken gap"
);
let document = ChartExport::build(&series, None, &[]);
assert_eq!(document.coverage.coverage_limited, 1);
assert!(
document
.series
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == AnalyticsDiagnosticKind::MissingHistory),
"the missing-history finding travels with the series"
);
assert_eq!(
document.series.points[0].coverage,
Coverage::Known,
"the observed day is known"
);
}
#[test]
fn t021_estimate_mode_states_its_unit_and_diagnoses_an_undeclared_one() {
let (dir, config) = unit_config("points = 1\nhours = 5");
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("3points"),
&[scope_addition("2026-01-05T10:00:00Z")],
)];
let snapshot = ScopeSnapshot::build(&config, &index(), &issues, &scope);
let series = estimate_series(&snapshot, &issues, &config, "points");
let document = ChartExport::build(&series, None, &[]);
assert_eq!(document.mode, Mode::Estimate);
assert_eq!(document.unit.as_deref(), Some("points"));
assert_eq!(document.precision, precision_of(Mode::Estimate));
assert_eq!(document.formula_version, ESTIMATE_FORMULA_VERSION);
assert_eq!(document.series.formula_version, ESTIMATE_FORMULA_VERSION);
assert_eq!(document.series.points[4].scope_total, Some(3));
drop(dir);
let (dir, config) = unit_config("points = 1\nhours = 5");
let series = estimate_series(
&ScopeSnapshot::build(&config, &index(), &issues, &scope),
&issues,
&config,
"points",
);
assert_eq!(
series
.diagnostics_of(AnalyticsDiagnosticKind::MissingConversion)
.count(),
0
);
let series = estimate_series(
&ScopeSnapshot::build(&config, &index(), &issues, &scope),
&issues,
&config,
"storypoints",
);
let document = ChartExport::build(&series, None, &[]);
assert_eq!(
document
.series
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.kind == AnalyticsDiagnosticKind::MissingConversion)
.count(),
1,
"an undeclared unit is reported, not converted: {:?}",
document.series.diagnostics
);
assert!(
document.is_empty(),
"no day is folded without a resolved unit, so no value is invented"
);
drop(dir);
}
#[test]
fn t021_a_recorded_estimate_change_is_reported_not_replayed() {
let (dir, config) = unit_config("points = 1");
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let issues = [issue(
1,
Some("3points"),
&[
scope_addition("2026-01-05T10:00:00Z"),
estimate_change(Some("3points"), Some("8points")),
],
)];
let series = estimate_series(
&ScopeSnapshot::build(&config, &index(), &issues, &scope),
&issues,
&config,
"points",
);
let document = ChartExport::build(&series, None, &[]);
assert_eq!(
document.series.points[4].scope_total,
Some(3),
"the stated estimate values the contribution; the change is not replayed"
);
assert_eq!(
document.series.points[2].coverage,
Coverage::CoverageLimited,
"the day the change was recorded is coverage-limited"
);
assert_eq!(
document.series.points[2].scope_total,
Some(3),
"the recorded change does not restate the level"
);
assert!(
document
.series
.diagnostics
.iter()
.any(
|diagnostic| diagnostic.kind == AnalyticsDiagnosticKind::MissingHistory
&& diagnostic.message.contains("estimate change")
),
"the recorded change is reported: {:?}",
document.series.diagnostics
);
drop(dir);
}
#[test]
fn t022_a_closed_snapshot_keeps_its_provenance_and_excludes_a_later_edit() {
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09")).with_snapshot_id("cs-1:000007");
let closed = [issue(
1,
None,
&[
scope_addition("2026-01-05T10:00:00Z"),
scope_addition("2026-02-10T10:00:00Z"),
],
)];
let without_the_later_edit = [issue(1, None, &[scope_addition("2026-01-05T10:00:00Z")])];
let document = ChartExport::build(&snapshot_of(&closed, &scope).series(Mode::Count), None, &[]);
let control = ChartExport::build(
&snapshot_of(&without_the_later_edit, &scope).series(Mode::Count),
None,
&[],
);
assert_eq!(
document.scope.snapshot_id.as_deref(),
Some("cs-1:000007"),
"the closed binding is carried as provenance"
);
assert_eq!(
document.series.scope.snapshot_id, document.scope.snapshot_id,
"the series node agrees with the document"
);
assert_eq!(
document.series.points, control.series.points,
"an event after the close is not part of the document"
);
assert!(
document
.series
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == AnalyticsDiagnosticKind::ClosedSnapshot),
"the closed-snapshot finding travels with the series"
);
let open = ChartExport::build(
&snapshot_of(
&closed,
&cycle_scope(Some("2026-01-05"), Some("2026-01-09")),
)
.series(Mode::Count),
None,
&[],
);
assert_eq!(open.scope.snapshot_id, None);
assert!(
open.series
.diagnostics
.iter()
.all(|diagnostic| diagnostic.kind != AnalyticsDiagnosticKind::ClosedSnapshot)
);
}
#[test]
fn t022_a_configured_remote_target_is_diagnosed_and_the_values_stay_local() {
let series = sprint_series();
let local = ChartExport::build(&series, None, &[]);
let remote = ChartExport::build(&series, None, &["gitlab", "linear"]);
assert!(
findings_of_kind(&local, UNSUPPORTED_REMOTE_FIELD).is_empty(),
"no remote target configured, no finding"
);
let findings = findings_of_kind(&remote, UNSUPPORTED_REMOTE_FIELD);
assert_eq!(findings.len(), 2, "one finding per configured target");
assert_eq!(findings[0].target.as_deref(), Some("gitlab"));
assert_eq!(findings[1].target.as_deref(), Some("linear"));
assert_eq!(findings[0].scope.as_deref(), Some("Sprint 7"));
assert!(
findings[0].render().contains("[gitlab]"),
"the rendered line names the target: {}",
findings[0].render()
);
assert!(
findings[0].reason.contains("stay local"),
"the finding promises the values are not mapped: {}",
findings[0].reason
);
assert_eq!(
remote.series, local.series,
"the remote finding changes no value"
);
assert_eq!(remote.coverage, local.coverage);
assert!(
Tool::from_target("chart").is_err(),
"chart is not a remote tool name"
);
assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
}
#[test]
fn t023_a_partial_document_is_read_and_absent_stays_null() {
let raw = r#"{
"format": "tix-chart",
"schema_version": 1,
"scope": {"kind": "cycle", "id": "Sprint 7", "period": {"start": "2026-01-05"}},
"mode": "count",
"precision": "whole_issues",
"series": {
"scope": {"kind": "cycle", "id": "Sprint 7", "period": {}},
"mode": "count",
"points": [{"date": "2026-01-05", "coverage": "unknown", "event_ids": []}],
"formula_version": "burndown.count.v1"
},
"coverage": {"known": 0, "unknown": 1, "coverage_limited": 0},
"formula_version": "burndown.count.v1"
}"#;
let document = ChartExport::from_json(raw).unwrap();
assert_eq!(document.scope.period.end, None);
assert_eq!(document.unit, None);
assert_eq!(document.projection, None);
assert!(document.diagnostics.is_empty());
assert_eq!(document.extensions().count(), 0);
assert_eq!(document.series.points.len(), 1);
assert_eq!(document.series.points[0].scope_added, None);
assert_eq!(document.series.points[0].coverage, Coverage::Unknown);
assert!(!document.is_empty());
let json: serde_json::Value =
serde_json::from_str(&document.to_json_pretty().unwrap()).unwrap();
assert_eq!(value(&json, "unit"), Some(&serde_json::Value::Null));
assert_eq!(value(&json, "extensions"), Some(&serde_json::json!({})));
assert_eq!(
value(&json, "series.points.0.remaining"),
Some(&serde_json::Value::Null)
);
assert_eq!(
value(&json, "scope.period.end"),
Some(&serde_json::Value::Null)
);
}
#[test]
fn t023_a_malformed_series_is_refused() {
let unknown_mode = r#"{"format": "tix-chart", "schema_version": 1,
"scope": {"kind": "cycle", "id": "S", "period": {}}, "mode": "weekly",
"precision": "whole_issues", "series": {"scope": {"kind": "cycle", "id": "S", "period": {}},
"mode": "weekly", "formula_version": "v"}, "coverage": {"known": 0, "unknown": 0,
"coverage_limited": 0}, "formula_version": "v"}"#;
let error = ChartExport::from_json(unknown_mode)
.unwrap_err()
.to_string();
assert!(
error.contains("not valid JSON of this shape"),
"an unknown mode is a shape error: {error}"
);
let bad_point = r#"{"format": "tix-chart", "schema_version": 1,
"scope": {"kind": "cycle", "id": "S", "period": {}}, "mode": "count",
"precision": "whole_issues", "series": {"scope": {"kind": "cycle", "id": "S", "period": {}},
"mode": "count", "points": [{"date": "2026-01-05", "coverage": "unknown",
"event_ids": [], "scope_added": "three"}],
"formula_version": "v"}, "coverage": {"known": 0, "unknown": 0, "coverage_limited": 0},
"formula_version": "v"}"#;
assert!(ChartExport::from_json(bad_point).is_err());
let good_point = bad_point.replace("\"three\"", "3");
let document = ChartExport::from_json(&good_point).unwrap();
assert_eq!(document.series.points[0].scope_added, Some(3));
}
#[test]
fn t023_an_empty_series_still_exports_a_complete_document() {
let scope = cycle_scope(None, None);
let series = snapshot_of(&[issue(1, None, &[])], &scope).series(Mode::Count);
assert!(series.is_empty());
let document = ChartExport::build(&series, None, &[]);
assert!(document.is_empty());
assert_eq!(document.coverage.total(), 0);
assert!(
document
.series
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == AnalyticsDiagnosticKind::InvalidPeriod),
"the unenumerable period is reported: {:?}",
document.series.diagnostics
);
let json = document.to_json_pretty().unwrap();
assert_eq!(ChartExport::from_json(&json).unwrap(), document);
}
#[test]
fn t022_the_chart_target_is_local_and_the_remote_path_is_untouched() {
let (dir, config) = project_fixture();
let store = store_for(&dir, &config);
run(
&store.config,
&store,
"chart",
false,
None,
Some("Sprint 7"),
None,
false,
)
.expect("the chart dispatch precedes Tool::from_target: no remote configuration is consulted");
let error = run(
&store.config,
&store,
"gitlab",
false,
None,
None,
None,
false,
)
.expect_err("an unconfigured remote target is refused");
let message = error.to_string();
assert!(
message.contains("gitlab") || message.contains("export"),
"the remote gate names its own condition: {message}"
);
}
#[test]
fn t019_a_generated_cycle_is_selected_by_its_name_and_its_stable_id() {
let (dir, config) = generated_fixture();
let store = store_for(&dir, &config);
let issues_dir = dir.path().join("issues");
fs::write(
issues_dir.join("2026-01-01-issue-1.md"),
issue_text(
1,
"Weekly 1",
None,
&[scope_addition("2026-01-05T09:00:00Z")],
),
)
.unwrap();
fs::write(
issues_dir.join("2026-01-01-issue-2.md"),
issue_text(
2,
"cs-1:000001",
None,
&[scope_addition("2026-01-06T09:00:00Z")],
),
)
.unwrap();
let by_id =
cycle_scope_for(&store.config, &store, "cs-1:000001").expect("the stable id resolves");
let by_name = cycle_scope_for(&store.config, &store, "Weekly 1").expect("the name resolves");
assert_eq!(by_id, by_name, "the two spellings are one scope");
assert_eq!(by_id.id, "Weekly 1", "the scope's id is the resolved name");
assert_eq!(by_id.period_start(), Some("2026-01-05"));
assert_eq!(by_id.period_end(), Some("2026-01-09"));
let document_by_id = run_chart_core(&store.config, &store, Some("cs-1:000001")).unwrap();
let document_by_name = run_chart_core(&store.config, &store, Some("Weekly 1")).unwrap();
assert_eq!(
document_by_id.to_json_pretty().unwrap(),
document_by_name.to_json_pretty().unwrap(),
"the two spellings produce one document"
);
assert_eq!(document_by_id.scope.id, "Weekly 1");
assert_eq!(document_by_id.coverage.total(), 5);
assert_eq!(
document_by_id.series.points[4].scope_total,
Some(2),
"both spellings in the issue records are members of the one cycle"
);
let error = cycle_scope_for(&store.config, &store, "cs-9:000042")
.unwrap_err()
.to_string();
assert!(
error.contains("cs-9:000042"),
"the unresolved reference is named: {error}"
);
}
fn project_fixture() -> (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]\npoints = 1\n\
[cycles.\"Sprint 7\"]\nstart_date = \"2026-01-05\"\ndue_date = \"2026-01-09\"\n",
issues = issues.to_string_lossy(),
),
)
.unwrap();
let config = Config::load(dir.path()).expect("the fixture loads");
(dir, config)
}
fn store_for(dir: &TempDir, config: &Config) -> Store {
let issues_dir = dir.path().join("issues");
fs::create_dir_all(&issues_dir).unwrap();
Store {
issues_dir,
config: config.clone(),
}
}
fn generated_fixture() -> (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\
[cycle_schedules.\"cs-1\"]\nname = \"Weekly\"\ncadence = {{ kind = \"weekly\" }}\n\
duration = {{ count = 1, unit = \"weeks\" }}\nstart_date = \"2026-01-05\"\n\
timezone = \"UTC\"\n",
issues = issues.to_string_lossy(),
),
)
.unwrap();
let config = Config::load(dir.path()).expect("the fixture loads");
let record = CycleRecord {
name: "Weekly 1".into(),
start_date: Some("2026-01-05".into()),
due_date: Some("2026-01-09".into()),
schedule_id: Some("cs-1".into()),
sequence: Some(1),
state: Some("active".into()),
..CycleRecord::default()
};
CycleStore::new(dir.path())
.save_record(&record, Some("The first weekly cycle.\n"))
.expect("the generated cycle is stored");
(dir, config)
}
#[test]
fn the_real_exercise_exports_a_temp_project_and_round_trips() {
let (dir, config) = project_fixture();
let issues_dir = dir.path().join("issues");
let store = store_for(&dir, &config);
let write = |number: u32, body: String| {
fs::write(
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-07T09:00:00Z"),
],
),
);
write(
2,
issue_markdown(
2,
Some("2points"),
&[
scope_addition("2026-01-06T09:00:00Z"),
scope_addition("2026-02-10T09:00:00Z"),
],
),
);
let before: Vec<Vec<u8>> = [1, 2]
.iter()
.map(|number| fs::read(issues_dir.join(format!("2026-01-01-issue-{number}.md"))).unwrap())
.collect();
let document =
run_chart_core(&store.config, &store, Some("Sprint 7")).expect("the cycle folds");
assert_eq!(document.format(), FORMAT);
assert_eq!(document.scope.kind, "cycle");
assert_eq!(document.scope.id, "Sprint 7");
assert_eq!(document.scope.period.start.as_deref(), Some("2026-01-05"));
assert_eq!(document.scope.period.end.as_deref(), Some("2026-01-09"));
assert_eq!(document.mode, Mode::Count);
assert_eq!(document.unit, None);
assert_eq!(document.coverage.total(), 5, "the period's own five days");
assert_eq!(document.series.points[4].scope_total, Some(2));
let json = document.to_json_pretty().unwrap();
println!("{json}");
let read = ChartExport::from_json(&json).expect("the document reads back");
assert_eq!(read, document);
assert_eq!(
read.to_json_pretty().unwrap(),
json,
"the emitted bytes are a fixpoint"
);
let scope = cycle_scope(Some("2026-01-05"), Some("2026-01-09"));
let estimated = chart_document(
&store.config,
&store,
&scope,
Mode::Estimate,
Some("points"),
Some(("2026-01-09", "2026-01-09")),
)
.expect("the estimate document builds");
assert_eq!(estimated.mode, Mode::Estimate);
assert_eq!(estimated.unit.as_deref(), Some("points"));
assert_eq!(estimated.series.points[4].scope_total, Some(5));
let projection = estimated
.projection
.as_ref()
.expect("a forecast was asked for");
assert_eq!(projection.as_of, "2026-01-09");
assert_eq!(
projection.remaining, estimated.series.points[4].remaining,
"the forecast carries the series' own remaining work"
);
assert_eq!(projection.formula_version, PROJECTION_FORMULA_VERSION);
println!(
"projection: status={} rate={:?} completion={:?}",
projection.status, projection.observed_rate, projection.projected_completion
);
for diagnostic in &estimated.diagnostics {
println!("{}", diagnostic.render());
}
assert_eq!(
ChartExport::from_json(&estimated.to_json_pretty().unwrap()).unwrap(),
estimated
);
for (number, bytes) in [1u32, 2].iter().zip(&before) {
let path = issues_dir.join(format!("2026-01-01-issue-{number}.md"));
assert_eq!(
&fs::read(&path).unwrap(),
bytes,
"export mutates no issue file"
);
}
let out = dir.path().join("chart.json");
run(
&store.config,
&store,
"chart",
false,
None,
Some("Sprint 7"),
Some(out.to_str().unwrap()),
false,
)
.expect("the target writes to --output");
let written = fs::read_to_string(&out).unwrap();
assert_eq!(
written,
document.to_json_pretty().unwrap(),
"the CLI writes exactly the core document"
);
assert!(
!Path::new(&out).metadata().unwrap().is_dir(),
"the output is a file"
);
}