Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/ui/src/components/sessionMusician/ConfidenceBandBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ import {
type ConfidenceBand,
} from '../../services/sessionMusician/confidenceBand';

// Four-tone traffic-light ladder so the producer can distinguish all four
// bands at a glance: green → orange → amber → red, best to worst. The
// vocabulary mirrors what `MeasurementPrimitives`, `DiagnosticLog`, and
// `FileUpload` already use for severity across the app — success = ready,
// accent = active, warning = caution, error = critical. All four CSS
// variables are defined in `apps/ui/src/index.css`; no new theme tokens
// needed. Uniform opacity (`/30` border, `/10` bg) matches the established
// BADGE_TONE_CLASSES convention; the color is what carries the distinction.
const PILL_CLASSES: Record<ConfidenceBand['id'], string> = {
solid: 'border-accent/40 text-accent bg-accent/10',
workable: 'border-accent/30 text-accent bg-accent/5',
solid: 'border-success/30 text-success bg-success/10',
workable: 'border-accent/40 text-accent bg-accent/10',
rough: 'border-warning/30 text-warning bg-warning/10',
unreliable: 'border-warning/40 text-warning bg-warning/15',
unreliable: 'border-error/30 text-error bg-error/10',
};

interface ConfidenceBandBadgeProps {
Expand Down
139 changes: 139 additions & 0 deletions apps/ui/tests/services/confidenceBandBadge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Verifies the four confidence bands render with distinct color tokens so
// the producer can tell Solid from Workable, and Rough from Unreliable, at
// a glance. The pure helper tests in `confidenceBand.test.ts` cover label
// and copy; this file pins the visual tone mapping in
// `ConfidenceBandBadge.tsx` and the override path used by
// full-mix-fallback / legacy render states.

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';

import { ConfidenceBandBadge } from '../../src/components/sessionMusician/ConfidenceBandBadge';

// Confidence values that fall squarely inside each band per the thresholds
// in services/sessionMusician/confidenceBand.ts (≥0.80 solid, ≥0.50 workable,
// ≥0.25 rough, <0.25 unreliable).
const BAND_CONFIDENCE = {
solid: 0.9,
workable: 0.6,
rough: 0.3,
unreliable: 0.1,
} as const;

// Extracts the three color tokens (border, text, bg) from a rendered pill so
// tests can compare bands without pinning the exact opacity suffix. Returns
// e.g. `{ border: 'border-success/30', text: 'text-success', bg: 'bg-success/10' }`.
function extractColorTokens(html: string): { border: string; text: string; bg: string } {
const border = html.match(/border-(success|accent|warning|error)\/\d+/)?.[0];
const text = html.match(/text-(success|accent|warning|error)(?!-)\b/)?.[0];
const bg = html.match(/bg-(success|accent|warning|error)\/\d+/)?.[0];
if (!border || !text || !bg) {
throw new Error(`Could not extract all three color tokens from markup: ${html}`);
}
return { border, text, bg };
}

function render(confidence: number, overrides: Partial<React.ComponentProps<typeof ConfidenceBandBadge>> = {}) {
return renderToStaticMarkup(
React.createElement(ConfidenceBandBadge, { confidence, ...overrides }),
);
}

describe('ConfidenceBandBadge — per-band color tones', () => {
it('renders the Solid scaffold band with success/green tokens', () => {
const tokens = extractColorTokens(render(BAND_CONFIDENCE.solid));
expect(tokens.border).toMatch(/^border-success\//);
expect(tokens.text).toBe('text-success');
expect(tokens.bg).toMatch(/^bg-success\//);
});

it('renders the Workable draft band with accent/orange tokens', () => {
const tokens = extractColorTokens(render(BAND_CONFIDENCE.workable));
expect(tokens.border).toMatch(/^border-accent\//);
expect(tokens.text).toBe('text-accent');
expect(tokens.bg).toMatch(/^bg-accent\//);
});

it('renders the Rough sketch band with warning/amber tokens', () => {
const tokens = extractColorTokens(render(BAND_CONFIDENCE.rough));
expect(tokens.border).toMatch(/^border-warning\//);
expect(tokens.text).toBe('text-warning');
expect(tokens.bg).toMatch(/^bg-warning\//);
});

it('renders the Unreliable band with error/red tokens', () => {
const tokens = extractColorTokens(render(BAND_CONFIDENCE.unreliable));
expect(tokens.border).toMatch(/^border-error\//);
expect(tokens.text).toBe('text-error');
expect(tokens.bg).toMatch(/^bg-error\//);
});
});

describe('ConfidenceBandBadge — bands are pairwise distinct', () => {
// The whole point of this redesign: producers must be able to tell the
// four bands apart visually. Extract each band's text-color token and
// assert no two bands share it. This survives future opacity tweaks.
it('no two bands share the same text-color token', () => {
const textTokens = (Object.keys(BAND_CONFIDENCE) as Array<keyof typeof BAND_CONFIDENCE>).map(
(band) => extractColorTokens(render(BAND_CONFIDENCE[band])).text,
);
const unique = new Set(textTokens);
expect(unique.size).toBe(4);
});

it('Solid and Unreliable use opposite ends of the severity ladder', () => {
const solid = extractColorTokens(render(BAND_CONFIDENCE.solid));
const unreliable = extractColorTokens(render(BAND_CONFIDENCE.unreliable));
expect(solid.text).toBe('text-success');
expect(unreliable.text).toBe('text-error');
});

it('Workable and Rough sit between Solid and Unreliable', () => {
const workable = extractColorTokens(render(BAND_CONFIDENCE.workable));
const rough = extractColorTokens(render(BAND_CONFIDENCE.rough));
expect(workable.text).toBe('text-accent');
expect(rough.text).toBe('text-warning');
});
});

describe('ConfidenceBandBadge — override path used by full-mix-fallback / legacy', () => {
// NoteDraftBlock passes `overrideTone="rough"` (with an overrideLabel like
// "Full-mix fallback" or "Legacy run") so the band visually reads as
// "amber caution" regardless of the underlying averageConfidence number.
it('overrideTone="rough" produces warning/amber tokens even when confidence is solid-range', () => {
const html = render(0.95, {
overrideTone: 'rough',
overrideLabel: 'Full-mix fallback',
overrideCopy: 'Pitch tracked across the whole mix; treat as approximate.',
});
const tokens = extractColorTokens(html);
expect(tokens.border).toMatch(/^border-warning\//);
expect(tokens.text).toBe('text-warning');
expect(tokens.bg).toMatch(/^bg-warning\//);
// And the pill text is the override, not "Solid scaffold · 95%".
expect(html).toContain('Full-mix fallback');
expect(html).not.toContain('Solid scaffold');
});

it('overrideTone="rough" with low-confidence input still uses warning tokens (not red)', () => {
const html = render(0.05, {
overrideTone: 'rough',
overrideLabel: 'Legacy run',
overrideCopy: 'Re-analyze for current stem-aware quality.',
});
const tokens = extractColorTokens(html);
expect(tokens.text).toBe('text-warning');
expect(html).toContain('Legacy run');
expect(html).not.toContain('Unreliable');
});

it('overrideCopy replaces the band copy without touching the pill class', () => {
const html = render(BAND_CONFIDENCE.solid, {
overrideCopy: 'Custom guidance for the producer.',
});
expect(html).toContain('Custom guidance for the producer.');
// Default band copy must NOT appear when overrideCopy is set.
expect(html).not.toContain("Notes look reliable. Expect light cleanup in Ableton's piano roll.");
});
});
Loading