diff --git a/frontend/taskdeck-web/src/locales/en/review.ts b/frontend/taskdeck-web/src/locales/en/review.ts index 203c1d65e..e53395351 100644 --- a/frontend/taskdeck-web/src/locales/en/review.ts +++ b/frontend/taskdeck-web/src/locales/en/review.ts @@ -475,8 +475,14 @@ export default { similarPast: { heading: 'Similar past decisions', empty: 'No comparable past decisions.', + // Shown inside the disclosure when there is nothing to list, so opening the + // control explains itself instead of revealing an empty region (#1940). + emptyDetail: 'Decisions on comparable proposals will be listed here.', details: { show: 'Show similar decisions', + // The empty-state label. It keeps the `show` wording and adds the count, + // so the closed control does not promise a list it does not have. + showEmpty: 'Show similar decisions (none found)', hide: 'Hide similar decisions', }, verdict: { diff --git a/frontend/taskdeck-web/src/locales/es/review.ts b/frontend/taskdeck-web/src/locales/es/review.ts index df00fdecc..3115e09fa 100644 --- a/frontend/taskdeck-web/src/locales/es/review.ts +++ b/frontend/taskdeck-web/src/locales/es/review.ts @@ -402,8 +402,10 @@ export default { similarPast: { heading: 'Decisiones parecidas anteriores', empty: 'No hay decisiones anteriores comparables.', + emptyDetail: 'Aquí se mostrarán las decisiones sobre propuestas comparables.', details: { show: 'Mostrar decisiones parecidas', + showEmpty: 'Mostrar decisiones parecidas (ninguna encontrada)', hide: 'Ocultar decisiones parecidas', }, verdict: { diff --git a/frontend/taskdeck-web/src/locales/it/review.ts b/frontend/taskdeck-web/src/locales/it/review.ts index c94f1876b..d61432e10 100644 --- a/frontend/taskdeck-web/src/locales/it/review.ts +++ b/frontend/taskdeck-web/src/locales/it/review.ts @@ -403,8 +403,10 @@ export default { similarPast: { heading: 'Decisioni simili passate', empty: 'Nessuna decisione passata comparabile.', + emptyDetail: 'Qui compariranno le decisioni su proposte comparabili.', details: { show: 'Mostra decisioni simili', + showEmpty: 'Mostra decisioni simili (nessuna trovata)', hide: 'Nascondi decisioni simili', }, verdict: { diff --git a/frontend/taskdeck-web/src/tests/views/paper/review/ReviewAuthorCard.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/review/ReviewAuthorCard.spec.ts new file mode 100644 index 000000000..8498d119f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/paper/review/ReviewAuthorCard.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ReviewAuthorCard from '../../../../views/paper/review/ReviewAuthorCard.vue' +import type { + ConfidenceBreakdown, + ConfidenceValueSource, +} from '../../../../composables/usePaperReviewSelectors' + +/** + * ReviewAuthorCard — the confidence SOURCE sentence must be readable before the + * disclosure opens (#1940, the retained residual of #2166). + * + * Why this one sentence matters more than it looks: on an Applied record the + * primary confidence-source badge in ReviewMain.vue is gated off, and + * PaperReviewView passes an empty `authorMeta` for the deterministic and + * not-reported sources. The sentence inside this collapsed region was then the + * only statement on the whole screen about where the confidence number came + * from — and it was invisible until the reviewer opened a control that gave no + * reason to be opened. + * + * The disclosure itself stays in every state (the view specs and the required + * E2E smoke drive it), so the fix hoists the sentence rather than moving the + * control. + */ + +function breakdownOf( + source: ConfidenceValueSource, + components: Array<{ key: string; value: number }> = [], + note?: string, +): ConfidenceBreakdown { + return { overall: components.length > 0 ? 0.9 : null, components, threshold: null, source, note } +} + +function mountCard(breakdown: ConfidenceBreakdown, authorMeta = '') { + return mount(ReviewAuthorCard, { + attachTo: document.body, + props: { + authorName: 'Taskdeck', + authorMeta, + proposedDate: '2026-08-22', + proposedTime: '18:00', + proposedNum: '001', + breakdown, + }, + }) +} + +const sourceLine = '[data-testid="paper-review-author-confidence-source"]' + +describe('ReviewAuthorCard', () => { + describe('confidence source, stated before the disclosure opens', () => { + // The two sources the backend actually emits with an empty components + // array. Their wording is unchanged by #1940 — only its position is. + const CASES: Array<[string, ConfidenceValueSource, string]> = [ + ['deterministic extraction', 'deterministic', 'Deterministic extraction · no model confidence'], + ['a model that reported nothing', 'not-reported', 'No model confidence reported'], + ['a derived average', 'derived', 'No model confidence reported'], + ] + + it.each(CASES)('names %s at first paint, outside the collapsed region', (_label, source, copy) => { + const wrapper = mountCard(breakdownOf(source)) + const line = wrapper.get(sourceLine) + const details = wrapper.get('[data-testid="paper-review-confidence-details"]') + + expect(line.text()).toBe(copy) + expect(line.isVisible()).toBe(true) + expect(details.find(sourceLine).exists()).toBe(false) + expect(details.isVisible()).toBe(false) + + wrapper.unmount() + }) + + it('says it exactly once, before and after the region opens', async () => { + const wrapper = mountCard(breakdownOf('deterministic')) + const copy = 'Deterministic extraction · no model confidence' + + expect(wrapper.text().split(copy).length - 1).toBe(1) + + await wrapper.get('[data-testid="paper-review-confidence-disclosure"]').trigger('click') + + expect(wrapper.text().split(copy).length - 1).toBe(1) + expect(wrapper.get('[data-testid="paper-review-confidence-details"]').text()).not.toContain(copy) + + wrapper.unmount() + }) + + it('drops the sentence entirely once there are per-component bars to read', async () => { + const wrapper = mountCard( + breakdownOf('model-reported', [ + { key: 'Operation 1: create card', value: 0.92 }, + { key: 'Operation 2: update card', value: 0.4 }, + ]), + '0.90 model-reported average', + ) + const details = wrapper.get('[data-testid="paper-review-confidence-details"]') + + expect(wrapper.find(sourceLine).exists()).toBe(false) + expect(details.isVisible()).toBe(false) + + await wrapper.get('[data-testid="paper-review-confidence-disclosure"]').trigger('click') + + expect(details.isVisible()).toBe(true) + expect(details.get('.paper-review-author__bd-heading').text()).toBe( + 'Model-reported item confidence', + ) + expect(details.findAll('.paper-review-author__bar-key').map((n) => n.text())).toEqual([ + 'Operation 1: create card', + 'Operation 2: update card', + ]) + + wrapper.unmount() + }) + + // #1940: `model-reported` with an empty components array made the heading + // announce "Model-reported item confidence" directly above a body saying + // no model confidence was reported. The backend never emits that pair — + // PaperReviewView.spec.ts builds it — but the heading is derived from what + // is actually on screen now, so the contradiction cannot be constructed. + it('derives the heading from the bars it has, not from the claimed source', async () => { + const wrapper = mountCard(breakdownOf('model-reported')) + const details = wrapper.get('[data-testid="paper-review-confidence-details"]') + + expect(wrapper.get(sourceLine).text()).toBe('No model confidence reported') + + await wrapper.get('[data-testid="paper-review-confidence-disclosure"]').trigger('click') + + const heading = details.get('.paper-review-author__bd-heading').text() + expect(heading).toBe('Confidence source') + expect(heading).not.toBe('Model-reported item confidence') + expect(details.findAll('.paper-review-author__bar')).toHaveLength(0) + + wrapper.unmount() + }) + + it('keeps the model note behind the disclosure', async () => { + const wrapper = mountCard( + breakdownOf('model-reported', [{ key: 'Operation 1: create card', value: 0.96 }], 'Reported by the model for the proposed operation.'), + ) + const details = wrapper.get('[data-testid="paper-review-confidence-details"]') + + expect(details.isVisible()).toBe(false) + + await wrapper.get('[data-testid="paper-review-confidence-disclosure"]').trigger('click') + + expect(details.text()).toContain('Reported by the model for the proposed operation.') + + wrapper.unmount() + }) + }) + + it('keeps the disclosure present and correctly paired while collapsed', () => { + const wrapper = mountCard(breakdownOf('deterministic')) + const button = wrapper.get('[data-testid="paper-review-confidence-disclosure"]') + const details = wrapper.get('[data-testid="paper-review-confidence-details"]') + + expect(button.element.tagName).toBe('BUTTON') + expect(button.attributes('type')).toBe('button') + expect(button.attributes('aria-expanded')).toBe('false') + expect(button.attributes('aria-controls')).toBe(details.attributes('id')) + expect(details.attributes('aria-labelledby')).toBe(button.attributes('id')) + expect(details.attributes('role')).toBe('region') + + wrapper.unmount() + }) + + // Matches ReviewProvenance.vue: `v-show` alone leaves the collapsed region in + // the accessibility tree for anything that reads the DOM rather than the + // computed style. + it('binds `hidden` to the collapsed state, like the provenance card', async () => { + const wrapper = mountCard(breakdownOf('model-reported', [{ key: 'Operation 1', value: 0.9 }])) + const button = wrapper.get('[data-testid="paper-review-confidence-disclosure"]') + const details = wrapper.get('[data-testid="paper-review-confidence-details"]') + + expect(details.attributes('hidden')).toBeDefined() + + await button.trigger('click') + expect(details.attributes('hidden')).toBeUndefined() + + await button.trigger('click') + expect(details.attributes('hidden')).toBeDefined() + + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/paper/review/ReviewSimilarPast.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/review/ReviewSimilarPast.spec.ts new file mode 100644 index 000000000..12d0a36e0 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/paper/review/ReviewSimilarPast.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ReviewSimilarPast from '../../../../views/paper/review/ReviewSimilarPast.vue' +import type { SimilarPastRow } from '../../../../composables/usePaperReviewSelectors' + +/** + * ReviewSimilarPast — the card must tell the truth BEFORE the disclosure opens + * (#1940, the retained residual of #2166). + * + * The card shipped with the whole empty state locked inside a collapsed + * region: a reviewer looking at a proposal with no comparable history saw only + * "Show similar decisions" and had to open it to learn there was nothing to + * show. The fix keeps the disclosure in every state — PaperReviewView.spec.ts, + * PaperReviewView.language.spec.ts and the required E2E smoke all drive that + * button on an empty fixture — and hoists the fact above it. + * + * The empty sentence lives in exactly one place. Duplicating it above and + * inside the region would make the open state read as two separate findings. + */ + +const ROWS: SimilarPastRow[] = [ + { serial: '#PAST-1', title: 'A prior comparable decision', verdict: 'applied', date: '2026-08-20' }, + { serial: '#PAST-2', title: 'A prior rejected decision', verdict: 'rejected', date: '2026-08-19' }, +] + +const EMPTY_SENTENCE = 'No comparable past decisions.' + +function mountCard(rows: SimilarPastRow[]) { + return mount(ReviewSimilarPast, { + attachTo: document.body, + props: { + rows, + applyRate: + rows.length === 0 + ? { applied: 0, total: 0, ratio: 0 } + : { applied: 1, total: 2, ratio: 0.5 }, + }, + }) +} + +/** How many times a sentence appears in the rendered text of the whole card. */ +function occurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1 +} + +describe('ReviewSimilarPast', () => { + describe('with no comparable decisions', () => { + it('states the emptiness at first paint, above the still-collapsed disclosure', () => { + const wrapper = mountCard([]) + const empty = wrapper.get('[data-testid="paper-review-similar-past-empty"]') + const details = wrapper.get('[data-testid="paper-review-similar-past-details"]') + + expect(empty.text()).toBe(EMPTY_SENTENCE) + expect(empty.isVisible()).toBe(true) + // The hoisted line is a sibling of the region, not a child of it: a + // child would still be invisible while the region is collapsed. + expect(details.find('[data-testid="paper-review-similar-past-empty"]').exists()).toBe(false) + expect(details.isVisible()).toBe(false) + + wrapper.unmount() + }) + + it('says so on the disclosure label too, so the closed control is not a promise', () => { + const wrapper = mountCard([]) + const button = wrapper.get('[data-testid="paper-review-similar-past-disclosure"]') + + expect(button.text()).toContain('Show similar decisions') + expect(button.text()).toContain('none found') + + wrapper.unmount() + }) + + it('keeps the disclosure present and correctly paired while collapsed', () => { + const wrapper = mountCard([]) + const button = wrapper.get('[data-testid="paper-review-similar-past-disclosure"]') + const details = wrapper.get('[data-testid="paper-review-similar-past-details"]') + + expect(button.element.tagName).toBe('BUTTON') + expect(button.attributes('type')).toBe('button') + expect(button.attributes('aria-expanded')).toBe('false') + expect(button.attributes('aria-controls')).toBe(details.attributes('id')) + expect(details.attributes('aria-labelledby')).toBe(button.attributes('id')) + expect(details.attributes('role')).toBe('region') + + wrapper.unmount() + }) + + it('never says the same thing twice', async () => { + const wrapper = mountCard([]) + const details = wrapper.get('[data-testid="paper-review-similar-past-details"]') + + expect(occurrences(wrapper.text(), EMPTY_SENTENCE)).toBe(1) + expect(details.text()).not.toContain(EMPTY_SENTENCE) + + await wrapper.get('[data-testid="paper-review-similar-past-disclosure"]').trigger('click') + + expect(occurrences(wrapper.text(), EMPTY_SENTENCE)).toBe(1) + expect(details.text()).not.toContain(EMPTY_SENTENCE) + + wrapper.unmount() + }) + + it('opens onto an explanation rather than onto nothing', async () => { + const wrapper = mountCard([]) + const button = wrapper.get('[data-testid="paper-review-similar-past-disclosure"]') + const details = wrapper.get('[data-testid="paper-review-similar-past-details"]') + + await button.trigger('click') + + expect(button.attributes('aria-expanded')).toBe('true') + expect(details.isVisible()).toBe(true) + // A region that opens to a zero-height void reads as a broken control, + // and the required E2E asserts this region is *visible* once opened. + expect(details.get('[data-testid="paper-review-similar-past-empty-detail"]').text()).toBe( + 'Decisions on comparable proposals will be listed here.', + ) + expect(wrapper.find('.paper-review-past__rate').exists()).toBe(false) + + wrapper.unmount() + }) + }) + + describe('with comparable decisions', () => { + it('keeps the rows and the apply-rate footer behind the disclosure', async () => { + const wrapper = mountCard(ROWS) + const button = wrapper.get('[data-testid="paper-review-similar-past-disclosure"]') + const details = wrapper.get('[data-testid="paper-review-similar-past-details"]') + const rate = wrapper.get('.paper-review-past__rate') + + expect(wrapper.find('[data-testid="paper-review-similar-past-empty"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain(EMPTY_SENTENCE) + expect(button.text()).toContain('Show similar decisions') + expect(button.text()).not.toContain('none found') + expect(details.isVisible()).toBe(false) + expect(rate.isVisible()).toBe(false) + + await button.trigger('click') + + expect(details.isVisible()).toBe(true) + expect(details.text()).toContain('A prior comparable decision') + expect(details.text()).toContain('A prior rejected decision') + expect(rate.isVisible()).toBe(true) + expect(rate.text()).toContain('1 of 2 (50%)') + expect( + wrapper.find('[data-testid="paper-review-similar-past-empty-detail"]').exists(), + ).toBe(false) + + wrapper.unmount() + }) + }) + + // Matches ReviewProvenance.vue: `v-show` alone leaves the collapsed region in + // the accessibility tree for anything that reads the DOM rather than the + // computed style. + it('binds `hidden` to the collapsed state, like the provenance card', async () => { + const wrapper = mountCard(ROWS) + const button = wrapper.get('[data-testid="paper-review-similar-past-disclosure"]') + const details = wrapper.get('[data-testid="paper-review-similar-past-details"]') + + expect(details.attributes('hidden')).toBeDefined() + + await button.trigger('click') + expect(details.attributes('hidden')).toBeUndefined() + + await button.trigger('click') + expect(details.attributes('hidden')).toBeDefined() + + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/views/paper/review/ReviewAuthorCard.vue b/frontend/taskdeck-web/src/views/paper/review/ReviewAuthorCard.vue index b33fefb12..6da2a3c53 100644 --- a/frontend/taskdeck-web/src/views/paper/review/ReviewAuthorCard.vue +++ b/frontend/taskdeck-web/src/views/paper/review/ReviewAuthorCard.vue @@ -1,5 +1,5 @@