///|
/// Final outcome is separate from evidence of inconsistent attempts.
#derive(Eq)
pub(all) enum Outcome {
Passed
Failed
Errored
Skipped
}
///|
#derive(Eq)
pub(all) enum DuplicatePolicy {
Reject
Attempts
}
///|
/// Tagged identity prevents class names from colliding with fallback suite paths.
#derive(Eq)
pub(all) enum TestId {
Class(String, String)
SuitePath(ReadOnlyArray[String], String)
}
///|
pub struct TestCase {
priv scope : Int32
priv source_units : Int64
id : TestId
name : String
classname : String?
final_results : ReadOnlyArray[String]
interim_results : ReadOnlyArray[String]
suite_order : Int32
document_order : Int32
outcome : Outcome
attempts : Int32
flaky : Bool
time_us : Int64?
message : String
source : String
}
///|
#derive(Eq)
pub struct Diagnostic {
source : String
code : String
message : String
}
///|
pub struct Report {
cases : ReadOnlyArray[TestCase]
diagnostics : ReadOnlyArray[Diagnostic]
priv scopes : ReadOnlyArray[Scope]
}
///|
#derive(Eq)
pub(all) suberror InputError {
InvalidInput(String)
Rejected(String, String)
UnknownField(String)
}
///|
pub fn TestId::to_json(self : TestId) -> Json {
match self {
Class(classname, name) => ["class", Json(classname), Json(name)]
SuitePath(path, name) => ["suite", Json(path), Json(name)]
}
}
///|
pub fn TestId::key(self : TestId) -> String {
self.to_json().stringify()
}
///|
pub fn Outcome::label(self : Outcome) -> String {
match self {
Passed => "passed"
Failed => "failed"
Errored => "errored"
Skipped => "skipped"
}
}
///|
/// Decimal seconds, floored to microseconds. Bounds keep comparison arithmetic safe.
pub fn parse_duration(value : String) -> Int64? raise InputError {
if value == "" {
return None
}
let mut whole : Int64 = 0L
let mut fraction : Int64 = 0L
let mut digits = 0
let mut before = 0
let mut dot = false
for ch in value {
if ch == '.' && !dot {
dot = true
continue
}
if ch < '0' || ch > '9' {
raise InvalidInput("invalid decimal time: \{value}")
}
let digit = (ch.to_int() - 48).to_int64()
if dot {
if digits < 6 {
fraction = fraction * 10L + digit
}
digits = digits + 1
} else {
before = before + 1
if whole > 9000000L / 10L {
raise InvalidInput("time exceeds 9000000 seconds")
}
whole = whole * 10L + digit
if whole > 9000000L {
raise InvalidInput("time exceeds 9000000 seconds")
}
}
}
if before == 0 || (dot && digits == 0) {
raise InvalidInput("invalid decimal time: \{value}")
}
while digits < 6 {
fraction = fraction * 10L
digits = digits + 1
}
let result = whole * 1000000L + fraction
if result > 9000000000000L {
raise InvalidInput("time exceeds 9000000 seconds")
}
Some(result)
}
///|
fn diagnostic(source : String, code : String, message : String) -> Diagnostic {
{ source: clipped(source, 512), code, message: clipped(message, 2048), }
}
///|
/// Ordered merge. Only explicit Attempts opts into interpreting duplicates as retries.
pub fn merge_reports(
reports : Array[Report],
duplicates~ : DuplicatePolicy = Reject,
) -> Report raise InputError {
if reports.length() > 50 {
raise Rejected(
"limit_exceeded", "a run cannot contain more than 50 reports",
)
}
let pool = new_scope_pool()
let cases : Array[TestCase] = []
let index : Map[(Int32, String), Int32] = Map([])
let diagnostics : Array[Diagnostic] = []
for report in reports {
let mapping = pool.remap(report.scopes)
for diagnostic in report.diagnostics {
diagnostics.push(diagnostic)
}
let ordered = report.cases.iter().to_array()
ordered.sort_by((a, b) => a.document_order.compare(b.document_order))
for original in ordered {
let current = { ..original, scope: mapping[original.scope], }
let key = (current.scope, current.name)
match index.get(key) {
None => {
if cases.length() >= 100000 {
raise Rejected(
"limit_exceeded", "a merged run exceeds 100000 unique testcases",
)
}
index[key] = cases.length()
cases.push(current)
}
Some(i) => {
let old = cases[i]
if duplicates == Reject {
raise Rejected(
"duplicate_test_id",
"duplicate test ID \{current.id.preview()} at document index \{current.document_order}",
)
}
if old.attempts > 1000000 - current.attempts {
raise Rejected(
"limit_exceeded",
"too many attempts for \{current.id.preview()}",
)
}
if old.outcome != current.outcome &&
(old.outcome == Skipped || current.outcome == Skipped) {
raise Rejected(
"retry_conflict",
"\{current.id.preview()}: skipped attempt mixed with another outcome at document index \{current.document_order}",
)
}
cases[i] = {
..current,
attempts: old.attempts + current.attempts,
flaky: old.flaky ||
current.flaky ||
(old.outcome == Passed) != (current.outcome == Passed),
}
diagnostics.push(
diagnostic(
current.source,
"duplicate_attempt",
"\{current.id.preview()}: final attempt wins; document index \{current.document_order}",
),
)
}
}
}
}
for i = 0; i < cases.length(); i = i + 1 {
cases[i] = { ..cases[i], document_order: i, }
}
{
cases: ReadOnlyArray::from_array(cases),
diagnostics: ReadOnlyArray::from_array(diagnostics),
scopes: ReadOnlyArray::from_array(pool.scopes),
}
}
///|
pub extend Outcome with Eq::{equal, not_equal}
///|
pub extend DuplicatePolicy with Eq::{equal, not_equal}
///|
pub extend TestId with Eq::{equal, not_equal}
///|
pub extend TestCase with Eq::{equal, not_equal}
///|
pub extend Diagnostic with Eq::{equal, not_equal}
///|
pub extend Report with Eq::{equal, not_equal}
///|
pub extend InputError with Eq::{equal, not_equal}
///|
pub extend InputError with Show::{output, to_string}
///|
pub impl Show for InputError with fn output(self, logger) {
match self {
InvalidInput(message) => logger.write_string(message)
Rejected(code, message) => logger.write_string("[\{code}] \{message}")
UnknownField(path) => logger.write_string("unknown field at \{path}")
}
}
///|
/// Raw result flags match junitparser's independent Boolean properties.
pub fn TestCase::is_passed(self : TestCase) -> Bool {
self.final_results.is_empty()
}
///|
pub fn TestCase::is_failure(self : TestCase) -> Bool {
self.final_results.contains("failure")
}
///|
pub fn TestCase::is_error(self : TestCase) -> Bool {
self.final_results.contains("error")
}
///|
pub fn TestCase::is_skipped(self : TestCase) -> Bool {
self.final_results.contains("skipped")
}
///|
pub fn TestCase::is_flaky(self : TestCase) -> Bool {
self.interim_results.any(x => x == "flakyFailure" || x == "flakyError")
}
///|
pub fn TestCase::is_rerun(self : TestCase) -> Bool {
self.interim_results.any(x => x == "rerunFailure" || x == "rerunError")
}
///|
pub impl Eq for TestCase with fn equal(a, b) {
a.id == b.id &&
a.name == b.name &&
a.classname == b.classname &&
a.final_results == b.final_results &&
a.interim_results == b.interim_results &&
a.suite_order == b.suite_order &&
a.document_order == b.document_order &&
a.outcome == b.outcome &&
a.attempts == b.attempts &&
a.flaky == b.flaky &&
a.time_us == b.time_us &&
a.message == b.message &&
a.source == b.source
}
///|
pub impl Eq for Report with fn equal(a, b) {
a.cases == b.cases && a.diagnostics == b.diagnostics
}