use face_core::{Envelope, OutputFormat, RenderOptions, render};
use serde_json::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_page() -> 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": null,
"score_max": null,
"clusters": []
}
],
"page": {
"cluster_id": "kind:bug",
"page": 1,
"per_page": 20,
"total_items": 2,
"items": [
{ "title": "page-item-alpha-7af3" },
{ "title": "page-item-beta-9c11" }
]
}
}))
.expect("fixture with page")
}
fn opts(color: bool, width: Option<usize>) -> RenderOptions {
let mut o = RenderOptions::default();
o.color = color;
o.width = width;
o
}
fn human(env: &Envelope, opts: &RenderOptions) -> String {
render(env, OutputFormat::Human, opts).expect("human render")
}
mod basic {
use super::*;
#[test]
fn renders_two_top_level_clusters() {
let env = fixture_envelope();
let out = human(&env, &RenderOptions::default());
assert!(out.contains("bug"), "output missing 'bug':\n{out}");
assert!(out.contains("feat"), "output missing 'feat':\n{out}");
let bug_pos = out.find("bug").expect("bug present");
let feat_pos = out.find("feat").expect("feat present");
assert!(
bug_pos < feat_pos,
"expected count-desc order (bug before feat); output:\n{out}"
);
}
#[test]
fn chart_header_names_grouping_axis() {
let env = fixture_envelope();
let out = human(&env, &RenderOptions::default());
let header_pos = out
.find("grouped by: kind (exact)")
.expect("grouping header present");
let bug_pos = out.find("bug").expect("bug present");
assert!(
header_pos < bug_pos,
"grouping header should appear before chart rows; output:\n{out}",
);
}
#[test]
fn nested_chart_header_names_axis_chain() {
let env = fixture_two_axis();
let out = human(&env, &RenderOptions::default());
assert!(
out.contains("grouped by: kind (exact) -> score (bands)"),
"nested grouping header should name both axes; output:\n{out}",
);
}
#[test]
fn cluster_count_appears() {
let env = fixture_envelope();
let out = human(&env, &RenderOptions::default());
let bug_line = out
.lines()
.find(|l| l.contains("bug"))
.expect("bug line present");
assert!(
bug_line.contains("20"),
"bug line should contain count 20: {bug_line:?}"
);
let feat_line = out
.lines()
.find(|l| l.contains("feat"))
.expect("feat line present");
assert!(
feat_line.contains("18"),
"feat line should contain count 18: {feat_line:?}"
);
}
#[test]
fn top_level_clusters_have_one_based_ordinals() {
let env = fixture_envelope();
let out = human(&env, &RenderOptions::default());
let bug_line = out
.lines()
.find(|l| l.contains("bug"))
.expect("bug line present");
let feat_line = out
.lines()
.find(|l| l.contains("feat"))
.expect("feat line present");
assert!(
bug_line.trim_start().starts_with("[1]"),
"first cluster should carry [1] ordinal: {bug_line:?}",
);
assert!(
feat_line.trim_start().starts_with("[2]"),
"second cluster should carry [2] ordinal: {feat_line:?}",
);
}
#[test]
fn score_range_uses_en_dash() {
let env = fixture_envelope();
let out = human(&env, &RenderOptions::default());
assert!(
out.contains('\u{2013}'),
"expected en-dash (U+2013) in score range; output:\n{out}",
);
}
#[test]
fn no_color_by_default() {
let env = fixture_envelope();
let out = human(&env, &RenderOptions::default());
assert!(
!out.contains('\x1b'),
"default RenderOptions must not emit ANSI escape; output:\n{out}",
);
}
#[test]
fn color_emits_ansi_when_enabled() {
let env = fixture_envelope();
let out = human(&env, &opts(true, None));
assert!(
out.contains("\x1b["),
"color=true should emit at least one ANSI CSI sequence; output:\n{out}",
);
}
#[test]
fn bars_align_across_uneven_numeric_labels() {
let env: Envelope = serde_json::from_value(json!({
"result": {
"input_total": 6,
"skipped": 0,
"axes": [{ "field": "score", "strategy": "bands", "count": 2, "auto": true }],
"detection": {
"format": "json",
"items_path": ".",
"score_path": "score",
"preset": null,
"fallback_reason": null
}
},
"meta": {},
"clusters": [
{
"id": "score:32.37–38.28",
"label": "32.37–38.28",
"axis": "score",
"value": { "min": 32.37, "max": 38.28 },
"total": 1,
"score_min": 38.28,
"score_max": 38.28,
"clusters": []
},
{
"id": "score:8.73–14.64",
"label": "8.73–14.64",
"axis": "score",
"value": { "min": 8.73, "max": 14.64 },
"total": 5,
"score_min": 8.73,
"score_max": 13.68,
"clusters": []
}
],
"page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
}))
.expect("fixture envelope");
let out = human(&env, &RenderOptions::default());
assert!(
!out.contains("38.28–38.28"),
"score range is redundant when the cluster axis is the score path:\n{out}",
);
assert!(
!out.contains("8.73–13.68"),
"score range is redundant when the cluster axis is the score path:\n{out}",
);
let lines = out
.lines()
.filter(|line| line.contains("score:") || line.contains('\u{2588}'))
.collect::<Vec<_>>();
assert_eq!(lines.len(), 2, "expected two cluster lines:\n{out}");
let bar_cols = lines
.iter()
.map(|line| {
line.chars()
.position(|ch| ch == '\u{2588}')
.unwrap_or_else(|| panic!("line missing bar glyph: {line:?}"))
})
.collect::<Vec<_>>();
assert_eq!(
bar_cols[0], bar_cols[1],
"bar columns should align; lines={lines:?}"
);
}
}
mod width {
use super::*;
const BAR_GLYPHS: &[char] = &[
'\u{2588}', '\u{2589}', '\u{258A}', '\u{258B}', '\u{258C}', '\u{258D}', '\u{258E}', '\u{258F}', ];
fn contains_any_bar(s: &str) -> bool {
s.chars().any(|c| BAR_GLYPHS.contains(&c))
}
#[test]
fn width_drops_bars() {
let env = fixture_envelope();
let out = human(&env, &opts(false, Some(30)));
assert!(
!contains_any_bar(&out),
"width=30 should drop bars; output:\n{out}",
);
}
#[test]
fn width_drops_score_range_after_bars() {
let env = fixture_envelope();
let out = human(&env, &opts(false, Some(12)));
assert!(
!contains_any_bar(&out),
"width=12 should drop bars; output:\n{out}",
);
assert!(
!out.contains('\u{2013}'),
"width=12 should drop score range (en-dash); output:\n{out}",
);
}
#[test]
fn wide_keeps_everything() {
let env = fixture_envelope();
let out = human(&env, &opts(false, None));
assert!(
contains_any_bar(&out),
"no width cap should keep bars; output:\n{out}",
);
assert!(
out.contains('\u{2013}'),
"no width cap should keep score range; output:\n{out}",
);
}
}
mod nesting {
use super::*;
fn leading_ws(line: &str) -> usize {
line.chars().take_while(|c| c.is_whitespace()).count()
}
#[test]
fn nested_clusters_indented_more_than_parents() {
let env = fixture_two_axis();
let out = human(&env, &RenderOptions::default());
let bug_line = out
.lines()
.find(|l| l.contains("bug"))
.expect("bug line present");
let excellent_line = out
.lines()
.find(|l| l.contains("excellent"))
.expect("excellent line present");
let bug_ws = leading_ws(bug_line);
let excellent_ws = leading_ws(excellent_line);
assert!(
excellent_ws > bug_ws,
"child indent ({excellent_ws}) must exceed parent indent ({bug_ws}); \
parent: {bug_line:?} child: {excellent_line:?}",
);
assert!(
!excellent_line.trim_start().starts_with('['),
"nested children should not show top-level drill ordinals: {excellent_line:?}",
);
}
}
mod page {
use super::*;
#[test]
fn page_items_rendered_below_tree() {
let env = fixture_with_page();
let out = human(&env, &RenderOptions::default());
let bug_pos = out.find("bug").expect("bug label present");
let item_pos = out
.find("page-item-alpha-7af3")
.or_else(|| out.find("page-item-beta-9c11"))
.expect("at least one page-item value should be rendered");
assert!(
item_pos > bug_pos,
"page items should render after cluster tree; output:\n{out}",
);
}
#[test]
fn page_items_are_pretty_printed_json() {
let mut env = fixture_with_page();
env.page.items = vec![json!({
"name": "newUser",
"kind": "variant",
"module": "Room",
"file": "TaskSystemManager.swift",
"span": "129:14-129:14",
"score": 13.6773815,
"snippet": "case newUser = 11 //新用户房主任务"
})];
let out = human(&env, &RenderOptions::default());
assert!(
out.contains("{\n \"file\": \"TaskSystemManager.swift\""),
"page item should be pretty JSON; output:\n{out}"
);
assert!(
out.contains(" \"name\": \"newUser\""),
"pretty JSON keeps readable fields; output:\n{out}"
);
assert!(
!out.contains("\"name\":\"newUser\""),
"page item should not be compact JSON; output:\n{out}"
);
}
}