use face_core::{Envelope, FlatCluster, OutputFormat, RenderOptions, from_flat, render};
use serde_json::{Value, json};
fn fixture_envelope() -> Envelope {
serde_json::from_value(json!({
"result": {
"input_total": 38,
"skipped": 0,
"axes": [{ "field": "kind", "strategy": "exact", "auto": false }],
"detection": {
"format": "json",
"items_path": ".",
"score_path": null,
"preset": null,
"fallback_reason": null
}
},
"meta": {},
"clusters": [
{
"id": "kind:bug",
"label": "bug",
"axis": "kind",
"value": "bug",
"total": 20,
"score_min": 0.7,
"score_max": 0.95,
"clusters": []
},
{
"id": "kind:feat",
"label": "feat",
"axis": "kind",
"value": "feat",
"total": 18,
"score_min": 0.5,
"score_max": 0.85,
"clusters": []
}
],
"page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
}))
.expect("fixture envelope")
}
fn fixture_two_axis() -> Envelope {
serde_json::from_value(json!({
"result": {
"input_total": 38,
"skipped": 0,
"axes": [
{ "field": "kind", "strategy": "exact", "auto": false },
{ "field": "score", "strategy": "bands", "count": 3, "auto": true }
],
"detection": {
"format": "json",
"items_path": ".",
"score_path": "score",
"preset": null,
"fallback_reason": null
}
},
"meta": {},
"clusters": [
{
"id": "kind:bug",
"label": "bug",
"axis": "kind",
"value": "bug",
"total": 20,
"score_min": 0.7,
"score_max": 0.95,
"clusters": [
{
"id": "kind:bug,score:excellent",
"label": "excellent",
"axis": "score",
"value": "excellent",
"total": 12,
"score_min": 0.85,
"score_max": 0.95,
"clusters": []
},
{
"id": "kind:bug,score:strong",
"label": "strong",
"axis": "score",
"value": "strong",
"total": 8,
"score_min": 0.7,
"score_max": 0.85,
"clusters": []
}
]
},
{
"id": "kind:feat",
"label": "feat",
"axis": "kind",
"value": "feat",
"total": 18,
"score_min": 0.5,
"score_max": 0.85,
"clusters": []
}
],
"page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
}))
.expect("two-axis fixture envelope")
}
fn fixture_with_three_items() -> Envelope {
serde_json::from_value(json!({
"result": {
"input_total": 38,
"skipped": 0,
"axes": [{ "field": "kind", "strategy": "exact", "auto": false }],
"detection": {
"format": "json",
"items_path": ".",
"score_path": null,
"preset": null,
"fallback_reason": null
}
},
"meta": {},
"clusters": [
{
"id": "kind:bug",
"label": "kind-cluster-label-must-not-leak",
"axis": "kind",
"value": "bug",
"total": 3,
"score_min": null,
"score_max": null,
"clusters": []
}
],
"page": {
"cluster_id": "kind:bug",
"page": 1,
"per_page": 20,
"total_items": 3,
"items": [
{ "title": "alpha", "id": 1 },
{ "title": "beta", "id": 2 },
{ "title": "gamma", "id": 3 }
]
}
}))
.expect("fixture with three items")
}
fn fixture_empty_page() -> Envelope {
serde_json::from_value(json!({
"result": {
"input_total": 0,
"skipped": 0,
"axes": [],
"detection": {
"format": "json",
"items_path": ".",
"score_path": null,
"preset": null,
"fallback_reason": null
}
},
"meta": {},
"clusters": [],
"page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
}))
.expect("empty page fixture")
}
mod nested {
use super::*;
#[test]
fn pretty_output_round_trips() {
let env = fixture_two_axis();
let s = render(&env, OutputFormat::Json, &RenderOptions::default()).expect("render json");
let back: Envelope = serde_json::from_str(&s).expect("re-parse json");
assert_eq!(env, back, "round-trip mismatch via:\n{s}");
}
#[test]
fn output_starts_with_brace() {
let env = fixture_envelope();
let s = render(&env, OutputFormat::Json, &RenderOptions::default()).expect("render json");
assert!(
s.trim_start().starts_with('{'),
"expected JSON object start; got:\n{s}",
);
}
#[test]
fn result_axes_serialized() {
let env = fixture_envelope();
let s = render(&env, OutputFormat::Json, &RenderOptions::default()).expect("render json");
assert!(
s.contains("\"axes\""),
"expected axes field in serialized output; got:\n{s}",
);
}
}
mod flat {
use super::*;
#[test]
fn flat_clusters_have_parent_id() {
let env = fixture_two_axis();
let s = render(&env, OutputFormat::JsonFlat, &RenderOptions::default())
.expect("render json-flat");
let v: Value = serde_json::from_str(&s).expect("parse flat output");
let clusters = v
.get("clusters")
.and_then(|c| c.as_array())
.expect("`clusters` is an array");
assert!(
clusters.iter().any(|c| c.get("parent_id").is_some()),
"no `parent_id` key found in flat clusters; output:\n{s}",
);
}
#[test]
fn flat_round_trip_via_from_flat() {
let env = fixture_two_axis();
let s = render(&env, OutputFormat::JsonFlat, &RenderOptions::default())
.expect("render json-flat");
let v: Value = serde_json::from_str(&s).expect("parse flat output");
let clusters_v = v
.get("clusters")
.cloned()
.expect("`clusters` array present");
let flat: Vec<FlatCluster> =
serde_json::from_value(clusters_v).expect("flat clusters deserialize");
let nested = from_flat(&flat).expect("from_flat reconstructs tree");
assert_eq!(
nested, env.clusters,
"from_flat(JsonFlat output) must equal original nested tree",
);
}
}
mod jsonl_items {
use super::*;
#[test]
fn one_object_per_line() {
let env = fixture_with_three_items();
let s = render(&env, OutputFormat::JsonlItems, &RenderOptions::default())
.expect("render jsonl-items");
let lines: Vec<&str> = s.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
3,
"expected 3 non-empty lines; got {}; output:\n{s}",
lines.len(),
);
for line in lines {
let _: Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("line not JSON: {line:?}: {e}"));
}
}
#[test]
fn ignores_clusters() {
let env = fixture_with_three_items();
let s = render(&env, OutputFormat::JsonlItems, &RenderOptions::default())
.expect("render jsonl-items");
assert!(
!s.contains("kind-cluster-label-must-not-leak"),
"jsonl-items must not emit cluster labels; output:\n{s}",
);
}
#[test]
fn empty_page_yields_empty_or_just_newline() {
let env = fixture_empty_page();
let s = render(&env, OutputFormat::JsonlItems, &RenderOptions::default())
.expect("render jsonl-items");
assert!(
s.is_empty() || s == "\n",
"expected empty / single newline for empty items; got {s:?}",
);
}
}
mod output_format_serde {
use face_core::OutputFormat;
#[test]
fn kebab_case_round_trip() {
let cases = [
(OutputFormat::Human, "\"human\""),
(OutputFormat::Json, "\"json\""),
(OutputFormat::JsonFlat, "\"json-flat\""),
(OutputFormat::JsonlItems, "\"jsonl-items\""),
(OutputFormat::Tsv, "\"tsv\""),
(OutputFormat::Csv, "\"csv\""),
(OutputFormat::Markdown, "\"markdown\""),
];
for (fmt, wire) in cases {
let s =
serde_json::to_string(&fmt).unwrap_or_else(|e| panic!("serialize {fmt:?}: {e}"));
assert_eq!(s, wire, "serialize {fmt:?}");
let back: OutputFormat =
serde_json::from_str(wire).unwrap_or_else(|e| panic!("deserialize {wire}: {e}"));
assert_eq!(back, fmt, "deserialize {wire}");
}
}
}