import { afterEach, describe, expect, it } from "vitest"; import { i18n, t } from "@/lib/i18n"; import type { RecentTaskEntry } from "@/lib/recent-tasks"; import { repositoryId as toRepositoryId, sessionId as toSessionId, taskId as toTaskId, workspaceId as toWorkspaceId, type Repository, type TaskSession, } from "@/lib/types/http"; import { buildRecentTaskDisplayItems, buildRecentTaskEntry, getInitialReverseSelectionIndex, getInitialSelectionIndex, getNextSelectionIndex, getPreviousSelectionIndex, getTaskStatusBadge, type RecentTaskBuildContext, } from "./recent-task-switcher-model"; const visitedAt = "2026-05-02T10:00:00Z"; const WORKSPACE_ID = "workspace-1"; const REPOSITORY_ID = "repo-1"; const CURRENT_TASK_ID = "task-current"; const PREVIOUS_TASK_ID = "task-previous"; const WF_MAIN = "wf-1"; const WF_REVIEW = "wf-2"; function recent(taskId: string, title: string): RecentTaskEntry { return { taskId, title, visitedAt, taskState: "TODO", sessionState: null, repositoryPath: null, workflowId: WF_MAIN, workflowName: "Stored Workflow", workflowStepTitle: "Stored Step", workspaceId: WORKSPACE_ID, }; } function session(taskIdStr: string, state: TaskSession["state"]): TaskSession { return { id: toSessionId(`${taskIdStr}-session`), task_id: toTaskId(taskIdStr), state, started_at: visitedAt, updated_at: visitedAt, is_primary: true, }; } function buildContext(): RecentTaskBuildContext { const repository: Repository = { id: toRepositoryId(REPOSITORY_ID), workspace_id: toWorkspaceId(WORKSPACE_ID), name: "Local Repo", source_type: "local", local_path: "/tmp/local-repo", provider: "github", provider_repo_id: "repo-gh-id", provider_owner: "kdlbs", provider_name: "kandev", default_branch: "main", worktree_branch_prefix: "task", pull_before_worktree: false, setup_script: "", cleanup_script: "", dev_script: "", copy_files: "", created_at: visitedAt, updated_at: visitedAt, }; return { activeTaskId: CURRENT_TASK_ID, kanbanWorkflowId: WF_MAIN, kanbanTasks: [ { id: CURRENT_TASK_ID, workflowId: WF_MAIN, workflowStepId: "step-1", title: "Current live title", position: 0, state: "IN_PROGRESS", repositoryId: REPOSITORY_ID, primarySessionId: `${CURRENT_TASK_ID}-session`, createdAt: visitedAt, updatedAt: visitedAt, }, ], kanbanSteps: [{ id: "step-1", title: "Working", color: "bg-blue-500", position: 0 }], snapshots: { [WF_REVIEW]: { workflowId: WF_REVIEW, workflowName: "Review Flow", steps: [{ id: "step-2", title: "Review", color: "bg-green-500", position: 0 }], tasks: [ { id: PREVIOUS_TASK_ID, workflowId: WF_REVIEW, workflowStepId: "step-2", title: "Previous live title", position: 0, state: "REVIEW", repositoryId: REPOSITORY_ID, createdAt: visitedAt, updatedAt: visitedAt, }, ], }, }, workflows: [ { id: WF_MAIN, workspaceId: WORKSPACE_ID, name: "Main Flow" }, { id: WF_REVIEW, workspaceId: WORKSPACE_ID, name: "Review Flow" }, ], repositoriesByWorkspace: { [WORKSPACE_ID]: [repository] }, sessionsByTaskId: { [CURRENT_TASK_ID]: [session(CURRENT_TASK_ID, "RUNNING")] }, gitStatusByEnvId: {}, environmentIdBySessionId: {}, }; } afterEach(async () => { await i18n.changeLanguage("en"); }); describe("recent task switcher model", () => { it("starts selection on the first non-current task when possible", () => { const items = [ { taskId: CURRENT_TASK_ID }, { taskId: PREVIOUS_TASK_ID }, { taskId: "task-older" }, ]; expect(getInitialSelectionIndex(items, CURRENT_TASK_ID)).toBe(1); expect(getInitialSelectionIndex(items, "task-missing")).toBe(0); expect(getInitialSelectionIndex([], CURRENT_TASK_ID)).toBe(-1); }); it("starts reverse selection on the last non-current task", () => { const items = [ { taskId: CURRENT_TASK_ID }, { taskId: PREVIOUS_TASK_ID }, { taskId: "task-older" }, ]; expect(getInitialReverseSelectionIndex(items, CURRENT_TASK_ID)).toBe(2); // When the current task is the last item, skip back to the previous one. expect( getInitialReverseSelectionIndex( [{ taskId: PREVIOUS_TASK_ID }, { taskId: CURRENT_TASK_ID }], CURRENT_TASK_ID, ), ).toBe(0); // All items are current -> fall back to the last index. expect(getInitialReverseSelectionIndex([{ taskId: CURRENT_TASK_ID }], CURRENT_TASK_ID)).toBe(0); expect(getInitialReverseSelectionIndex([], CURRENT_TASK_ID)).toBe(-1); }); it("cycles through items and handles empty lists", () => { expect(getNextSelectionIndex(0, 3)).toBe(1); expect(getNextSelectionIndex(2, 3)).toBe(0); expect(getNextSelectionIndex(-1, 3)).toBe(0); expect(getNextSelectionIndex(0, 0)).toBe(-1); }); it("cycles backward through items and handles empty lists", () => { expect(getPreviousSelectionIndex(1, 3)).toBe(0); expect(getPreviousSelectionIndex(0, 3)).toBe(2); expect(getPreviousSelectionIndex(-1, 3)).toBe(2); expect(getPreviousSelectionIndex(0, 0)).toBe(-1); }); it("maps task and session states to compact status badges", () => { expect(getTaskStatusBadge("IN_PROGRESS", "RUNNING")).toMatchObject({ labelKey: "task:sessionStatusRunning", }); expect(getTaskStatusBadge("REVIEW", "WAITING_FOR_INPUT")).toMatchObject({ labelKey: "task:sessionStatusTurnFinished", }); expect(getTaskStatusBadge("TODO", undefined)).toMatchObject({ labelKey: "task:statusTodo" }); expect(getTaskStatusBadge("FAILED", "FAILED")).toMatchObject({ labelKey: "task:sessionStatusFailed", variant: "destructive", }); }); it("builds display items from live task metadata with stored fallback", () => { const display = buildRecentTaskDisplayItems( [recent(CURRENT_TASK_ID, "Stored current"), recent(PREVIOUS_TASK_ID, "Stored previous")], buildContext(), ); expect(display[0]).toMatchObject({ taskId: CURRENT_TASK_ID, title: "Current live title", isCurrent: true, repositoryPath: "kdlbs/kandev", workflowName: "Main Flow", workflowStepTitle: "Working", }); expect(display[0]?.statusBadge.labelKey).toBe("task:sessionStatusRunning"); expect(display[1]).toMatchObject({ taskId: PREVIOUS_TASK_ID, title: "Previous live title", isCurrent: false, repositoryPath: "kdlbs/kandev", workflowName: "Review Flow", workflowStepTitle: "Review", }); }); it("builds a recent entry for the active task while preserving prior visit time", () => { const previous = recent(CURRENT_TASK_ID, "Old title"); const entry = buildRecentTaskEntry(CURRENT_TASK_ID, buildContext(), previous); expect(entry).toMatchObject({ taskId: CURRENT_TASK_ID, title: "Current live title", visitedAt, taskState: "IN_PROGRESS", sessionState: "RUNNING", repositoryPath: "kdlbs/kandev", workflowName: "Main Flow", workflowStepTitle: "Working", workspaceId: WORKSPACE_ID, }); }); }); describe("status badge localization", () => { it("emits catalog keys that resolve to the badge copy", () => { expect(t(getTaskStatusBadge("IN_PROGRESS", "RUNNING").labelKey)).toBe("Running"); expect(t(getTaskStatusBadge("REVIEW", "WAITING_FOR_INPUT").labelKey)).toBe("Turn Finished"); expect(t(getTaskStatusBadge("TODO", undefined).labelKey)).toBe("Todo"); expect(t(getTaskStatusBadge(undefined, undefined).labelKey)).toBe("Backlog"); }); it("keeps badge copy out of module scope, so a locale switch reaches it", async () => { await i18n.changeLanguage("pseudo"); // The table is built at import time. Resolving `t()` there would have // frozen these at "en" and this would still read "Running". expect(t(getTaskStatusBadge("IN_PROGRESS", "RUNNING").labelKey)).toMatch(/[^\p{ASCII}]/u); }); });