Source code for exlab_wizard.ui.components.travelling_badge
"""Travelling problem-badge pure function. GUI/Orchestrator Redesign §4.5.
Given the validator finding set and per-node fold (expand) state, return
for each node the badge to render. The badge for each finding sits on
the **shallowest collapsed node** on the path to that finding, travelling
inward as nodes expand. If a node aggregates both red and amber findings
the red wins; the count is the total.
Pure function; no NiceGUI dependency. The tree renderer consumes the
returned ``BadgeProps`` to paint the badges.
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal
from exlab_wizard.constants.enums import Tier
BadgeColor = Literal["red", "amber"]
[docs]
@dataclass(frozen=True)
class BadgeProps:
"""Render properties for a single tree node's badge."""
color: BadgeColor
count: int
[docs]
@dataclass(frozen=True)
class FindingLocation:
"""A validator finding pinned to a tree-node path.
``path`` is the slash-joined node id of the deepest tree node the
finding belongs to (e.g. ``"EQUIP_A/PROJ-0001/Runs/Run_2026-04-17T14-32"``).
``tier`` is "hard" or "soft".
"""
path: str
tier: str # "hard" | "soft"
[docs]
def travelling_badges(
findings: Iterable[FindingLocation],
fold_state: dict[str, bool],
) -> dict[str, BadgeProps]:
"""Compute ``{node_id: BadgeProps}`` for every node that gets a badge.
``fold_state[node_id] == True`` means the node is **expanded**; False
or missing means collapsed. For each finding, walk from the leaf
toward the root and find the **shallowest** collapsed ancestor — that
ancestor receives the badge contribution. If every ancestor on the
path is expanded, the finding lives on its own node.
"""
per_node_red: dict[str, int] = defaultdict(int)
per_node_amber: dict[str, int] = defaultdict(int)
for finding in findings:
target = _shallowest_collapsed_ancestor(finding.path, fold_state)
if finding.tier == Tier.HARD.value:
per_node_red[target] += 1
else:
per_node_amber[target] += 1
out: dict[str, BadgeProps] = {}
for node_id in set(per_node_red) | set(per_node_amber):
red = per_node_red.get(node_id, 0)
amber = per_node_amber.get(node_id, 0)
if red > 0:
# Red wins; total count includes amber siblings on the same
# collapsed ancestor.
out[node_id] = BadgeProps(color="red", count=red + amber)
else:
out[node_id] = BadgeProps(color="amber", count=amber)
return out
def _shallowest_collapsed_ancestor(
path: str,
fold_state: dict[str, bool],
) -> str:
"""Walk root → leaf along ``path``; return the first collapsed ancestor.
If every ancestor is expanded, return ``path`` itself (the finding
lives on its own node).
"""
parts = [p for p in path.split("/") if p]
cumulative: str = ""
for part in parts:
cumulative = f"{cumulative}/{part}" if cumulative else part
if cumulative == path:
# We reached the finding's own node without finding a collapsed
# ancestor; the badge lives on the leaf.
return path
if not fold_state.get(cumulative, False):
# Collapsed (or unknown) — this is the shallowest collapsed
# ancestor on the path.
return cumulative
return path