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
23 changes: 23 additions & 0 deletions frontend/src/components/DisputeVoting/DisputeVotingPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/components/DisputeVoting/DisputeVotingPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

expect(screen.getByText(/Resolved: Counterparty/i)).toBeInTheDocument()
})

it('finalized notice has role=status for screen reader accessibility', () => {
mockUseDisputes([{ ...BASE_DISPUTE, outcome: 'ResolvedCounterparty' }])
render(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

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(<DisputeVotingPanel />)

// 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)
})
})
12 changes: 10 additions & 2 deletions frontend/src/components/DisputeVoting/DisputeVotingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function DisputeCard({ dispute, onVote }: { dispute: Dispute; onVote: (id: numbe
}

return (
<div className="dispute-card">
<div className={`dispute-card${resolved ? ' dispute-card--finalized' : ''}`}>
<div className="dispute-card__header">
<span className="dispute-card__id">Settlement #{dispute.settlement_id}</span>
{resolved && <OutcomeBadge outcome={dispute.outcome!} />}
Expand All @@ -69,7 +69,15 @@ function DisputeCard({ dispute, onVote }: { dispute: Dispute; onVote: (id: numbe
</div>
</div>

{!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 ? (
<div className="dispute-card__finalized-notice" role="status" aria-live="polite">
<span className="dispute-card__finalized-icon" aria-hidden="true">✓</span>
Outcome finalized — voting is closed for this dispute.
</div>
) : (
<div className="dispute-card__actions" role="group" aria-label={`Vote on settlement #${dispute.settlement_id}`}>
<button
className="vote-btn vote-btn--claimant"
Expand Down
Loading