Files
DECNET/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx
anti 5253b32319 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.
2026-05-09 02:28:11 -04:00

173 lines
6.0 KiB
TypeScript

import { describe, it, expect } from 'vitest';
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,
confidence = 0.85,
): BehaviouralObservation => ({
primitive,
value,
confidence,
ts: 1714521660.456,
source: 'test',
});
describe('BehaviouralPrimitivesPanel', () => {
it('renders an empty-state placeholder when no observations', () => {
render(<BehaviouralPrimitivesPanel observations={[]} />);
expect(screen.getByTestId('behaviour-empty')).toBeInTheDocument();
});
it('places day-one priority primitives at the top of their group', () => {
// Mix priority + non-priority primitives in arbitrary input order.
const observations: BehaviouralObservation[] = [
_obs('motor.keystroke_cadence', 'steady'),
_obs('cognitive.tool_vocabulary', 'broad'),
_obs('motor.input_modality', 'typed'), // priority #1
_obs('cognitive.feedback_loop_engagement', 'closed_loop'), // priority #2
_obs('cognitive.command_branch_diversity', 'adaptive_branching'), // #3
_obs('cognitive.inter_command_latency_class', 'typing_speed'), // #4
_obs('motor.error_correction', 'immediate'),
];
render(<BehaviouralPrimitivesPanel observations={observations} />);
// motor group: input_modality must precede the alphabetised rest.
const motorRows = Array.from(
document.querySelectorAll('[data-testid^="behaviour-row-motor."]'),
).map((el) => el.getAttribute('data-testid')!);
expect(motorRows[0]).toBe('behaviour-row-motor.input_modality');
// cognitive group: the three priority primitives must be in the
// documented order at the top.
const cogRows = Array.from(
document.querySelectorAll('[data-testid^="behaviour-row-cognitive."]'),
).map((el) => el.getAttribute('data-testid')!);
expect(cogRows.slice(0, 3)).toEqual([
'behaviour-row-cognitive.feedback_loop_engagement',
'behaviour-row-cognitive.command_branch_diversity',
'behaviour-row-cognitive.inter_command_latency_class',
]);
});
it('renders the primitive value and a confidence badge', () => {
const observations: BehaviouralObservation[] = [
_obs('motor.input_modality', 'pasted', 0.91),
];
render(<BehaviouralPrimitivesPanel observations={observations} />);
const row = screen.getByTestId('behaviour-row-motor.input_modality');
expect(row.textContent).toContain('input_modality');
expect(row.textContent).toContain('pasted');
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'),
_obs('temporal.session_duration', 'medium'),
_obs('motor.input_modality', 'typed'),
_obs('cognitive.cognitive_load', 'medium'),
];
render(<BehaviouralPrimitivesPanel observations={observations} />);
const groups = Array.from(
document.querySelectorAll('[data-testid^="behaviour-group-"]'),
).map((el) => el.getAttribute('data-testid')!);
expect(groups).toEqual([
'behaviour-group-motor',
'behaviour-group-cognitive',
'behaviour-group-temporal',
'behaviour-group-emotional_valence',
]);
});
});