Skip to content

Commit 3324feb

Browse files
feat(governance): implement governance voting dashboard with real-time proposal tracking (#174) (#200)
Co-authored-by: JamesEjembi <jameseonoja@gmail.com>
1 parent 1b214d0 commit 3324feb

12 files changed

Lines changed: 2601 additions & 1373 deletions

File tree

src/__tests__/governance.test.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import {
3+
useGovernanceStore,
4+
calculateEffectiveWeight,
5+
calculateQuorumProgress,
6+
formatVoteHistoryCsv,
7+
MINIMUM_PROPOSAL_DEPOSIT,
8+
} from '@/src/store/governanceStore'
9+
import type { Proposal, CreateProposalInput, VoteRecord } from '@/src/types/governance'
10+
11+
describe('Governance Store & Pure Calculations', () => {
12+
beforeEach(() => {
13+
useGovernanceStore.getState().resetStore()
14+
})
15+
16+
describe('calculateEffectiveWeight', () => {
17+
it('calculates linear weight for token-weighted voting', () => {
18+
expect(calculateEffectiveWeight(1000, 'token-weighted')).toBe(1000)
19+
expect(calculateEffectiveWeight(25000, 'token-weighted')).toBe(25000)
20+
expect(calculateEffectiveWeight(0, 'token-weighted')).toBe(0)
21+
expect(calculateEffectiveWeight(-50, 'token-weighted')).toBe(0)
22+
})
23+
24+
it('calculates square root weight for quadratic voting', () => {
25+
expect(calculateEffectiveWeight(10000, 'quadratic')).toBe(100)
26+
expect(calculateEffectiveWeight(25, 'quadratic')).toBe(5)
27+
expect(calculateEffectiveWeight(100, 'quadratic')).toBe(10)
28+
expect(calculateEffectiveWeight(2, 'quadratic')).toBe(1.41)
29+
expect(calculateEffectiveWeight(0, 'quadratic')).toBe(0)
30+
expect(calculateEffectiveWeight(-10, 'quadratic')).toBe(0)
31+
})
32+
})
33+
34+
describe('calculateQuorumProgress', () => {
35+
it('calculates quorum and vote percentages accurately', () => {
36+
const mockProposal: Proposal = {
37+
id: 'TEST-1',
38+
title: 'Test',
39+
description: 'Test desc',
40+
proposer: 'GBV...',
41+
status: 'active',
42+
forVotes: 600,
43+
againstVotes: 300,
44+
abstainVotes: 100,
45+
quorum: 1000,
46+
startBlock: 100,
47+
endBlock: 200,
48+
category: 'general',
49+
votingType: 'token-weighted',
50+
deposit: 100,
51+
createdAt: 1000,
52+
}
53+
54+
const progress = calculateQuorumProgress(mockProposal)
55+
expect(progress.currentVotes).toBe(1000)
56+
expect(progress.quorum).toBe(1000)
57+
expect(progress.quorumReached).toBe(true)
58+
expect(progress.percentage).toBe(100)
59+
expect(progress.forPercentage).toBe(60)
60+
expect(progress.againstPercentage).toBe(30)
61+
expect(progress.abstainPercentage).toBe(10)
62+
})
63+
64+
it('handles zero votes correctly without division by zero', () => {
65+
const emptyProposal: Proposal = {
66+
id: 'TEST-2',
67+
title: 'Empty',
68+
description: 'Empty',
69+
proposer: 'GBV...',
70+
status: 'active',
71+
forVotes: 0,
72+
againstVotes: 0,
73+
abstainVotes: 0,
74+
quorum: 50000,
75+
startBlock: 100,
76+
endBlock: 200,
77+
category: 'treasury',
78+
votingType: 'token-weighted',
79+
deposit: 100,
80+
createdAt: 1000,
81+
}
82+
83+
const progress = calculateQuorumProgress(emptyProposal)
84+
expect(progress.currentVotes).toBe(0)
85+
expect(progress.quorumReached).toBe(false)
86+
expect(progress.percentage).toBe(0)
87+
expect(progress.forPercentage).toBe(0)
88+
expect(progress.againstPercentage).toBe(0)
89+
expect(progress.abstainPercentage).toBe(0)
90+
})
91+
})
92+
93+
describe('formatVoteHistoryCsv', () => {
94+
it('formats vote records into valid CSV with headers and quoted fields', () => {
95+
const sampleRecords: VoteRecord[] = [
96+
{
97+
id: 'v1',
98+
proposalId: 'PROP-001',
99+
proposalTitle: 'VIP-001: First, Proposal',
100+
voter: 'GUSER123',
101+
choice: 'for',
102+
votingPower: 5000,
103+
effectiveWeight: 5000,
104+
votingType: 'token-weighted',
105+
txHash: '0xabc123',
106+
gasCost: '0.002 XLM',
107+
timestamp: 1787000000000,
108+
},
109+
]
110+
111+
const csv = formatVoteHistoryCsv(sampleRecords)
112+
const lines = csv.split('\n')
113+
expect(lines[0]).toContain('Tx Hash,Proposal ID,Proposal Title')
114+
expect(lines[1]).toContain('"0xabc123"')
115+
expect(lines[1]).toContain('"PROP-001"')
116+
expect(lines[1]).toContain('"VIP-001: First, Proposal"')
117+
expect(lines[1]).toContain('"FOR"')
118+
})
119+
})
120+
121+
describe('Store Actions - Voting', () => {
122+
it('casts a vote on an active proposal and updates state', () => {
123+
const store = useGovernanceStore.getState()
124+
const initialProp = store.proposals.find((p) => p.id === 'PROP-001')!
125+
const initialFor = initialProp.forVotes
126+
const initialHistoryCount = store.voteHistory.length
127+
128+
const res = store.castVote('PROP-001', 'for', 5000)
129+
expect(res.success).toBe(true)
130+
expect(res.txHash).toBeDefined()
131+
132+
const updatedProp = useGovernanceStore.getState().proposals.find((p) => p.id === 'PROP-001')!
133+
expect(updatedProp.forVotes).toBe(initialFor + 5000)
134+
expect(updatedProp.userVote).toBe('for')
135+
136+
const updatedHistory = useGovernanceStore.getState().voteHistory
137+
expect(updatedHistory.length).toBe(initialHistoryCount + 1)
138+
expect(updatedHistory[0].proposalId).toBe('PROP-001')
139+
expect(updatedHistory[0].choice).toBe('for')
140+
expect(updatedHistory[0].votingPower).toBe(5000)
141+
expect(updatedHistory[0].effectiveWeight).toBe(5000)
142+
})
143+
144+
it('applies quadratic weight when voting on quadratic proposal', () => {
145+
const store = useGovernanceStore.getState()
146+
const prop = store.proposals.find((p) => p.id === 'PROP-002')!
147+
expect(prop.votingType).toBe('quadratic')
148+
const initialAgainst = prop.againstVotes
149+
150+
// 10000 tokens in quadratic voting = 100 votes
151+
const res = store.castVote('PROP-002', 'against', 10000)
152+
expect(res.success).toBe(true)
153+
154+
const updatedProp = useGovernanceStore.getState().proposals.find((p) => p.id === 'PROP-002')!
155+
expect(updatedProp.againstVotes).toBe(initialAgainst + 100)
156+
157+
const latestVote = useGovernanceStore.getState().voteHistory[0]
158+
expect(latestVote.votingPower).toBe(10000)
159+
expect(latestVote.effectiveWeight).toBe(100)
160+
})
161+
162+
it('rejects voting on non-active proposals', () => {
163+
const store = useGovernanceStore.getState()
164+
const res = store.castVote('PROP-003', 'for', 1000) // PROP-003 is 'passed'
165+
expect(res.success).toBe(false)
166+
expect(res.error).toContain('Cannot vote on passed proposal')
167+
})
168+
169+
it('rejects voting with invalid or excessive tokens', () => {
170+
const store = useGovernanceStore.getState()
171+
const res1 = store.castVote('PROP-001', 'for', 0)
172+
expect(res1.success).toBe(false)
173+
174+
const res2 = store.castVote('PROP-001', 'for', 99999999)
175+
expect(res2.success).toBe(false)
176+
})
177+
})
178+
179+
describe('Store Actions - Delegation', () => {
180+
it('delegates voting power to a chosen delegate and reduces direct power', () => {
181+
const store = useGovernanceStore.getState()
182+
const delegateAddress = 'GD7BX8M31NP4450KLS9921VZTTTR43100981A'
183+
const initialBalance = store.userTokenBalance
184+
185+
const res = store.delegateVotes(delegateAddress)
186+
expect(res.success).toBe(true)
187+
188+
const state = useGovernanceStore.getState()
189+
expect(state.currentDelegation).toBe(delegateAddress)
190+
expect(state.userVotingPower).toBe(0)
191+
192+
const delegate = state.delegates.find((d) => d.address === delegateAddress)!
193+
expect(delegate.isDelegatedTo).toBe(true)
194+
})
195+
196+
it('revokes active delegation and restores user direct voting power', () => {
197+
const store = useGovernanceStore.getState()
198+
const delegateAddress = 'GD7BX8M31NP4450KLS9921VZTTTR43100981A'
199+
200+
store.delegateVotes(delegateAddress)
201+
expect(useGovernanceStore.getState().userVotingPower).toBe(0)
202+
203+
const res = useGovernanceStore.getState().revokeDelegation()
204+
expect(res.success).toBe(true)
205+
206+
const state = useGovernanceStore.getState()
207+
expect(state.currentDelegation).toBeNull()
208+
expect(state.userVotingPower).toBe(state.userTokenBalance)
209+
210+
const delegate = state.delegates.find((d) => d.address === delegateAddress)!
211+
expect(delegate.isDelegatedTo).toBe(false)
212+
})
213+
214+
it('handles delegation errors properly', () => {
215+
const store = useGovernanceStore.getState()
216+
const selfRes = store.delegateVotes(store.userAddress)
217+
expect(selfRes.success).toBe(false)
218+
expect(selfRes.error).toContain('Cannot delegate to your own address')
219+
220+
const emptyRes = store.delegateVotes('')
221+
expect(emptyRes.success).toBe(false)
222+
223+
const revokeEmpty = store.revokeDelegation()
224+
expect(revokeEmpty.success).toBe(false)
225+
})
226+
})
227+
228+
describe('Store Actions - Proposal Creation', () => {
229+
it('creates a new proposal, locks deposit, and adds to proposal list', () => {
230+
const store = useGovernanceStore.getState()
231+
const initialCount = store.proposals.length
232+
const initialBalance = store.userTokenBalance
233+
234+
const input: CreateProposalInput = {
235+
title: 'VIP-099: Community Validator Subsidy Pool',
236+
description: 'Establish a new subsidy pool for community validators to ensure decentralization.',
237+
category: 'treasury',
238+
votingType: 'token-weighted',
239+
deposit: 500,
240+
actions: [
241+
{
242+
id: 'act-1',
243+
target: 'CDLZFC3SY...',
244+
functionName: 'allocate_subsidy',
245+
parameters: '{"amount": "50000"}',
246+
value: '50,000 VN',
247+
},
248+
],
249+
}
250+
251+
const res = store.createProposal(input)
252+
expect(res.success).toBe(true)
253+
expect(res.proposalId).toBeDefined()
254+
255+
const state = useGovernanceStore.getState()
256+
expect(state.proposals.length).toBe(initialCount + 1)
257+
expect(state.proposals[0].id).toBe(res.proposalId)
258+
expect(state.proposals[0].title).toBe(input.title)
259+
expect(state.proposals[0].status).toBe('active')
260+
expect(state.userTokenBalance).toBe(initialBalance - 500)
261+
})
262+
263+
it('rejects proposal creation with invalid title, description, or insufficient deposit', () => {
264+
const store = useGovernanceStore.getState()
265+
266+
const shortTitle = store.createProposal({
267+
title: 'Hi',
268+
description: 'Valid description with more than 20 characters in length.',
269+
category: 'general',
270+
votingType: 'token-weighted',
271+
deposit: MINIMUM_PROPOSAL_DEPOSIT,
272+
})
273+
expect(shortTitle.success).toBe(false)
274+
275+
const shortDesc = store.createProposal({
276+
title: 'Valid Long Proposal Title',
277+
description: 'Too short',
278+
category: 'general',
279+
votingType: 'token-weighted',
280+
deposit: MINIMUM_PROPOSAL_DEPOSIT,
281+
})
282+
expect(shortDesc.success).toBe(false)
283+
284+
const lowDeposit = store.createProposal({
285+
title: 'Valid Long Proposal Title',
286+
description: 'Valid description with more than 20 characters in length.',
287+
category: 'general',
288+
votingType: 'token-weighted',
289+
deposit: 10,
290+
})
291+
expect(lowDeposit.success).toBe(false)
292+
})
293+
})
294+
295+
describe('Filtering & Selectors', () => {
296+
it('filters proposals by status, category, and search query', () => {
297+
const store = useGovernanceStore.getState()
298+
299+
// Status filter
300+
store.setFilter('active')
301+
let filtered = useGovernanceStore.getState().getFilteredProposals()
302+
expect(filtered.every((p) => p.status === 'active')).toBe(true)
303+
304+
// Category filter
305+
store.setFilter('all')
306+
store.setCategory('treasury')
307+
filtered = useGovernanceStore.getState().getFilteredProposals()
308+
expect(filtered.every((p) => p.category === 'treasury')).toBe(true)
309+
310+
// Search query
311+
store.setCategory('all')
312+
store.setSearchQuery('Slashing')
313+
filtered = useGovernanceStore.getState().getFilteredProposals()
314+
expect(filtered.length).toBeGreaterThan(0)
315+
expect(filtered.some((p) => p.title.includes('Slashing'))).toBe(true)
316+
})
317+
318+
it('computes governance metrics summary', () => {
319+
const metrics = useGovernanceStore.getState().getMetrics()
320+
expect(metrics.totalProposals).toBeGreaterThan(0)
321+
expect(metrics.activeProposals).toBeGreaterThan(0)
322+
expect(metrics.totalVotingPower).toBeGreaterThan(0)
323+
expect(metrics.userVotingPower).toBe(25000)
324+
})
325+
})
326+
})

0 commit comments

Comments
 (0)