|
| 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