use super::graph::DEFAULT_VALUE;
use crate::estimate::display::format_bound;
use crate::listing::{self, Column, ColumnPaint, RenderOpts, TITLE_EVEN, TITLE_ODD, status_hue};
use owo_colors::{
AnsiColors::{Cyan, Red},
DynColors,
};
use super::findings::Finding;
use super::view::{ActionabilityBlock, BlockersView, Explanation, NextRow, ReasonKind, SurveyRow};
pub(crate) const PRIORITY_POLICY_VERSION: &str = "priority.v3";
const SURVEY_COLS: [Column<SurveyRow>; 6] = [
Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: ColumnPaint::ByValue(|r| {
if matches!(r.act, super::view::Actionability::Blocked) {
Some(DynColors::Ansi(Red))
} else {
Some(DynColors::Ansi(Cyan))
}
}),
},
Column {
name: "kind",
header: "kind",
cell: |r| r.kind.clone(),
paint: ColumnPaint::None,
},
Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: ColumnPaint::ByValue(|r| status_hue(&r.status)),
},
Column {
name: "score",
header: "score",
cell: |r| format!("{:.1}", r.score),
paint: ColumnPaint::None,
},
Column {
name: "blocker",
header: "blocker",
cell: |r| r.blockers.first().cloned().unwrap_or_default(),
paint: ColumnPaint::None,
},
Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: ColumnPaint::Alternate([TITLE_EVEN, TITLE_ODD]),
},
];
#[expect(
dead_code,
reason = "declared for IMP-038 validation parity; not used by render_columns (priority has no --columns surface)"
)]
const SURVEY_DEFAULT: &[&str] = &["id", "kind", "status", "score", "blocker", "title"];
const NEXT_COLS: [Column<NextRow>; 8] = [
Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: ColumnPaint::Fixed(DynColors::Ansi(Cyan)),
},
Column {
name: "kind",
header: "kind",
cell: |r| r.kind.clone(),
paint: ColumnPaint::None,
},
Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: ColumnPaint::ByValue(|r| status_hue(&r.status)),
},
Column {
name: "score",
header: "score",
cell: |r| format!("{:.1}", r.score),
paint: ColumnPaint::None,
},
Column {
name: "estimate",
header: "estimate",
cell: |r| estimate_cell(r),
paint: ColumnPaint::None,
},
Column {
name: "value",
header: "value",
cell: |r| value_cell(r),
paint: ColumnPaint::None,
},
Column {
name: "tags",
header: "tags",
cell: |r| {
if r.tags.is_empty() {
listing::ABSENT_CELL.to_string()
} else {
r.tags.join(", ")
}
},
paint: ColumnPaint::PerToken {
split: |r| r.tags.clone(),
render: listing::paint_tag,
},
},
Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: ColumnPaint::Alternate([TITLE_EVEN, TITLE_ODD]),
},
];
const NEXT_DEFAULT: &[&str] = &["id", "status", "score", "estimate", "value", "title"];
fn estimate_cell(r: &NextRow) -> String {
match &r.estimate {
Some(e) => format!(
"{:>4} - {:>4}",
format_bound(e.lower),
format_bound(e.upper)
),
None => listing::ABSENT_CELL.to_string(),
}
}
const DEFAULT_VALUE_MARKER: &str = "*";
fn value_cell(r: &NextRow) -> String {
match &r.value {
Some(v) => format_bound(v.value),
None if crate::kinds::is_value_bearing(&r.kind) => {
format!("{}{DEFAULT_VALUE_MARKER}", format_bound(DEFAULT_VALUE))
}
None => listing::ABSENT_CELL.to_string(),
}
}
pub(crate) fn survey_human(
rows: &[SurveyRow],
opts: RenderOpts,
limit: usize,
offset: usize,
) -> String {
if rows.is_empty() {
return "(no eligible work)\n".to_string();
}
let (visible, footer) = paginated(rows, limit, offset);
let sel: Vec<&Column<SurveyRow>> = SURVEY_COLS.iter().collect();
let mut out = listing::render_columns(visible, &sel, opts);
if let Some(f) = footer {
out.push_str(&f);
}
out
}
fn paginated<T>(rows: &[T], limit: usize, offset: usize) -> (&[T], Option<String>) {
let total = rows.len();
let start = offset.min(total);
let end = if limit == 0 {
total
} else {
(start + limit).min(total)
};
let visible = rows.get(start..end).unwrap_or(&[]);
let shown = visible.len();
let footer = if limit != 0 && shown < total {
Some(listing::format_truncation_notice(
shown, total, offset, limit,
))
} else {
None
};
(visible, footer)
}
pub(crate) fn next_human(
rows: &[NextRow],
opts: RenderOpts,
columns: Option<&[String]>,
limit: usize,
offset: usize,
) -> anyhow::Result<String> {
if rows.is_empty() {
return Ok("(nothing actionable)\n".to_string());
}
let (visible, footer) = paginated(rows, limit, offset);
let any_tagged = visible.iter().any(|r| !r.tags.is_empty());
let effective = listing::default_with_tags(NEXT_DEFAULT, any_tagged);
let sel = listing::select_columns(&NEXT_COLS, &effective, columns)?;
let mut out = listing::render_columns(visible, &sel, opts);
if let Some(f) = footer {
out.push_str(&f);
}
Ok(out)
}
pub(crate) fn blockers_human(view: &BlockersView) -> String {
let depth = if view.transitive {
"transitive"
} else {
"direct"
};
let mut parts: Vec<String> = vec![format!("{} — blockers ({depth})\n", view.id)];
if !view.blocked_by.is_empty() {
parts.push("\nblocked by:\n".to_string());
for b in &view.blocked_by {
parts.push(format!(" {b}\n"));
}
}
if !view.blocking.is_empty() {
parts.push("\nblocking:\n".to_string());
for b in &view.blocking {
parts.push(format!(" {b}\n"));
}
}
if view.blocked_by.is_empty() && view.blocking.is_empty() {
parts.push("\n(no blockers, blocks nothing)\n".to_string());
}
parts.concat()
}
fn reason_line(reason: &ReasonKind) -> String {
match reason {
ReasonKind::Eligibility { status, class } => {
let s = status.as_deref().unwrap_or("—");
format!(" eligibility: {s} → {class:?}\n")
}
ReasonKind::BlockedBy { items } => format!(" blocked by: {}\n", items.join(", ")),
ReasonKind::Blocking { items } => format!(" blocking: {}\n", items.join(", ")),
ReasonKind::Score {
base,
value_dim,
risk_dim,
leverage,
optionality,
total,
} => format!(
" score: {total:.1} (base {base:.1} [value {value_dim:.1}, risk {risk_dim:.1}], \
leverage {leverage:.1}, optionality {optionality:.1})\n"
),
ReasonKind::EvictedEdge { .. } | ReasonKind::CycleDegraded { .. } => {
format!(" {}\n", provenance_fragment(reason).unwrap_or_default())
}
}
}
fn provenance_fragment(reason: &ReasonKind) -> Option<String> {
match reason {
ReasonKind::EvictedEdge { from, to, reason } => {
Some(format!("evicted seq edge: {from} → {to} ({reason:?})"))
}
ReasonKind::CycleDegraded { nodes } => {
Some(format!("dep cycle (order degraded): {}", nodes.join(", ")))
}
_ => None,
}
}
pub(crate) fn explain_human(ex: &Explanation) -> String {
let mut parts: Vec<String> = vec![format!("{} — explain\n", ex.id)];
parts.push(reason_line(&ex.eligibility));
for r in &ex.blocker_chain {
parts.push(reason_line(r));
}
for r in &ex.evictions {
parts.push(reason_line(r));
}
parts.push(reason_line(&ex.score));
parts.concat()
}
fn finding_line(f: &Finding) -> String {
match f {
Finding::Fork { hub, arms } => {
format!(
" {hub} settles → {{{}}} ({} arms)\n",
arms.join(", "),
arms.len()
)
}
Finding::Join { node, prereqs } => format!(
" {node} needs → {{{}}} ({} prereqs)\n",
prereqs.join(", "),
prereqs.len()
),
Finding::GatingFanOut { record, blocks } => format!(
" {record} gates → {{{}}} ({} blocks)\n",
blocks.join(", "),
blocks.len()
),
Finding::ValueInversion {
blocker,
blocked,
gap,
} => format!(" {blocker} gates {blocked} Δ{gap:.1}\n"),
Finding::Displacement {
node,
score_rank,
constrained_rank,
delta,
} => format!(" {node} score #{score_rank} vs survey #{constrained_rank} Δ{delta}\n"),
Finding::Plateau { members, span } => {
let body = match members.as_slice() {
[] => String::new(),
[only] => only.clone(),
[first, .., last] => format!("{first} … {last}"),
};
format!(" {{{body}}} ({}, span {span:.2})\n", members.len())
}
Finding::OrderInstability { high, low, .. } => {
format!(" {high} ↔ {low} (flips β0↔β1)\n")
}
Finding::ArmResequencing {
hub,
order_lo,
order_hi,
..
} => format!(
" {hub} arms {{{}}} → {{{}}} (β0↔β1)\n",
order_lo.join(", "),
order_hi.join(", ")
),
Finding::Provenance(reason) => {
format!(" {}\n", provenance_fragment(reason).unwrap_or_default())
}
}
}
pub(crate) fn findings_human(findings: &[Finding]) -> String {
if findings.is_empty() {
return "(no findings)\n".to_string();
}
let mut parts: Vec<String> = Vec::new();
let mut current: Option<&str> = None;
for f in findings {
let label = f.kind_label();
if current != Some(label) {
parts.push(format!("{label}\n"));
current = Some(label);
}
parts.push(finding_line(f));
}
parts.concat()
}
fn finding_json(f: &Finding) -> serde_json::Value {
let mut value = match f {
Finding::Fork { hub, arms } => serde_json::json!({ "hub": hub, "arms": arms }),
Finding::Join { node, prereqs } => serde_json::json!({ "node": node, "prereqs": prereqs }),
Finding::GatingFanOut { record, blocks } => {
serde_json::json!({ "record": record, "blocks": blocks })
}
Finding::ValueInversion {
blocker,
blocked,
gap,
} => serde_json::json!({ "blocker": blocker, "blocked": blocked, "gap": gap }),
Finding::Displacement {
node,
score_rank,
constrained_rank,
delta,
} => serde_json::json!({
"node": node,
"score_rank": score_rank,
"constrained_rank": constrained_rank,
"delta": delta,
}),
Finding::Plateau { members, span } => {
serde_json::json!({ "members": members, "span": span })
}
Finding::OrderInstability { high, low, .. } => {
serde_json::json!({ "high": high, "low": low })
}
Finding::ArmResequencing {
hub,
order_lo,
order_hi,
..
} => serde_json::json!({ "hub": hub, "order_lo": order_lo, "order_hi": order_hi }),
Finding::Provenance(reason) => serde_json::json!({ "detail": reason_json(reason) }),
};
if let Some(obj) = value.as_object_mut() {
obj.insert("kind".to_string(), serde_json::json!(f.kind_label()));
obj.insert("magnitude".to_string(), serde_json::json!(f.magnitude()));
}
value
}
pub(crate) fn findings_json(findings: &[Finding]) -> anyhow::Result<String> {
let items: Vec<serde_json::Value> = findings.iter().map(finding_json).collect();
finish(&serde_json::json!({
"kind": "findings",
"policy_version": PRIORITY_POLICY_VERSION,
"findings": items,
}))
}
pub(crate) fn actionability_block_human(block: &ActionabilityBlock) -> String {
let mut parts: Vec<String> = vec!["\nactionability:\n".to_string()];
parts.push(format!(" eligible: {}\n", block.eligible));
parts.push(format!(" actionable: {}\n", block.actionable));
parts.push(format!(" score: {:.1}\n", block.score));
if !block.blockers.is_empty() {
parts.push(format!(" blocked by: {}\n", block.blockers.join(", ")));
}
if !block.blocking.is_empty() {
parts.push(format!(" blocking: {}\n", block.blocking.join(", ")));
}
parts.concat()
}
fn reason_json(reason: &ReasonKind) -> serde_json::Value {
match reason {
ReasonKind::Eligibility { status, class } => serde_json::json!({
"kind": "eligibility",
"status": status,
"class": format!("{class:?}"),
}),
ReasonKind::BlockedBy { items } => {
serde_json::json!({ "kind": "blocked_by", "items": items })
}
ReasonKind::Blocking { items } => {
serde_json::json!({ "kind": "blocking", "items": items })
}
ReasonKind::Score {
base,
value_dim,
risk_dim,
leverage,
optionality,
total,
} => serde_json::json!({
"kind": "score",
"base": base,
"value_dim": value_dim,
"risk_dim": risk_dim,
"leverage": leverage,
"optionality": optionality,
"total": total,
}),
ReasonKind::EvictedEdge { from, to, reason } => serde_json::json!({
"kind": "evicted_edge",
"from": from,
"to": to,
"reason": format!("{reason:?}"),
}),
ReasonKind::CycleDegraded { nodes } => {
serde_json::json!({ "kind": "cycle_degraded", "nodes": nodes })
}
}
}
fn finish(value: &serde_json::Value) -> anyhow::Result<String> {
serde_json::to_string_pretty(value)
.map_err(|e| anyhow::anyhow!("failed to serialize priority JSON: {e}"))
}
pub(crate) fn survey_json(rows: &[SurveyRow]) -> anyhow::Result<String> {
let rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"title": r.title,
"kind": r.kind,
"status": r.status,
"actionability": r.act.token(),
"score": r.score,
"blockers": r.blockers,
"reasons": r.reasons.iter().map(reason_json).collect::<Vec<_>>(),
})
})
.collect();
finish(&serde_json::json!({
"kind": "survey",
"policy_version": PRIORITY_POLICY_VERSION,
"rows": rows,
}))
}
pub(crate) fn next_json(rows: &[NextRow]) -> anyhow::Result<String> {
let rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"title": r.title,
"kind": r.kind,
"status": r.status,
"actionability": r.act.token(),
"score": r.score,
"blocking": r.blocking,
"reasons": r.reasons.iter().map(reason_json).collect::<Vec<_>>(),
})
})
.collect();
finish(&serde_json::json!({
"kind": "next",
"policy_version": PRIORITY_POLICY_VERSION,
"rows": rows,
}))
}
pub(crate) fn blockers_json(view: &BlockersView) -> anyhow::Result<String> {
finish(&serde_json::json!({
"kind": "blockers",
"policy_version": PRIORITY_POLICY_VERSION,
"id": view.id,
"transitive": view.transitive,
"blocked_by": view.blocked_by,
"blocking": view.blocking,
}))
}
pub(crate) fn explain_json(ex: &Explanation) -> anyhow::Result<String> {
finish(&serde_json::json!({
"kind": "explain",
"policy_version": PRIORITY_POLICY_VERSION,
"id": ex.id,
"eligibility": reason_json(&ex.eligibility),
"blocker_chain": ex.blocker_chain.iter().map(reason_json).collect::<Vec<_>>(),
"evictions": ex.evictions.iter().map(reason_json).collect::<Vec<_>>(),
"score": reason_json(&ex.score),
}))
}
pub(crate) fn actionability_block_value(block: &ActionabilityBlock) -> serde_json::Value {
serde_json::json!({
"eligible": block.eligible,
"actionable": block.actionable,
"blockers": block.blockers,
"blocking": block.blocking,
"score": block.score,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::estimate::EstimateFacet;
use crate::listing::ABSENT_CELL;
use crate::priority::view::Actionability;
use crate::value::ValueFacet;
fn bare_row(id: &str) -> NextRow {
NextRow {
id: id.to_string(),
title: "Title".to_string(),
kind: "ISS".to_string(),
status: "open".to_string(),
act: Actionability::Actionable,
score: 0.0,
reasons: vec![],
blockers: vec![],
blocking: vec![],
estimate: None,
value: None,
tags: vec![],
}
}
fn faceted_row(id: &str, lo: f64, hi: f64, val: f64, tags: &[&str]) -> NextRow {
NextRow {
id: id.to_string(),
title: "Title".to_string(),
kind: "ISS".to_string(),
status: "open".to_string(),
act: Actionability::Actionable,
score: val / 6.5,
reasons: vec![],
blockers: vec![],
blocking: vec![],
estimate: Some(EstimateFacet {
lower: lo,
upper: hi,
}),
value: Some(ValueFacet { value: val }),
tags: tags.iter().map(|t| (*t).to_string()).collect(),
}
}
fn header(out: &str) -> &str {
out.lines().next().unwrap_or("")
}
#[test]
fn vt1_columns_id_score_emits_exact_headers() {
let rows = vec![bare_row("ISS-001")];
let out = next_human(
&rows,
RenderOpts::default(),
Some(&["id".to_string(), "score".to_string()]),
20,
0,
)
.unwrap();
assert!(header(&out).contains("id"), "header has id: {out}");
assert!(header(&out).contains("score"), "header has score: {out}");
assert!(!header(&out).contains("kind"), "header lacks kind: {out}");
}
#[test]
fn vt1_columns_bogus_errors_with_available_set() {
let rows = vec![bare_row("ISS-001")];
let err = next_human(
&rows,
RenderOpts::default(),
Some(&["bogus".to_string()]),
20,
0,
)
.unwrap_err()
.to_string();
assert!(err.contains("unknown column `bogus`"), "got: {err}");
assert!(err.contains("available:"), "got: {err}");
}
#[test]
fn vt2_default_headers_no_kind_no_unblocks() {
let rows = vec![bare_row("ISS-001")];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
let h = header(&out);
assert!(h.contains("id"), "header has id: {h}");
assert!(h.contains("status"), "header has status: {h}");
assert!(h.contains("score"), "header has score: {h}");
assert!(h.contains("estimate"), "header has estimate: {h}");
assert!(h.contains("value"), "header has value: {h}");
assert!(h.contains("title"), "header has title: {h}");
assert!(!h.contains("kind"), "kind absent from default: {h}");
assert!(!h.contains("unblocks"), "unblocks absent from default: {h}");
}
#[test]
fn vt2_columns_unblocks_errors_no_such_column() {
let rows = vec![bare_row("ISS-001")];
let err = next_human(
&rows,
RenderOpts::default(),
Some(&["unblocks".to_string()]),
20,
0,
)
.unwrap_err()
.to_string();
assert!(err.contains("unknown column `unblocks`"), "got: {err}");
}
#[test]
fn vt3_tags_column_appears_when_any_row_tagged() {
let rows = vec![
bare_row("ISS-001"),
faceted_row("ISS-002", 0.0, 10.0, 5.0, &["cli:command"]),
];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(
header(&out).contains("tags"),
"tags column appears when any row tagged: {out}"
);
}
#[test]
fn vt3_tags_column_hidden_when_none_tagged() {
let rows = vec![bare_row("ISS-001"), bare_row("ISS-002")];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(
!header(&out).contains("tags"),
"tags column hidden when none tagged: {out}"
);
}
#[test]
fn vt3_columns_tags_forces_column_even_all_empty() {
let rows = vec![bare_row("ISS-001")];
let out = next_human(
&rows,
RenderOpts::default(),
Some(&["id".to_string(), "tags".to_string()]),
20,
0,
)
.unwrap();
assert!(
header(&out).contains("tags"),
"--columns tags forces column: {out}"
);
}
#[test]
fn vt4_format_bound_estimate_fractional() {
let rows = vec![faceted_row("ISS-001", 3.2, 4.8, 5.0, &[])];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(out.contains(" 3.2 - 4.8"), "fractional estimate: {out}");
}
#[test]
fn vt4_format_bound_estimate_integral() {
let rows = vec![faceted_row("ISS-001", 3.0, 8.0, 5.0, &[])];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(
out.contains(" 3.0 - 8.0"),
"integral estimate shows .0: {out}"
);
}
#[test]
fn vt4_format_bound_value_integral() {
let rows = vec![faceted_row("ISS-001", 3.0, 8.0, 5.0, &[])];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(
out.contains(" 5.0 ") || out.contains("│5.0│"),
"integral value 5.0: {out}"
);
}
#[test]
fn vt4_format_bound_value_fractional() {
let rows = vec![faceted_row("ISS-001", 3.0, 8.0, 5.5, &[])];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(out.contains("5.5"), "fractional value 5.5: {out}");
}
#[test]
fn vt4_absent_cell_for_bare_row() {
let rows = vec![bare_row("ISS-001")];
let out = next_human(&rows, RenderOpts::default(), None, 20, 0).unwrap();
assert!(out.contains(ABSENT_CELL), "bare row has ABSENT_CELL: {out}");
}
#[test]
fn value_cell_shows_marked_default_for_value_bearing_kind_without_facet() {
let mut r = bare_row("ISS-001"); assert_eq!(
value_cell(&r),
format!("{}{DEFAULT_VALUE_MARKER}", format_bound(DEFAULT_VALUE))
);
r.value = Some(ValueFacet { value: 2.0 });
assert_eq!(value_cell(&r), format_bound(2.0));
}
#[test]
fn value_cell_is_absent_for_valueless_kind_without_facet() {
let mut r = bare_row("REV-001");
r.kind = "REV".to_string();
r.value = None;
assert_eq!(value_cell(&r), ABSENT_CELL);
}
fn five_rows() -> Vec<NextRow> {
(1..=5).map(|n| bare_row(&format!("ISS-00{n}"))).collect()
}
#[test]
fn vt_pagination_limit_shows_footer() {
let rows = five_rows();
let out = next_human(&rows, RenderOpts::default(), None, 2, 0).unwrap();
assert!(out.contains("ISS-001"), "page 1 row 1: {out}");
assert!(out.contains("ISS-002"), "page 1 row 2: {out}");
assert!(!out.contains("ISS-003"), "row 3 clipped: {out}");
assert!(out.contains("2 of 5"), "footer count: {out}");
assert!(out.contains("--page 2"), "footer next-page: {out}");
}
#[test]
fn vt_pagination_offset_slices_and_advances_page() {
let rows = five_rows();
let out = next_human(&rows, RenderOpts::default(), None, 2, 2).unwrap();
assert!(out.contains("ISS-003"), "offset page row 3: {out}");
assert!(out.contains("ISS-004"), "offset page row 4: {out}");
assert!(!out.contains("ISS-001"), "row 1 skipped: {out}");
assert!(out.contains("--page 3"), "footer advances page: {out}");
}
#[test]
fn vt_pagination_limit_zero_uncapped_no_footer() {
let rows = five_rows();
let out = next_human(&rows, RenderOpts::default(), None, 0, 0).unwrap();
for n in 1..=5 {
assert!(out.contains(&format!("ISS-00{n}")), "row {n} shown: {out}");
}
assert!(!out.contains(" of 5"), "no footer when uncapped: {out}");
}
#[test]
fn vt_pagination_limit_zero_with_offset_no_panic_no_footer() {
let rows = five_rows();
let out = next_human(&rows, RenderOpts::default(), None, 0, 2).unwrap();
assert!(!out.contains("ISS-001"), "offset honoured: {out}");
assert!(out.contains("ISS-003"), "rows[2..] shown: {out}");
assert!(
!out.contains(" of 5"),
"no footer with --limit 0 --offset N: {out}"
);
}
#[test]
fn vt_pagination_offset_exceeds_total() {
let rows = five_rows();
let out = next_human(&rows, RenderOpts::default(), None, 2, 10).unwrap();
assert!(
out.contains("no results at this offset"),
"offset-branch footer: {out}"
);
assert!(out.contains("0 of 5"), "shown=0 of total: {out}");
}
#[test]
fn vt_d7_tags_gate_is_per_visible_page() {
let rows = vec![
bare_row("ISS-001"),
bare_row("ISS-002"),
faceted_row("ISS-003", 0.0, 1.0, 1.0, &["cli:command"]),
];
let page1 = next_human(&rows, RenderOpts::default(), None, 2, 0).unwrap();
assert!(
!page1.lines().next().unwrap_or("").contains("tags"),
"page 1 (no tagged row) hides tags column: {page1}"
);
let page2 = next_human(&rows, RenderOpts::default(), None, 2, 2).unwrap();
assert!(
page2.lines().next().unwrap_or("").contains("tags"),
"page 2 (tagged row) shows tags column: {page2}"
);
}
use super::super::view::ReasonKind;
use crate::backlog_order::OverrideReason;
#[test]
fn vt6_findings_human_groups_by_kind_and_reuses_provenance_fragment() {
let findings = vec![
Finding::Fork {
hub: "ISS-001".to_string(),
arms: vec!["ISS-002".to_string(), "ISS-003".to_string()],
},
Finding::ValueInversion {
blocker: "ISS-004".to_string(),
blocked: "ISS-005".to_string(),
gap: 16.3,
},
Finding::Provenance(ReasonKind::EvictedEdge {
from: "ISS-006".to_string(),
to: "ISS-007".to_string(),
reason: OverrideReason::SoftCycleEvicted,
}),
];
let out = findings_human(&findings);
assert!(out.contains("forks\n"), "forks header: {out}");
assert!(
out.contains("value inversions\n"),
"value inversions header: {out}"
);
assert!(out.contains("provenance\n"), "provenance header: {out}");
assert!(
out.contains("ISS-001 settles → {ISS-002, ISS-003} (2 arms)"),
"fork line: {out}"
);
assert!(
out.contains("ISS-004 gates ISS-005 Δ16.3"),
"inversion line: {out}"
);
let via_reason = reason_line(&ReasonKind::EvictedEdge {
from: "ISS-006".to_string(),
to: "ISS-007".to_string(),
reason: OverrideReason::SoftCycleEvicted,
});
assert!(
out.contains(via_reason.trim_end()),
"reuses explain fragment: {out}"
);
}
#[test]
fn vt6_findings_human_empty_is_clean_note() {
assert_eq!(findings_human(&[]), "(no findings)\n");
}
#[test]
fn vt6_findings_json_shape_kind_payload_magnitude() {
let findings = vec![Finding::Fork {
hub: "ISS-001".to_string(),
arms: vec!["ISS-002".to_string(), "ISS-003".to_string()],
}];
let out = findings_json(&findings).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["kind"], "findings");
assert_eq!(v["policy_version"], PRIORITY_POLICY_VERSION);
let f0 = &v["findings"][0];
assert_eq!(f0["kind"], "forks", "json kind tag == kind_label");
assert_eq!(f0["hub"], "ISS-001");
assert_eq!(f0["arms"][0], "ISS-002");
assert_eq!(f0["magnitude"], 2.0, "magnitude = arm count");
}
#[test]
fn vt6_findings_json_provenance_nests_reason() {
let findings = vec![Finding::Provenance(ReasonKind::CycleDegraded {
nodes: vec!["ISS-001".to_string(), "ISS-002".to_string()],
})];
let out = findings_json(&findings).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
let f0 = &v["findings"][0];
assert_eq!(f0["kind"], "provenance");
assert_eq!(f0["detail"]["kind"], "cycle_degraded");
assert_eq!(f0["detail"]["nodes"][1], "ISS-002");
assert_eq!(f0["magnitude"], 2.0);
}
#[test]
fn vt3_beta_family_renders_one_line_each_human() {
let findings = vec![
Finding::OrderInstability {
high: "IMP-054".to_string(),
low: "IMP-071".to_string(),
moved: 3,
},
Finding::ArmResequencing {
hub: "QUE-003".to_string(),
order_lo: vec!["IMP-054".to_string(), "IMP-071".to_string()],
order_hi: vec!["IMP-071".to_string(), "IMP-054".to_string()],
moved: 2,
},
];
let out = findings_human(&findings);
assert!(
out.contains("order instability\n"),
"order instability header: {out}"
);
assert!(
out.contains("arm resequencing\n"),
"arm resequencing header: {out}"
);
assert!(
out.contains("IMP-054 ↔ IMP-071 (flips β0↔β1)"),
"order-instability line: {out}"
);
assert!(
out.contains("QUE-003 arms {IMP-054, IMP-071} → {IMP-071, IMP-054} (β0↔β1)"),
"arm-resequencing line: {out}"
);
}
#[test]
fn vt3_beta_family_json_payload_and_magnitude() {
let oi = Finding::OrderInstability {
high: "IMP-054".to_string(),
low: "IMP-071".to_string(),
moved: 4,
};
let out = findings_json(std::slice::from_ref(&oi)).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
let f0 = &v["findings"][0];
assert_eq!(f0["kind"], "order instability");
assert_eq!(f0["high"], "IMP-054");
assert_eq!(f0["low"], "IMP-071");
assert_eq!(f0["magnitude"], 4.0, "magnitude = positions moved");
assert!(
f0.get("moved").is_none(),
"moved is surfaced via magnitude only"
);
let ar = Finding::ArmResequencing {
hub: "QUE-003".to_string(),
order_lo: vec!["A-1".to_string(), "A-2".to_string()],
order_hi: vec!["A-2".to_string(), "A-1".to_string()],
moved: 2,
};
let out2 = findings_json(std::slice::from_ref(&ar)).unwrap();
let v2: serde_json::Value = serde_json::from_str(&out2).unwrap();
let g0 = &v2["findings"][0];
assert_eq!(g0["kind"], "arm resequencing");
assert_eq!(g0["hub"], "QUE-003");
assert_eq!(g0["order_lo"][0], "A-1");
assert_eq!(g0["order_hi"][0], "A-2");
assert_eq!(g0["magnitude"], 2.0, "magnitude = arms moved");
}
#[test]
fn vt3_no_beta_variants_render_no_beta_section() {
let findings = vec![Finding::Fork {
hub: "ISS-001".to_string(),
arms: vec!["ISS-002".to_string(), "ISS-003".to_string()],
}];
let out = findings_human(&findings);
assert!(!out.contains("order instability"), "no OI section: {out}");
assert!(!out.contains("arm resequencing"), "no AR section: {out}");
}
#[test]
fn vt_d7_columns_tags_overrides_visible_gate() {
let rows = vec![bare_row("ISS-001"), bare_row("ISS-002")];
let out = next_human(
&rows,
RenderOpts::default(),
Some(&["id".to_string(), "tags".to_string()]),
2,
0,
)
.unwrap();
assert!(
out.lines().next().unwrap_or("").contains("tags"),
"explicit --columns tags overrides the visible-page gate: {out}"
);
}
}