feat(decnet_web/AttackerDetail): attribution state badges (Phase 6)
Per-primitive state badge rendered next to each value in the
Behavioural Primitives panel. Five-state vocabulary, frozen, mirrors
decnet/correlation/attribution/aggregate.py:
* STABLE — green, low-key
* DRIFTING — amber, draws the eye
* CONFLICTED — red
* MULTI-ACTOR — purple, loudest (cross-primitive escalation lives
in attribution.multi_actor_suspected, not the
per-primitive badge)
* UNKNOWN — neutral border, no fill
Wiring:
* GET /api/v1/attackers/{id}/attribution on mount + on id change.
Failures swallowed silently (the worker may be off in dev).
* useAttackerStream gains attribution.state_changed +
attribution.multi_actor_suspected named events. The state-changed
handler merges by primitive and locks last_change_ts when the
state did not actually flip (defensive — backend already gates
these on transition, but a future relaxation shouldn't lie about
"stable since X" on the badge tooltip).
* multi_actor_suspected is wired but unused by the badges; the
per-primitive multi_actor signal already shows on each contributing
primitive. The handler is in place so a future "two operators
detected" banner has a live source.
Vitest: 4 new tests (badge renders only for mapped primitives, all
five states render with distinct labels, no badge when prop omitted)
on top of the existing 4. 7 of 7 pass; tsc + vite build clean.
This commit is contained in:
@@ -4,8 +4,24 @@ import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
BehaviouralPrimitivesPanel,
|
||||
type BehaviouralObservation,
|
||||
type AttributionPrimitiveState,
|
||||
} from './AttackerDetail';
|
||||
|
||||
const _attr = (
|
||||
primitive: string,
|
||||
state: AttributionPrimitiveState['state'],
|
||||
confidence = 0.85,
|
||||
observation_count = 6,
|
||||
): AttributionPrimitiveState => ({
|
||||
primitive,
|
||||
current_value: 'x',
|
||||
state,
|
||||
confidence,
|
||||
observation_count,
|
||||
last_change_ts: 1714000000,
|
||||
last_observation_ts: 1714000300,
|
||||
});
|
||||
|
||||
const _obs = (
|
||||
primitive: string,
|
||||
value: unknown,
|
||||
@@ -66,6 +82,75 @@ describe('BehaviouralPrimitivesPanel', () => {
|
||||
expect(row.textContent).toContain('91%');
|
||||
});
|
||||
|
||||
it('renders attribution badges only for primitives in the map', () => {
|
||||
const observations: BehaviouralObservation[] = [
|
||||
_obs('motor.input_modality', 'pasted', 0.91),
|
||||
_obs('cognitive.feedback_loop_engagement', 'closed_loop', 0.88),
|
||||
];
|
||||
const attribution = new Map<string, AttributionPrimitiveState>([
|
||||
['motor.input_modality', _attr('motor.input_modality', 'stable', 0.95)],
|
||||
// cognitive.feedback_loop_engagement intentionally absent —
|
||||
// the panel must not render a badge for it.
|
||||
]);
|
||||
render(
|
||||
<BehaviouralPrimitivesPanel
|
||||
observations={observations}
|
||||
attribution={attribution}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByTestId('attribution-badge-motor.input_modality');
|
||||
expect(badge.textContent).toBe('STABLE');
|
||||
expect(badge.getAttribute('data-state')).toBe('stable');
|
||||
expect(
|
||||
screen.queryByTestId(
|
||||
'attribution-badge-cognitive.feedback_loop_engagement',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders each of the five frozen states with a distinct label', () => {
|
||||
const cases: [AttributionPrimitiveState['state'], string][] = [
|
||||
['stable', 'STABLE'],
|
||||
['drifting', 'DRIFTING'],
|
||||
['conflicted', 'CONFLICTED'],
|
||||
['multi_actor', 'MULTI-ACTOR'],
|
||||
['unknown', 'UNKNOWN'],
|
||||
];
|
||||
const observations: BehaviouralObservation[] = cases.map((_pair, i) =>
|
||||
_obs(`motor.synthetic_${i}`, 'x'),
|
||||
);
|
||||
const attribution = new Map(
|
||||
cases.map(([state], i) => [
|
||||
`motor.synthetic_${i}`,
|
||||
_attr(`motor.synthetic_${i}`, state),
|
||||
]),
|
||||
);
|
||||
render(
|
||||
<BehaviouralPrimitivesPanel
|
||||
observations={observations}
|
||||
attribution={attribution}
|
||||
/>,
|
||||
);
|
||||
for (const [state, label] of cases) {
|
||||
const idx = cases.findIndex(([s]) => s === state);
|
||||
const badge = screen.getByTestId(
|
||||
`attribution-badge-motor.synthetic_${idx}`,
|
||||
);
|
||||
expect(badge.textContent).toBe(label);
|
||||
expect(badge.getAttribute('data-state')).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render badges when no attribution prop is provided', () => {
|
||||
const observations: BehaviouralObservation[] = [
|
||||
_obs('motor.input_modality', 'pasted'),
|
||||
];
|
||||
render(<BehaviouralPrimitivesPanel observations={observations} />);
|
||||
expect(
|
||||
screen.queryByTestId('attribution-badge-motor.input_modality'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('groups by top-level domain in the canonical order', () => {
|
||||
const observations: BehaviouralObservation[] = [
|
||||
_obs('emotional_valence.arousal', 'medium_engaged'),
|
||||
|
||||
Reference in New Issue
Block a user