///|
/// A timestamped TAP report record for trend analysis.
pub(all) struct TapRunRecord {
id : String
label : String
timestamp : String
report : TapReport
} derive(Debug, Eq)
///|
/// Difference between two TAP summaries.
pub(all) struct TapSummaryDelta {
planned : Int
total : Int
passed : Int
failed : Int
skipped : Int
todo : Int
diagnostics : Int
yaml_blocks : Int
issues : Int
health : Int
} derive(Debug, Eq)
///|
/// Trend classification for release notes.
pub(all) enum TapTrendKind {
TrendImproved
TrendRegressed
TrendStable
TrendMixed
} derive(Debug, Eq)
///|
/// Comparison between two records.
pub(all) struct TapTrendReport {
before : TapRunRecord
after : TapRunRecord
delta : TapSummaryDelta
kind : TapTrendKind
notes : Array[String]
} derive(Debug, Eq)
///|
/// Construct a timestamped run record.
pub fn run_record(
id : String,
label : String,
timestamp : String,
report : TapReport,
) -> TapRunRecord {
TapRunRecord::{ id, label, timestamp, report }
}
///|
/// Compare two report summaries.
pub fn compare_summaries(
before : TapReport,
after : TapReport,
) -> TapSummaryDelta {
TapSummaryDelta::{
planned: after.summary.planned - before.summary.planned,
total: after.summary.total - before.summary.total,
passed: after.summary.passed - before.summary.passed,
failed: after.summary.failed - before.summary.failed,
skipped: after.summary.skipped - before.summary.skipped,
todo: after.summary.todo - before.summary.todo,
diagnostics: after.summary.diagnostics - before.summary.diagnostics,
yaml_blocks: after.summary.yaml_blocks - before.summary.yaml_blocks,
issues: after.issues.length() - before.issues.length(),
health: health_score(after) - health_score(before),
}
}
///|
/// Compare two timestamped records.
pub fn compare_runs(
before : TapRunRecord,
after : TapRunRecord,
) -> TapTrendReport {
let delta = compare_summaries(before.report, after.report)
let notes = trend_notes(before, after, delta)
TapTrendReport::{ before, after, delta, kind: classify_trend(delta), notes }
}
///|
/// Classify a summary delta into a compact trend kind.
pub fn classify_trend(delta : TapSummaryDelta) -> TapTrendKind {
let improved = delta.failed < 0 || delta.issues < 0 || delta.health > 0
let regressed = delta.failed > 0 || delta.issues > 0 || delta.health < 0
if improved && regressed {
TrendMixed
} else if improved {
TrendImproved
} else if regressed {
TrendRegressed
} else {
TrendStable
}
}
///|
/// Human-readable trend kind label.
pub fn trend_kind_label(kind : TapTrendKind) -> String {
match kind {
TrendImproved => "improved"
TrendRegressed => "regressed"
TrendStable => "stable"
TrendMixed => "mixed"
}
}
///|
/// Render a summary delta as Markdown.
pub fn trend_to_markdown(trend : TapTrendReport) -> String {
let sb = StringBuilder()
sb.write_string("# TAPTrend Comparison\n\n")
sb.write_string("- before: ")
sb.write_string(trend.before.label)
sb.write_string(" (")
sb.write_string(trend.before.id)
sb.write_string(")\n")
sb.write_string("- after: ")
sb.write_string(trend.after.label)
sb.write_string(" (")
sb.write_string(trend.after.id)
sb.write_string(")\n")
sb.write_string("- kind: ")
sb.write_string(trend_kind_label(trend.kind))
sb.write_string("\n")
sb.write_string("- health_delta: \{signed_int(trend.delta.health)}\n")
sb.write_string("\n")
sb.write_string("| Metric | Delta |\n")
sb.write_string("| --- | ---: |\n")
sb.write_string("| planned | \{signed_int(trend.delta.planned)} |\n")
sb.write_string("| total | \{signed_int(trend.delta.total)} |\n")
sb.write_string("| passed | \{signed_int(trend.delta.passed)} |\n")
sb.write_string("| failed | \{signed_int(trend.delta.failed)} |\n")
sb.write_string("| skipped | \{signed_int(trend.delta.skipped)} |\n")
sb.write_string("| todo | \{signed_int(trend.delta.todo)} |\n")
sb.write_string("| diagnostics | \{signed_int(trend.delta.diagnostics)} |\n")
sb.write_string("| yaml_blocks | \{signed_int(trend.delta.yaml_blocks)} |\n")
sb.write_string("| issues | \{signed_int(trend.delta.issues)} |\n")
if trend.notes.length() > 0 {
sb.write_string("\n## Notes\n\n")
for note in trend.notes {
sb.write_string("- ")
sb.write_string(note)
sb.write_string("\n")
}
}
sb.to_string()
}
///|
/// Render a trend report as compact JSON.
pub fn trend_to_json(trend : TapTrendReport) -> String {
let sb = StringBuilder()
sb.write_string("{")
sb.write_string("\"before\":\"")
sb.write_string(json_escape(trend.before.id))
sb.write_string("\",\"after\":\"")
sb.write_string(json_escape(trend.after.id))
sb.write_string("\",\"kind\":\"")
sb.write_string(trend_kind_label(trend.kind))
sb.write_string("\",\"delta\":{")
sb.write_string("\"planned\":\{trend.delta.planned}")
sb.write_string(",\"total\":\{trend.delta.total}")
sb.write_string(",\"passed\":\{trend.delta.passed}")
sb.write_string(",\"failed\":\{trend.delta.failed}")
sb.write_string(",\"skipped\":\{trend.delta.skipped}")
sb.write_string(",\"todo\":\{trend.delta.todo}")
sb.write_string(",\"diagnostics\":\{trend.delta.diagnostics}")
sb.write_string(",\"yaml_blocks\":\{trend.delta.yaml_blocks}")
sb.write_string(",\"issues\":\{trend.delta.issues}")
sb.write_string(",\"health\":\{trend.delta.health}")
sb.write_string("},\"notes\":[")
for note in trend.notes; first = true {
if first {
()
} else {
sb.write_string(",")
}
sb.write_string("\"")
sb.write_string(json_escape(note))
sb.write_string("\"")
continue false
}
sb.write_string("]}")
sb.to_string()
}
///|
/// Render a trend report as one CSV row with header.
pub fn trend_to_csv(trend : TapTrendReport) -> String {
let sb = StringBuilder()
sb.write_string(
"before,after,kind,planned,total,passed,failed,skipped,todo,issues,health\n",
)
sb.write_string(csv_escape(trend.before.id))
sb.write_string(",")
sb.write_string(csv_escape(trend.after.id))
sb.write_string(",")
sb.write_string(trend_kind_label(trend.kind))
sb.write_string(",\{trend.delta.planned}")
sb.write_string(",\{trend.delta.total}")
sb.write_string(",\{trend.delta.passed}")
sb.write_string(",\{trend.delta.failed}")
sb.write_string(",\{trend.delta.skipped}")
sb.write_string(",\{trend.delta.todo}")
sb.write_string(",\{trend.delta.issues}")
sb.write_string(",\{trend.delta.health}\n")
sb.to_string()
}
///|
/// Render a list of runs as Markdown history.
pub fn history_to_markdown(records : Array[TapRunRecord]) -> String {
let sb = StringBuilder()
sb.write_string("# TAPTrail History\n\n")
sb.write_string(
"| ID | Label | Timestamp | Status | Total | Passed | Failed | Issues | Health |\n",
)
sb.write_string(
"| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |\n",
)
for item in records {
sb.write_string("| ")
sb.write_string(history_cell(item.id))
sb.write_string(" | ")
sb.write_string(history_cell(item.label))
sb.write_string(" | ")
sb.write_string(history_cell(item.timestamp))
sb.write_string(" | ")
sb.write_string(if item.report.ok { "ok" } else { "failed" })
sb.write_string(" | \{item.report.summary.total}")
sb.write_string(" | \{item.report.summary.passed}")
sb.write_string(" | \{item.report.summary.failed}")
sb.write_string(" | \{item.report.issues.length()}")
sb.write_string(" | \{health_score(item.report)} |\n")
}
sb.to_string()
}
///|
/// Render a run history as compact JSON.
pub fn history_to_json(records : Array[TapRunRecord]) -> String {
let sb = StringBuilder()
sb.write_string("[")
for item in records; first = true {
if first {
()
} else {
sb.write_string(",")
}
sb.write_string("{\"id\":\"")
sb.write_string(json_escape(item.id))
sb.write_string("\",\"label\":\"")
sb.write_string(json_escape(item.label))
sb.write_string("\",\"timestamp\":\"")
sb.write_string(json_escape(item.timestamp))
sb.write_string("\",\"ok\":")
sb.write_string(if item.report.ok { "true" } else { "false" })
sb.write_string(",\"total\":\{item.report.summary.total}")
sb.write_string(",\"passed\":\{item.report.summary.passed}")
sb.write_string(",\"failed\":\{item.report.summary.failed}")
sb.write_string(",\"issues\":\{item.report.issues.length()}")
sb.write_string(",\"health\":\{health_score(item.report)}")
sb.write_string("}")
continue false
}
sb.write_string("]")
sb.to_string()
}
///|
/// Render pairwise trends for adjacent run records.
pub fn history_trends_to_markdown(records : Array[TapRunRecord]) -> String {
let sb = StringBuilder()
sb.write_string("# TAPTrail History Trends\n\n")
if records.length() < 2 {
sb.write_string("Not enough records to calculate trends.\n")
return sb.to_string()
}
sb.write_string("| From | To | Kind | Failed | Issues | Health |\n")
sb.write_string("| --- | --- | --- | ---: | ---: | ---: |\n")
for i in 1.. TapRunRecord? {
let mut best_index = -1
let mut best_score = -1
for i in 0.. best_score {
best_score = score
best_index = i
}
}
if best_index >= 0 {
Some(records[best_index])
} else {
None
}
}
///|
/// Return the worst health-score record.
pub fn worst_run(records : Array[TapRunRecord]) -> TapRunRecord? {
let mut worst_index = -1
let mut worst_score = 101
for i in 0..= 0 {
Some(records[worst_index])
} else {
None
}
}
///|
/// Return true if the last run improved from the previous run.
pub fn latest_run_improved(records : Array[TapRunRecord]) -> Bool {
if records.length() < 2 {
return false
}
let trend = compare_runs(
records[records.length() - 2],
records[records.length() - 1],
)
trend.kind == TrendImproved
}
///|
/// Return true if the last run regressed from the previous run.
pub fn latest_run_regressed(records : Array[TapRunRecord]) -> Bool {
if records.length() < 2 {
return false
}
let trend = compare_runs(
records[records.length() - 2],
records[records.length() - 1],
)
trend.kind == TrendRegressed || trend.kind == TrendMixed
}
///|
/// Return records whose report failed validation.
pub fn failed_runs(records : Array[TapRunRecord]) -> Array[TapRunRecord] {
let items : Array[TapRunRecord] = []
for item in records {
if !item.report.ok {
items.push(item)
}
}
items
}
///|
/// Return records whose report passed validation.
pub fn passed_runs(records : Array[TapRunRecord]) -> Array[TapRunRecord] {
let items : Array[TapRunRecord] = []
for item in records {
if item.report.ok {
items.push(item)
}
}
items
}
///|
/// Count issue occurrences across run history.
pub fn history_issue_count(records : Array[TapRunRecord], code : String) -> Int {
let mut count = 0
for record in records {
for item in record.report.issues {
if item.code == code {
count += 1
}
}
}
count
}
///|
/// Return unique issue codes seen across a history.
pub fn history_issue_codes(records : Array[TapRunRecord]) -> Array[String] {
let codes : Array[String] = []
for record in records {
for item in record.report.issues {
if !codes.contains(item.code) {
codes.push(item.code)
}
}
}
codes
}
///|
/// Render issue frequency as Markdown.
pub fn history_issue_frequency_markdown(
records : Array[TapRunRecord],
) -> String {
let sb = StringBuilder()
sb.write_string("| Code | Severity | Count | Title |\n")
sb.write_string("| --- | --- | ---: | --- |\n")
for code in history_issue_codes(records) {
let meta = issue_meta(code)
sb.write_string("| `")
sb.write_string(code)
sb.write_string("` | ")
sb.write_string(severity_label(meta.severity))
sb.write_string(" | \{history_issue_count(records, code)} | ")
sb.write_string(history_cell(meta.title))
sb.write_string(" |\n")
}
sb.to_string()
}
///|
fn trend_notes(
before : TapRunRecord,
after : TapRunRecord,
delta : TapSummaryDelta,
) -> Array[String] {
let notes : Array[String] = []
if delta.failed < 0 {
notes.push("fewer failing tests than \{before.label}")
} else if delta.failed > 0 {
notes.push("more failing tests than \{before.label}")
}
if delta.issues < 0 {
notes.push("validation issue count decreased")
} else if delta.issues > 0 {
notes.push("validation issue count increased")
}
if delta.health > 0 {
notes.push("health score improved by \{delta.health}")
} else if delta.health < 0 {
notes.push("health score regressed by \{0 - delta.health}")
}
if delta.total > 0 {
notes.push("test coverage expanded by \{delta.total} points")
}
if after.report.ok && !before.report.ok {
notes.push("latest run changed from failing to passing")
}
if !after.report.ok && before.report.ok {
notes.push("latest run changed from passing to failing")
}
if notes.is_empty() {
notes.push("no material trend detected")
}
notes
}
///|
fn signed_int(value : Int) -> String {
if value > 0 {
"+\{value}"
} else {
"\{value}"
}
}
///|
fn history_cell(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'|' => sb.write_string("\\|")
'\n' => sb.write_string("
")
'\r' => ()
_ => sb.write_char(ch)
}
}
sb.to_string()
}