5257dfbaab
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|