diff --git a/src/studio/__tests__/adapters/validitySuggestions.test.ts b/src/studio/__tests__/adapters/validitySuggestions.test.ts index 23fa8c4c..d72b8902 100644 --- a/src/studio/__tests__/adapters/validitySuggestions.test.ts +++ b/src/studio/__tests__/adapters/validitySuggestions.test.ts @@ -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', [ diff --git a/src/studio/__tests__/tabs/ValidityTab.test.tsx b/src/studio/__tests__/tabs/ValidityTab.test.tsx index 1b8c5d33..e13b1b57 100644 --- a/src/studio/__tests__/tabs/ValidityTab.test.tsx +++ b/src/studio/__tests__/tabs/ValidityTab.test.tsx @@ -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(); + + 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', diff --git a/src/studio/adapters/validitySuggestions.ts b/src/studio/adapters/validitySuggestions.ts index 18466712..5ae08c40 100644 --- a/src/studio/adapters/validitySuggestions.ts +++ b/src/studio/adapters/validitySuggestions.ts @@ -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'; @@ -50,6 +51,7 @@ export function buildValiditySuggestions(input: { targetLabel: null, targetId: null, targetIds: [], + diagnosticCount: 1, code: entry.code, promptText: backendPrompt ?? fallbackPrompt(entry.code, evidence, action), promptSource, @@ -57,7 +59,8 @@ export function buildValiditySuggestions(input: { }; }) ?? []; 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); @@ -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, @@ -83,6 +87,52 @@ export function buildValiditySuggestions(input: { return [...mechanismCards, ...diagnosticCards].slice(0, limit); } +function groupDiagnostics( + diagnostics: ReadonlyArray, +): Array<{ diagnostics: ValidatorDiagnostic[]; severity: ValidatorDiagnostic['severity'] }> { + const groups = new Map(); + 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['severity'] { + return diagnostics.reduce( + (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; diff --git a/src/studio/tabs/ValidityTab.tsx b/src/studio/tabs/ValidityTab.tsx index b5c8c0d1..f38b8a69 100644 --- a/src/studio/tabs/ValidityTab.tsx +++ b/src/studio/tabs/ValidityTab.tsx @@ -181,6 +181,14 @@ function SuggestionCard({ /> {card.title} {card.code} + {card.diagnosticCount > 1 && ( + + {card.diagnosticCount} findings + + )} {card.targetLabel != null && ( {card.targetLabel}