@@ -47,7 +47,10 @@ function makeClient(overrides?: {
4747 delegate ?: unknown ;
4848 voteError ?: string | null ;
4949 delegateError ?: string | null ;
50+ revokeError ?: string | null ;
51+ voteThrows ?: string ;
5052} ) {
53+ let currentDelegate = overrides ?. delegate ?? null ;
5154 const invokeContract = vi . fn ( async ( params : InvokeParams ) => {
5255 switch ( params . method ) {
5356 case "get_proposals" :
@@ -58,11 +61,19 @@ function makeClient(overrides?: {
5861 } ;
5962 case "get_delegate" :
6063 return {
61- data : overrides ?. delegate ?? null ,
64+ data : currentDelegate ,
6265 error : null ,
6366 status : "success" as const ,
6467 } ;
68+ case "revoke_delegation" :
69+ if ( ! overrides ?. revokeError ) currentDelegate = null ;
70+ return {
71+ data : overrides ?. revokeError ? null : { hash : "tx_revoke" } ,
72+ error : overrides ?. revokeError ?? null ,
73+ status : overrides ?. revokeError ? ( "error" as const ) : ( "success" as const ) ,
74+ } ;
6575 case "vote" :
76+ if ( overrides ?. voteThrows ) throw new Error ( overrides . voteThrows ) ;
6677 return {
6778 data : overrides ?. voteError ? null : { hash : "tx_vote" } ,
6879 error : overrides ?. voteError ?? null ,
@@ -316,4 +327,162 @@ describe("GovernanceDashboard", () => {
316327 ) ;
317328 } ) ;
318329 } ) ;
330+ // ─── Closed / executed proposals ───────────────────────────────────────────
331+ describe ( "closed proposals" , ( ) => {
332+ const withStatus = ( id : string , status : string ) : GovernanceProposal => ( {
333+ id,
334+ title : `Proposal ${ status } ` ,
335+ description : `A ${ status } proposal.` ,
336+ status,
337+ votesFor : 10 ,
338+ votesAgainst : 5 ,
339+ votesAbstain : 1 ,
340+ } ) ;
341+
342+ it . each ( [ "closed" , "executed" ] ) ( "disables every vote button for a %s proposal" , async ( status ) => {
343+ const { client, invokeContract } = makeClient ( { proposals : [ withStatus ( "9" , status ) ] } ) ;
344+ mockSorokit ( client ) ;
345+
346+ render ( < GovernanceDashboard /> ) ;
347+
348+ const card = ( await screen . findByText ( `Proposal ${ status } ` ) ) . closest ( '[role="listitem"]' ) as HTMLElement ;
349+ expect ( within ( card ) . getByText ( status ) ) . toBeInTheDocument ( ) ;
350+ expect ( within ( card ) . getByRole ( "button" , { name : "Vote Yes" } ) ) . toBeDisabled ( ) ;
351+ expect ( within ( card ) . getByRole ( "button" , { name : "Vote No" } ) ) . toBeDisabled ( ) ;
352+ expect ( within ( card ) . getByRole ( "button" , { name : "Vote Abstain" } ) ) . toBeDisabled ( ) ;
353+ expect ( within ( card ) . getByTestId ( "voting-closed-note" ) ) . toHaveTextContent ( "Voting closed" ) ;
354+
355+ fireEvent . click ( within ( card ) . getByRole ( "button" , { name : "Vote Yes" } ) ) ;
356+ expect ( screen . queryByRole ( "dialog" ) ) . not . toBeInTheDocument ( ) ;
357+ expect ( invokeContract ) . not . toHaveBeenCalledWith ( expect . objectContaining ( { method : "vote" } ) ) ;
358+ } ) ;
359+
360+ it ( "keeps voting enabled on an active proposal listed next to a closed one" , async ( ) => {
361+ const { client } = makeClient ( {
362+ proposals : [ PROPOSALS [ 0 ] , withStatus ( "9" , "closed" ) ] ,
363+ } ) ;
364+ mockSorokit ( client ) ;
365+
366+ render ( < GovernanceDashboard /> ) ;
367+
368+ await screen . findByText ( "Proposal closed" ) ;
369+ const [ active , closed ] = screen . getAllByRole ( "listitem" ) ;
370+ expect ( within ( active ) . getByRole ( "button" , { name : "Vote Yes" } ) ) . toBeEnabled ( ) ;
371+ expect ( within ( active ) . queryByTestId ( "voting-closed-note" ) ) . not . toBeInTheDocument ( ) ;
372+ expect ( within ( closed ) . getByRole ( "button" , { name : "Vote Yes" } ) ) . toBeDisabled ( ) ;
373+ } ) ;
374+ } ) ;
375+
376+ // ─── Vote failure feedback ─────────────────────────────────────────────────
377+ describe ( "vote failures" , ( ) => {
378+ async function openConfirm ( client : SorokitClient ) {
379+ mockSorokit ( client ) ;
380+ render ( < GovernanceDashboard /> ) ;
381+ await screen . findByText ( "Increase validator rewards" ) ;
382+ fireEvent . click ( within ( screen . getAllByRole ( "listitem" ) [ 0 ] ) . getByRole ( "button" , { name : "Vote Yes" } ) ) ;
383+ return screen . findByRole ( "dialog" ) ;
384+ }
385+
386+ it ( "shows an error banner, no success notice, and allows retrying" , async ( ) => {
387+ const { client, invokeContract } = makeClient ( { voteError : "tx_failed" } ) ;
388+ const dialog = await openConfirm ( client ) ;
389+
390+ fireEvent . click ( within ( dialog ) . getByRole ( "button" , { name : / c o n f i r m v o t e / i } ) ) ;
391+
392+ await waitFor ( ( ) => expect ( within ( dialog ) . getByRole ( "alert" ) ) . toBeInTheDocument ( ) ) ;
393+ expect ( screen . queryByRole ( "status" ) ) . not . toBeInTheDocument ( ) ;
394+ expect ( within ( dialog ) . getByRole ( "button" , { name : / c o n f i r m v o t e / i } ) ) . toBeEnabled ( ) ;
395+
396+ fireEvent . click ( within ( dialog ) . getByRole ( "button" , { name : / c o n f i r m v o t e / i } ) ) ;
397+ await waitFor ( ( ) =>
398+ expect ( invokeContract . mock . calls . filter ( ( [ p ] ) => p . method === "vote" ) ) . toHaveLength ( 2 ) ,
399+ ) ;
400+ } ) ;
401+
402+ it ( "shows an error banner when the contract invocation throws" , async ( ) => {
403+ const { client } = makeClient ( { voteThrows : "network down" } ) ;
404+ const dialog = await openConfirm ( client ) ;
405+
406+ fireEvent . click ( within ( dialog ) . getByRole ( "button" , { name : / c o n f i r m v o t e / i } ) ) ;
407+
408+ await waitFor ( ( ) => expect ( within ( dialog ) . getByRole ( "alert" ) ) . toBeInTheDocument ( ) ) ;
409+ expect ( screen . getByRole ( "dialog" ) ) . toBeInTheDocument ( ) ;
410+ expect ( screen . queryByRole ( "status" ) ) . not . toBeInTheDocument ( ) ;
411+ expect ( within ( dialog ) . getByRole ( "button" , { name : / c o n f i r m v o t e / i } ) ) . toBeEnabled ( ) ;
412+ } ) ;
413+
414+ it ( "clears the error when the dialog is cancelled and reopened" , async ( ) => {
415+ const { client } = makeClient ( { voteError : "tx_failed" } ) ;
416+ const dialog = await openConfirm ( client ) ;
417+
418+ fireEvent . click ( within ( dialog ) . getByRole ( "button" , { name : / c o n f i r m v o t e / i } ) ) ;
419+ await waitFor ( ( ) => expect ( within ( dialog ) . getByRole ( "alert" ) ) . toBeInTheDocument ( ) ) ;
420+
421+ fireEvent . click ( within ( dialog ) . getByRole ( "button" , { name : / c a n c e l / i } ) ) ;
422+ await waitFor ( ( ) => expect ( screen . queryByRole ( "dialog" ) ) . not . toBeInTheDocument ( ) ) ;
423+
424+ fireEvent . click ( within ( screen . getAllByRole ( "listitem" ) [ 0 ] ) . getByRole ( "button" , { name : "Vote No" } ) ) ;
425+ const reopened = await screen . findByRole ( "dialog" ) ;
426+ expect ( within ( reopened ) . queryByRole ( "alert" ) ) . not . toBeInTheDocument ( ) ;
427+ } ) ;
428+ } ) ;
429+
430+ // ─── Delegation revocation ─────────────────────────────────────────────────
431+ describe ( "delegation revocation" , ( ) => {
432+ it ( "hides the revoke button when there is no delegate" , async ( ) => {
433+ const { client } = makeClient ( { delegate : null } ) ;
434+ mockSorokit ( client ) ;
435+
436+ render ( < GovernanceDashboard /> ) ;
437+
438+ await screen . findByTestId ( "current-delegate" ) ;
439+ expect ( screen . queryByRole ( "button" , { name : / r e v o k e d e l e g a t i o n / i } ) ) . not . toBeInTheDocument ( ) ;
440+ } ) ;
441+
442+ it ( "revokes the current delegate and returns to self-voting" , async ( ) => {
443+ const user = userEvent . setup ( ) ;
444+ const { client, invokeContract } = makeClient ( { delegate : VALID_ADDRESS } ) ;
445+ mockSorokit ( client ) ;
446+
447+ render ( < GovernanceDashboard /> ) ;
448+
449+ await waitFor ( ( ) =>
450+ expect ( screen . getByTestId ( "current-delegate" ) ) . toHaveAttribute ( "title" , VALID_ADDRESS ) ,
451+ ) ;
452+ await user . click ( screen . getByRole ( "button" , { name : / r e v o k e d e l e g a t i o n / i } ) ) ;
453+
454+ await waitFor ( ( ) =>
455+ expect ( invokeContract ) . toHaveBeenCalledWith (
456+ expect . objectContaining ( {
457+ method : "revoke_delegation" ,
458+ sourceAccount : VALID_ADDRESS ,
459+ } ) ,
460+ ) ,
461+ ) ;
462+ await waitFor ( ( ) =>
463+ expect ( screen . getByRole ( "status" ) ) . toHaveTextContent ( / d e l e g a t i o n r e v o k e d / i) ,
464+ ) ;
465+ await waitFor ( ( ) =>
466+ expect ( screen . getByTestId ( "current-delegate" ) ) . toHaveTextContent ( / n o n e / i) ,
467+ ) ;
468+ expect ( screen . queryByRole ( "button" , { name : / r e v o k e d e l e g a t i o n / i } ) ) . not . toBeInTheDocument ( ) ;
469+ } ) ;
470+
471+ it ( "shows an error and keeps the delegate when revocation fails" , async ( ) => {
472+ const user = userEvent . setup ( ) ;
473+ const { client } = makeClient ( { delegate : VALID_ADDRESS , revokeError : "tx_failed" } ) ;
474+ mockSorokit ( client ) ;
475+
476+ render ( < GovernanceDashboard /> ) ;
477+
478+ await waitFor ( ( ) =>
479+ expect ( screen . getByTestId ( "current-delegate" ) ) . toHaveAttribute ( "title" , VALID_ADDRESS ) ,
480+ ) ;
481+ await user . click ( screen . getByRole ( "button" , { name : / r e v o k e d e l e g a t i o n / i } ) ) ;
482+
483+ await waitFor ( ( ) => expect ( screen . getByRole ( "alert" ) ) . toBeInTheDocument ( ) ) ;
484+ expect ( screen . getByTestId ( "current-delegate" ) ) . toHaveAttribute ( "title" , VALID_ADDRESS ) ;
485+ expect ( screen . queryByRole ( "status" ) ) . not . toBeInTheDocument ( ) ;
486+ } ) ;
487+ } ) ;
319488} ) ;
0 commit comments