use std::sync::Mutex;
use qubit_progress::{
Event,
Metric,
OperationAttributes,
Phase,
Progress,
Reporter,
ReporterError,
};
#[cfg(feature = "serde")]
use qubit_progress::MetricSnapshot;
#[cfg(feature = "serde")]
use serde_json::json;
#[derive(Default)]
struct RecordingReporter {
events: Mutex<Vec<Event>>,
}
impl Reporter for RecordingReporter {
fn report(&self, event: &Event) -> Result<(), ReporterError> {
self.events
.lock()
.expect("recording reporter mutex must not be poisoned")
.push(event.clone());
Ok(())
}
}
#[test]
fn test_event_carries_operation_attributes_through_terminal_phase() {
let reporter = RecordingReporter::default();
let mut attributes = OperationAttributes::new();
attributes.insert("tenant", "acme");
attributes.insert("trace_id", "trace-1");
let mut progress = Progress::builder(&reporter)
.attributes(attributes)
.attribute("trace_id", "trace-2")
.metric(Metric::new("tasks", "Tasks"))
.start()
.expect("attributes should validate");
progress.report().expect("running event must report");
progress.cancel().expect("terminal event must report");
let events = reporter
.events
.lock()
.expect("recording reporter mutex must not be poisoned");
for event in events.iter() {
assert_eq!(event.attribute("tenant"), Some("acme"));
assert_eq!(event.attribute("trace_id"), Some("trace-2"));
}
assert_eq!(
events[0].attributes().iter().collect::<Vec<_>>(),
[("tenant", "acme"), ("trace_id", "trace-2"),]
);
}
#[test]
fn test_event_carries_cancelled_metric_count() {
let reporter = RecordingReporter::default();
let progress = Progress::builder(&reporter)
.metric(Metric::new("tasks", "Tasks").total(2))
.start()
.expect("progress must start");
let tasks = progress
.metric("tasks")
.expect("configured metric must exist");
tasks.start(2).expect("work must start");
tasks.cancel(2).expect("work must cancel");
progress.finish().expect("progress must finish");
let events = reporter
.events
.lock()
.expect("recording reporter mutex must not be poisoned");
let terminal = events.last().expect("terminal event must exist");
assert_eq!(terminal.phase(), Phase::Succeeded);
assert_eq!(
terminal
.metric("tasks")
.expect("metric must exist")
.cancelled(),
2,
);
}
#[test]
fn test_event_accessors_and_phase_names() {
let reporter = RecordingReporter::default();
let mut progress = Progress::builder(&reporter)
.metric(Metric::new("tasks", "Tasks"))
.start()
.expect("progress must start");
progress.report().expect("running event must report");
progress.fail().expect("failed event must report");
let events = reporter
.events
.lock()
.expect("recording reporter mutex must not be poisoned");
assert_eq!(events[0].phase().as_str(), "started");
assert_eq!(events[1].phase().as_str(), "running");
assert_eq!(events[2].phase().as_str(), "failed");
assert!(events[0].operation_id() > 0);
assert_eq!(events[0].sequence(), 0);
assert!(events[0].stage().is_none());
assert_eq!(events[0].metrics().len(), 1);
assert!(events[0].metric("unknown").is_none());
assert!(events[2].elapsed() >= events[0].elapsed());
assert_eq!(Phase::Succeeded.as_str(), "succeeded");
assert_eq!(Phase::Cancelled.as_str(), "cancelled");
}
#[cfg(feature = "serde")]
#[test]
fn test_event_json_deserializes_canonical_durations() {
for (phase, sequence, elapsed) in [
("started", 0, "0ns"),
("running", 1, "1ns"),
("succeeded", 1, "1us"),
("failed", 1, "1ms"),
("cancelled", 1, "1s"),
("running", 1, "1m"),
("running", 1, "1h"),
] {
let value = json!({
"operation_id": 1,
"sequence": sequence,
"phase": phase,
"stage": { "id": "copy", "name": "Copy", "position": 1, "total": 1 },
"metrics": [{
"id": "tasks", "name": "Tasks", "total": 2,
"completed": 0, "active": 0, "succeeded": 0, "failed": 0, "cancelled": 0
}],
"elapsed": elapsed,
});
let event: Event =
serde_json::from_value(value).expect("event JSON must deserialize");
assert_eq!(event.phase().as_str(), phase);
assert_eq!(event.sequence(), sequence);
assert_eq!(event.stage().expect("stage must exist").total(), Some(1));
assert_eq!(
serde_json::to_value(&event)
.expect("event JSON must serialize")
.pointer("/elapsed")
.expect("elapsed must serialize"),
if elapsed == "0ns" { "0s" } else { elapsed },
);
}
let event: Event = serde_json::from_str(
r#"{"operation_id":1,"sequence":0,"phase":"started","stage":null,"metrics":[{"id":"tasks","name":"Tasks","total":null,"completed":0,"active":0,"succeeded":0,"failed":0,"cancelled":0}],"elapsed":"0s"}"#,
)
.expect("direct JSON deserialization must use the public event impl");
serde_json::to_string(&event)
.expect("direct JSON serialization must succeed");
}
#[cfg(feature = "serde")]
#[test]
fn test_event_json_rejects_invalid_invariants() {
let valid = json!({
"operation_id": 1,
"sequence": 0,
"phase": "started",
"stage": null,
"metrics": [{
"id": "tasks", "name": "Tasks", "total": 1,
"completed": 0, "active": 0, "succeeded": 0, "failed": 0, "cancelled": 0
}],
"elapsed": "0ns",
});
for (pointer, replacement) in [
("/operation_id", json!(0)),
("/elapsed", json!("1xs")),
("/elapsed", json!("-1s")),
("/elapsed", json!("")),
(
"/elapsed",
json!("340282366920938463463374607431768211456ns"),
),
(
"/elapsed",
json!("340282366920938463463374607431768211455h"),
),
("/elapsed", json!("18446744073709551615h")),
("/sequence", json!(1)),
("/metrics/0/active", json!(1)),
(
"/stage",
json!({"id":"copy", "name":"Copy", "position":1, "total":0}),
),
(
"/stage",
json!({"id":"copy", "name":"Copy", "position":null, "total":1}),
),
] {
let mut invalid = valid.clone();
*invalid.pointer_mut(pointer).expect("field must exist") = replacement;
assert!(serde_json::from_value::<Event>(invalid).is_err());
}
for invalid in [
json!({
"operation_id": 1, "sequence": 1, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":1, "completed":1, "active":1, "succeeded":0, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 1, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":null, "completed":18446744073709551615_u64, "active":0, "succeeded":18446744073709551615_u64, "failed":1, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 1, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":18446744073709551615_u64, "completed":18446744073709551615_u64, "active":1, "succeeded":0, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 1, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":1, "completed":1, "active":0, "succeeded":2, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 1, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":0, "completed":0, "active":1, "succeeded":0, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 1, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":null, "completed":0, "active":0, "succeeded":0, "failed":0, "cancelled":0},
{"id":"tasks", "name":"Tasks", "total":null, "completed":0, "active":0, "succeeded":0, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 1, "phase": "running",
"stage": {"id":"copy", "name":"Copy", "position":1, "total":null},
"metrics": [
{"id":"tasks", "name":"Tasks", "total":null, "completed":0, "active":0, "succeeded":0, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
json!({
"operation_id": 1, "sequence": 0, "phase": "running", "stage": null,
"metrics": [
{"id":"tasks", "name":"Tasks", "total":null, "completed":0, "active":0, "succeeded":0, "failed":0, "cancelled":0}
], "elapsed": "1ns"
}),
] {
assert!(serde_json::from_value::<Event>(invalid).is_err());
}
}
#[cfg(feature = "serde")]
#[test]
fn test_metric_snapshot_deserialization_rejects_invalid_invariants() {
for value in [
json!({"id":"", "name":"Tasks", "total":null, "completed":0, "active":0, "succeeded":0, "failed":0, "cancelled":0}),
json!({"id":"tasks", "name":"", "total":null, "completed":0, "active":0, "succeeded":0, "failed":0, "cancelled":0}),
json!({"id":"tasks", "name":"Tasks", "total":1, "completed":1, "active":1, "succeeded":0, "failed":0, "cancelled":0}),
json!({"id":"tasks", "name":"Tasks", "total":1, "completed":1, "active":0, "succeeded":2, "failed":0, "cancelled":0}),
json!({"id":"tasks", "name":"Tasks", "total":0, "completed":0, "active":1, "succeeded":0, "failed":0, "cancelled":0}),
] {
assert!(serde_json::from_value::<MetricSnapshot>(value).is_err());
}
}