+
-
- {delegate.name}
-
+ {delegate?.name ?? `Delegação ${delegateId}`}
+
@@ -58,4 +59,4 @@ export default function SpeakerList() {
)
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/session/timer.test.tsx b/frontend/src/components/session/timer.test.tsx
new file mode 100644
index 0000000..faedc3a
--- /dev/null
+++ b/frontend/src/components/session/timer.test.tsx
@@ -0,0 +1,69 @@
+import { act, fireEvent, screen } from "@testing-library/react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { ChairEvents } from "@/schemas/types.gen"
+import Timer from "@/components/session/timer"
+import { useCommitteeStore } from "@/store/useCommitteeStore"
+import { createSessionState } from "@/test/session-state"
+import { renderWithRouter } from "@/test/render"
+
+const sendMessage = vi.fn()
+
+vi.mock("@/pages/Session", () => ({
+ sendMessage: (data: unknown) => sendMessage(data),
+}))
+
+describe("Timer", () => {
+ beforeEach(() => {
+ sendMessage.mockClear()
+ vi.useRealTimers()
+ useCommitteeStore.setState(createSessionState(), true)
+ })
+
+ it("renders the paused timer and sends chair timer events", () => {
+ useCommitteeStore.setState(
+ createSessionState({ timer_remaining_seconds: 65 }),
+ true,
+ )
+
+ renderWithRouter(
)
+
+ expect(screen.getByText("01:05")).toBeInTheDocument()
+ expect(screen.getByText("Mesa")).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole("button", { name: /start timer/i }))
+ expect(sendMessage).toHaveBeenCalledWith({
+ type: ChairEvents.TOGGLE_TIMER_EVENT,
+ payload: {},
+ })
+
+ fireEvent.click(screen.getByRole("button", { name: /5s/i }))
+ expect(sendMessage).toHaveBeenCalledWith({
+ type: ChairEvents.INCREASE_TIMER_EVENT,
+ payload: { seconds: 5 },
+ })
+ })
+
+ it("counts down from timer_expiration while running", () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date("2026-07-01T12:00:00Z"))
+ useCommitteeStore.setState(
+ createSessionState({
+ timer_is_running: true,
+ timer_expiration: "2026-07-01T12:01:30Z",
+ timer_remaining_seconds: 90,
+ }),
+ true,
+ )
+
+ renderWithRouter(
)
+
+ expect(screen.getByText("01:30")).toBeInTheDocument()
+
+ act(() => {
+ vi.advanceTimersByTime(31_000)
+ })
+
+ expect(screen.getByText("00:59")).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/session/timer.tsx b/frontend/src/components/session/timer.tsx
index 874f1e0..48b5b58 100644
--- a/frontend/src/components/session/timer.tsx
+++ b/frontend/src/components/session/timer.tsx
@@ -12,7 +12,8 @@ export default function Timer() {
const currentSpeaker = useCommitteeStore((state) => state.current_speaker);
- const speaker = currentSpeaker ?? {
+ const delegations = useCommitteeStore((state) => state.delegations);
+ const speaker = delegations.find((delegation) => delegation.id === currentSpeaker) ?? {
id: -1,
seat: "",
name: "Mesa",
@@ -66,4 +67,4 @@ export default function Timer() {
)
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/session/top-bar.test.tsx b/frontend/src/components/session/top-bar.test.tsx
new file mode 100644
index 0000000..a301535
--- /dev/null
+++ b/frontend/src/components/session/top-bar.test.tsx
@@ -0,0 +1,33 @@
+import { screen } from "@testing-library/react"
+import { beforeEach, describe, expect, it } from "vitest"
+
+import TopBar from "@/components/session/top-bar"
+import { useCommitteeStore } from "@/store/useCommitteeStore"
+import { createSessionState } from "@/test/session-state"
+import { renderWithRouter } from "@/test/render"
+
+describe("TopBar", () => {
+ beforeEach(() => {
+ useCommitteeStore.setState(createSessionState(), true)
+ })
+
+ it("shows the no-topic fallback", () => {
+ renderWithRouter(
{/*
diff --git a/frontend/src/store/useCommitteeStore.test.ts b/frontend/src/store/useCommitteeStore.test.ts
new file mode 100644
index 0000000..bc068d6
--- /dev/null
+++ b/frontend/src/store/useCommitteeStore.test.ts
@@ -0,0 +1,41 @@
+import { beforeEach, describe, expect, it } from "vitest"
+
+import { States } from "@/schemas/types.gen"
+import { createSessionState } from "@/test/session-state"
+
+import { UpdateStore, useCommitteeStore } from "./useCommitteeStore"
+
+const initialState = useCommitteeStore.getInitialState()
+
+describe("useCommitteeStore", () => {
+ beforeEach(() => {
+ useCommitteeStore.setState(initialState, true)
+ })
+
+ it("starts with an empty committee projection", () => {
+ const state = useCommitteeStore.getState()
+
+ expect(state.session_id).toBe(-1)
+ expect(state.delegations).toEqual([])
+ expect(state.timer_is_running).toBe(false)
+ expect(state.roll_call).toEqual({})
+ })
+
+ it("replaces local state with the backend session snapshot", () => {
+ const snapshot = createSessionState({
+ session_id: 42,
+ current_state: States.ROLL_CALL,
+ timer_remaining_seconds: 90,
+ gsl_queue: [1, 2],
+ })
+
+ UpdateStore(snapshot)
+
+ expect(useCommitteeStore.getState()).toMatchObject({
+ session_id: 42,
+ current_state: States.ROLL_CALL,
+ timer_remaining_seconds: 90,
+ gsl_queue: [1, 2],
+ })
+ })
+})
diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx
new file mode 100644
index 0000000..25fde85
--- /dev/null
+++ b/frontend/src/test/render.tsx
@@ -0,0 +1,14 @@
+import { render } from "@testing-library/react"
+import type { ReactElement } from "react"
+import { MemoryRouter } from "react-router-dom"
+
+export function renderWithRouter(
+ ui: ReactElement,
+ initialEntries: string[] = ["/"],
+) {
+ return render(
+
+ {ui}
+ ,
+ )
+}
diff --git a/frontend/src/test/session-state.ts b/frontend/src/test/session-state.ts
new file mode 100644
index 0000000..0e9ab51
--- /dev/null
+++ b/frontend/src/test/session-state.ts
@@ -0,0 +1,41 @@
+import type { SessionLiveState } from "@/schemas/types.gen"
+import { RollCallChoice, States } from "@/schemas/types.gen"
+
+export function createSessionState(
+ overrides: Partial = {},
+): SessionLiveState {
+ return {
+ session_id: 0,
+ start_time: "2026-07-01T12:00:00Z",
+ delegations: [
+ { id: 0, name: "Brazil", seat: "1-1", code: "br" },
+ { id: 1, name: "France", seat: "1-2", code: "fr" },
+ { id: 2, name: "Japan", seat: "1-3", code: "jp" },
+ ],
+ current_state: States.OPEN_GSL,
+ timer_is_running: false,
+ timer_expiration: null,
+ timer_remaining_seconds: 0,
+ current_speaker: null,
+ gsl_queue: [],
+ can_set_motion: false,
+ gsl_default_time_seconds: 60,
+ caucus_list: [],
+ debate: null,
+ submitted_motions: [],
+ submitted_questions: [],
+ agenda_topics: [],
+ active_topic_index: null,
+ voting: null,
+ voting_choice: null,
+ roll_call: {
+ registry: {
+ 0: RollCallChoice.PRESENT,
+ 1: RollCallChoice.PRESENT_AND_VOTING,
+ 2: RollCallChoice.ABSENT,
+ },
+ current_delegation: null,
+ },
+ ...overrides,
+ }
+}
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..601f021
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,8 @@
+import "@testing-library/jest-dom/vitest"
+
+import { cleanup } from "@testing-library/react"
+import { afterEach } from "vitest"
+
+afterEach(() => {
+ cleanup()
+})
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index d3c52ea..4bbd510 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -4,7 +4,7 @@
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
- "types": ["node"],
+ "types": ["node", "vitest/config"],
"skipLibCheck": true,
/* Bundler mode */
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 5b6048f..69535c8 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,6 +1,6 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
-import { defineConfig } from 'vite'
+import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
@@ -14,4 +14,8 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
+ test: {
+ environment: "jsdom",
+ setupFiles: "./src/test/setup.ts",
+ },
})