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
99 changes: 99 additions & 0 deletions src/studio/__tests__/adapters/validitySuggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,105 @@ describe('buildValiditySuggestions', () => {
]);
});

it('groups duplicate diagnostics with the same code and target into one suggestion', () => {
const suggestions = buildValiditySuggestions({
validity: makeValidity('error', [
{
code: 'assembly.part.floating',
severity: 'warning',
message: 'link floats',
hint: 'anchor link',
partName: 'link',
},
{
code: 'assembly.part.floating',
severity: 'error',
message: 'link still has no grounded mate',
hint: 'add a grounded mate to link',
partName: 'link',
},
]),
mechanismBanner: null,
});

expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
id: 'diagnostic:assembly.part.floating:link:0',
severity: 'error',
targetLabel: 'link',
targetId: 'link',
diagnosticCount: 2,
evidence: 'link floats',
action: 'anchor link',
});
});

it('keeps diagnostics with the same code but different targets as separate suggestions', () => {
const suggestions = buildValiditySuggestions({
validity: makeValidity('error', [
{
code: 'assembly.part.floating',
severity: 'error',
message: 'link floats',
hint: 'anchor link',
partName: 'link',
},
{
code: 'assembly.part.floating',
severity: 'error',
message: 'cover floats',
hint: 'anchor cover',
partName: 'cover',
},
]),
mechanismBanner: null,
});

expect(suggestions.map((suggestion) => suggestion.targetId)).toEqual(['link', 'cover']);
expect(suggestions.map((suggestion) => suggestion.diagnosticCount)).toEqual([1, 1]);
});

it('applies the visible card limit after grouping duplicate diagnostics', () => {
const suggestions = buildValiditySuggestions({
validity: makeValidity('error', [
{
code: 'assembly.part.floating',
severity: 'error',
message: 'link floats',
hint: 'anchor link',
partName: 'link',
},
{
code: 'assembly.part.floating',
severity: 'error',
message: 'link still floats',
hint: 'anchor link again',
partName: 'link',
},
{
code: 'assembly.part.floating',
severity: 'error',
message: 'cover floats',
hint: 'anchor cover',
partName: 'cover',
},
{
code: 'assembly.loop.unclosed',
severity: 'warning',
message: 'loop is open',
hint: 'close loop',
},
]),
mechanismBanner: null,
limit: 2,
});

expect(suggestions.map((suggestion) => suggestion.id)).toEqual([
'diagnostic:assembly.part.floating:link:0',
'diagnostic:assembly.part.floating:cover:1',
]);
});

it('maps target precedence for partName, mateName, partA, and no target', () => {
const suggestions = buildValiditySuggestions({
validity: makeValidity('error', [
Expand Down
27 changes: 27 additions & 0 deletions src/studio/__tests__/tabs/ValidityTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,33 @@ describe('ValidityTab', () => {
expect(card.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

it('groups duplicate diagnostic suggestions while keeping every raw diagnostic row', () => {
const first: ValidatorDiagnostic = {
code: 'assembly.part.floating',
severity: 'error',
message: 'output-horn floats',
hint: 'add a mate to output-horn',
partName: 'output-horn',
};
const duplicate: ValidatorDiagnostic = {
code: 'assembly.part.floating',
severity: 'warning',
message: 'output-horn still has no grounded mate',
hint: 'ground output-horn',
partName: 'output-horn',
};

mockUseRecomputeResult.mockReturnValue(
withValidity(makeValidity('error', [first, duplicate], 1, 0)),
);

render(<ValidityTab />);

expect(screen.getAllByTestId('validity-suggestion-card')).toHaveLength(1);
expect(screen.getByTestId('validity-suggestion-count').textContent).toBe('2 findings');
expect(screen.getAllByTestId('diagnostic-row')).toHaveLength(2);
});

it('clicking a suggestion card Jump button calls selectFeature with the target id', () => {
const diag: ValidatorDiagnostic = {
code: 'assembly.part.floating',
Expand Down
54 changes: 52 additions & 2 deletions src/studio/adapters/validitySuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ValiditySuggestionCard {
readonly targetLabel: string | null;
readonly targetId: string | null;
readonly targetIds: readonly string[];
readonly diagnosticCount: number;
readonly code: string;
readonly promptText: string;
readonly promptSource: 'review' | 'fallback';
Expand Down Expand Up @@ -50,14 +51,16 @@ export function buildValiditySuggestions(input: {
targetLabel: null,
targetId: null,
targetIds: [],
diagnosticCount: 1,
code: entry.code,
promptText: backendPrompt ?? fallbackPrompt(entry.code, evidence, action),
promptSource,
repairEvidence: repairEvidenceForCardCode(repairEvidence, entry.code),
};
}) ?? [];
const diagnosticCards =
input.validity?.diagnostics.map((diag, index): ValiditySuggestionCard => {
groupDiagnostics(input.validity?.diagnostics ?? []).map((group, index): ValiditySuggestionCard => {
const diag = group.diagnostics[0]!;
const targetLabel = diagnosticTargetLabel(diag);
const targetId = diagnosticTargetId(diag);
const targetIds = diagnosticTargetIds(diag);
Expand All @@ -66,13 +69,14 @@ export function buildValiditySuggestions(input: {
return {
id: `diagnostic:${diag.code}:${targetId ?? 'global'}:${index}`,
kind: 'diagnostic',
severity: diag.severity,
severity: group.severity,
title: targetLabel != null ? `Fix ${targetLabel}` : `Resolve ${diag.code}`,
evidence,
action,
targetLabel,
targetId,
targetIds,
diagnosticCount: group.diagnostics.length,
code: diag.code,
promptText: backendPrompt ?? fallbackPrompt(diag.code, evidence, action),
promptSource,
Expand All @@ -83,6 +87,52 @@ export function buildValiditySuggestions(input: {
return [...mechanismCards, ...diagnosticCards].slice(0, limit);
}

function groupDiagnostics(
diagnostics: ReadonlyArray<ValidatorDiagnostic>,
): Array<{ diagnostics: ValidatorDiagnostic[]; severity: ValidatorDiagnostic['severity'] }> {
const groups = new Map<string, ValidatorDiagnostic[]>();
for (const diagnostic of diagnostics) {
const key = diagnosticGroupKey(diagnostic);
const group = groups.get(key);
if (group == null) {
groups.set(key, [diagnostic]);
} else {
group.push(diagnostic);
}
}
return [...groups.values()].map((group) => ({
diagnostics: group,
severity: highestSeverity(group),
}));
}

function diagnosticGroupKey(d: ValidatorDiagnostic): string {
const targetId = diagnosticTargetId(d) ?? 'global';
const targetIds = diagnosticTargetIds(d).join('|');
return `${d.code}:${targetId}:${targetIds}`;
}

function highestSeverity(diagnostics: ReadonlyArray<ValidatorDiagnostic>): ValidatorDiagnostic['severity'] {
return diagnostics.reduce<ValidatorDiagnostic['severity']>(
(highest, diagnostic) =>
severityRank(diagnostic.severity) > severityRank(highest)
? diagnostic.severity
: highest,
'info',
);
}

function severityRank(severity: ValidatorDiagnostic['severity']): number {
switch (severity) {
case 'error':
return 3;
case 'warning':
return 2;
case 'info':
return 1;
}
}

function diagnosticTargetLabel(d: ValidatorDiagnostic): string | null {
if (d.partName) return d.partName;
if (d.mateName) return d.mateName;
Expand Down
8 changes: 8 additions & 0 deletions src/studio/tabs/ValidityTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ function SuggestionCard({
/>
<span className="font-medium text-gray-100">{card.title}</span>
<span className="font-mono text-[10px] text-gray-500">{card.code}</span>
{card.diagnosticCount > 1 && (
<span
className="rounded border border-[#343434] bg-[#1b1b1b] px-1.5 py-0.5 text-[10px] text-gray-300"
data-testid="validity-suggestion-count"
>
{card.diagnosticCount} findings
</span>
)}
{card.targetLabel != null && (
<span className="ml-auto max-w-[8rem] truncate text-[11px] text-gray-300" title={card.targetLabel}>
{card.targetLabel}
Expand Down
Loading