diff --git a/frontend/src/components/DisputeVoting/DisputeVotingPanel.css b/frontend/src/components/DisputeVoting/DisputeVotingPanel.css index 96b0ecd..7dfd0db 100644 --- a/frontend/src/components/DisputeVoting/DisputeVotingPanel.css +++ b/frontend/src/components/DisputeVoting/DisputeVotingPanel.css @@ -117,6 +117,29 @@ gap: 10px; } +/* Finalized state — replaces vote buttons when an outcome is set */ +.dispute-card--finalized { + opacity: 0.85; +} + +.dispute-card__finalized-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: var(--radius); + background: var(--color-muted-bg, #f3f4f6); + color: var(--color-text-muted, #6b7280); + font-size: 0.875rem; + font-weight: 500; +} + +.dispute-card__finalized-icon { + font-size: 1rem; + color: var(--color-success, #16a34a); + font-weight: 700; +} + .dispute-card__voted { font-size: 0.85rem; color: var(--color-text-muted); diff --git a/frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx b/frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx new file mode 100644 index 0000000..1d3dfe3 --- /dev/null +++ b/frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import DisputeVotingPanel from './DisputeVotingPanel' +import * as useDisputesModule from '../../hooks/useDisputes' +import type { Dispute } from '../../types/dispute' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const BASE_DISPUTE: Dispute = { + settlement_id: 42, + claimant_weight: 3, + counterparty_weight: 2, + resolution_weight: 5, + threshold: 5, + outcome: null, + votes: [], +} + +function mockUseDisputes(disputes: Dispute[]) { + vi.spyOn(useDisputesModule, 'useDisputes').mockReturnValue({ + disputes, + loading: false, + error: null, + voteDispute: vi.fn(), + refresh: vi.fn(), + lastUpdated: new Date('2025-01-01T12:00:00Z'), + weightChanged: false, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DisputeVotingPanel — finalized/disabled state (issue #230)', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('renders vote buttons when a dispute is open (outcome is null)', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: null }]) + render() + + expect(screen.getByRole('button', { name: /Vote in favor of claimant/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Vote in favor of counterparty/i })).toBeInTheDocument() + }) + + it('does NOT render vote buttons when a dispute is finalized', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }]) + render() + + expect(screen.queryByRole('button', { name: /Vote Claimant/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Vote Counterparty/i })).not.toBeInTheDocument() + }) + + it('shows "Outcome finalized" notice when dispute is resolved', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }]) + render() + + expect(screen.getByText(/Outcome finalized/i)).toBeInTheDocument() + }) + + it('shows the outcome badge with the winner label when finalized (claimant)', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }]) + render() + + expect(screen.getByText(/Resolved: Claimant/i)).toBeInTheDocument() + }) + + it('shows the outcome badge with the winner label when finalized (counterparty)', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedCounterparty' }]) + render() + + expect(screen.getByText(/Resolved: Counterparty/i)).toBeInTheDocument() + }) + + it('finalized notice has role=status for screen reader accessibility', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedCounterparty' }]) + render() + + expect(screen.getByRole('status', { name: '' })).toBeInTheDocument() + expect(screen.getByRole('status').textContent).toMatch(/Outcome finalized/i) + }) + + it('dispute card gets dispute-card--finalized class when outcome is set', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedClaimant' }]) + const { container } = render() + + const finalizedCard = container.querySelector('.dispute-card--finalized') + expect(finalizedCard).toBeInTheDocument() + }) + + it('open disputes do NOT get the dispute-card--finalized class', () => { + mockUseDisputes([{ ...BASE_DISPUTE, outcome: null }]) + const { container } = render() + + expect(container.querySelector('.dispute-card--finalized')).not.toBeInTheDocument() + }) + + it('a mix of open and finalized disputes renders correctly', () => { + mockUseDisputes([ + { ...BASE_DISPUTE, settlement_id: 1, outcome: null }, + { ...BASE_DISPUTE, settlement_id: 2, outcome: 'ResolvedCounterparty' }, + ]) + render() + + // Open dispute has vote buttons (aria-label references the settlement id) + expect(screen.getAllByRole('button', { name: /Vote in favor of claimant/i })).toHaveLength(1) + // Finalized dispute has no vote buttons but shows notice + expect(screen.getAllByText(/Outcome finalized/i)).toHaveLength(1) + }) +}) diff --git a/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx b/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx index 6e8bfca..56a69b4 100644 --- a/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx +++ b/frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx @@ -49,7 +49,7 @@ function DisputeCard({ dispute, onVote }: { dispute: Dispute; onVote: (id: numbe } return ( -
+
Settlement #{dispute.settlement_id} {resolved && } @@ -69,7 +69,15 @@ function DisputeCard({ dispute, onVote }: { dispute: Dispute; onVote: (id: numbe
- {!resolved && ( + {/* When the outcome is finalized, show a clear status notice instead of + vote buttons. The buttons are not rendered at all so that there is no + possibility of submitting a now-meaningless vote transaction. */} + {resolved ? ( +
+ + Outcome finalized — voting is closed for this dispute. +
+ ) : (