5257dfbaab
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
7.9 KiB
TypeScript
251 lines
7.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { i18n } from "@/lib/i18n";
|
|
import type { StatsResponse } from "@/lib/types/http";
|
|
import {
|
|
buildStatsSummary,
|
|
formatDuration,
|
|
getRangeLabel,
|
|
getSubtitle,
|
|
isRangeKey,
|
|
statsReducer,
|
|
toPanelState,
|
|
} from "./stats-utils";
|
|
import { composeStatsResponse, type StatsSections } from "./stats-data";
|
|
|
|
const emptyGlobal: StatsResponse["global"] = {
|
|
total_tasks: 0,
|
|
completed_tasks: 0,
|
|
in_progress_tasks: 0,
|
|
total_sessions: 0,
|
|
total_turns: 0,
|
|
total_messages: 0,
|
|
total_user_messages: 0,
|
|
total_tool_calls: 0,
|
|
total_duration_ms: 0,
|
|
avg_turns_per_task: 0,
|
|
avg_messages_per_task: 0,
|
|
avg_duration_ms_per_task: 0,
|
|
avg_turn_duration_ms: 0,
|
|
avg_messages_per_turn: 0,
|
|
};
|
|
|
|
const sampleStats: StatsResponse = {
|
|
global: {
|
|
...emptyGlobal,
|
|
total_tasks: 10,
|
|
completed_tasks: 4,
|
|
in_progress_tasks: 2,
|
|
total_sessions: 7,
|
|
total_duration_ms: 3_725_000,
|
|
avg_duration_ms_per_task: 372_500,
|
|
},
|
|
task_stats: [],
|
|
daily_activity: [],
|
|
completed_activity: [],
|
|
model_usage: [],
|
|
repository_stats: [
|
|
{
|
|
repository_id: "r1",
|
|
repository_name: "kandev",
|
|
total_tasks: 6,
|
|
completed_tasks: 3,
|
|
in_progress_tasks: 1,
|
|
session_count: 5,
|
|
turn_count: 30,
|
|
message_count: 60,
|
|
user_message_count: 25,
|
|
tool_call_count: 40,
|
|
total_duration_ms: 1_000_000,
|
|
total_commits: 12,
|
|
total_files_changed: 30,
|
|
total_insertions: 1200,
|
|
total_deletions: 800,
|
|
},
|
|
],
|
|
git_stats: {
|
|
total_commits: 12,
|
|
total_files_changed: 30,
|
|
total_insertions: 1200,
|
|
total_deletions: 800,
|
|
},
|
|
};
|
|
|
|
function readySections(modelUsage: StatsResponse["model_usage"]): StatsSections {
|
|
return {
|
|
global: { kind: "ready", data: sampleStats.global },
|
|
tasks: {
|
|
kind: "ready",
|
|
data: { task_stats: sampleStats.task_stats, task_stats_has_more: false },
|
|
},
|
|
daily: { kind: "ready", data: sampleStats.daily_activity },
|
|
completed: { kind: "ready", data: sampleStats.completed_activity },
|
|
models: { kind: "ready", data: modelUsage },
|
|
repos: { kind: "ready", data: sampleStats.repository_stats },
|
|
git: { kind: "ready", data: sampleStats.git_stats },
|
|
};
|
|
}
|
|
|
|
describe("composeStatsResponse", () => {
|
|
it("waits for model usage before composing the response", () => {
|
|
const sections = readySections([]);
|
|
sections.models = { kind: "loading" };
|
|
|
|
expect(composeStatsResponse(sections)).toBeNull();
|
|
});
|
|
|
|
it("maps model usage into the composed response", () => {
|
|
const modelUsage = [{ model: "opus", session_count: 2, turn_count: 4, total_duration_ms: 100 }];
|
|
|
|
expect(composeStatsResponse(readySections(modelUsage))?.model_usage).toEqual(modelUsage);
|
|
});
|
|
|
|
it("preserves an empty model usage response", () => {
|
|
expect(composeStatsResponse(readySections([]))?.model_usage).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("isRangeKey", () => {
|
|
it("accepts the three valid keys", () => {
|
|
expect(isRangeKey("week")).toBe(true);
|
|
expect(isRangeKey("month")).toBe(true);
|
|
expect(isRangeKey("all")).toBe(true);
|
|
});
|
|
|
|
it("rejects null, undefined, and unknown strings", () => {
|
|
expect(isRangeKey(null)).toBe(false);
|
|
expect(isRangeKey(undefined)).toBe(false);
|
|
expect(isRangeKey("")).toBe(false);
|
|
expect(isRangeKey("day")).toBe(false);
|
|
expect(isRangeKey("MONTH")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getRangeLabel", () => {
|
|
it("returns the friendly label for each range", () => {
|
|
expect(getRangeLabel("week")).toBe("Last Week");
|
|
expect(getRangeLabel("month")).toBe("Last Month");
|
|
expect(getRangeLabel("all")).toBe("All Time");
|
|
});
|
|
});
|
|
|
|
describe("formatDuration", () => {
|
|
it("renders an em-dash for zero", () => {
|
|
expect(formatDuration(0)).toBe("-");
|
|
});
|
|
|
|
it("formats seconds, minutes, and hours", () => {
|
|
expect(formatDuration(45_000)).toBe("45s");
|
|
expect(formatDuration(125_000)).toBe("2m 5s");
|
|
expect(formatDuration(3_725_000)).toBe("1h 2m");
|
|
});
|
|
});
|
|
|
|
describe("getSubtitle", () => {
|
|
it("renders the summary when stats are available", () => {
|
|
expect(getSubtitle(sampleStats.global, false)).toBe("10 tasks · 7 sessions · 1h 2m");
|
|
});
|
|
|
|
it("shows the loading message while the fetch is in flight", () => {
|
|
expect(getSubtitle(null, false)).toBe("Loading stats…");
|
|
});
|
|
|
|
it("shows the error message when the fetch has failed", () => {
|
|
expect(getSubtitle(null, true)).toBe("Failed to load stats");
|
|
});
|
|
|
|
it("prefers stats over the error flag once data arrives", () => {
|
|
expect(getSubtitle(sampleStats.global, true)).toBe("10 tasks · 7 sessions · 1h 2m");
|
|
});
|
|
|
|
it("localizes the ready branch, not only the error and loading ones", async () => {
|
|
// The regression: the error and loading returns went through the catalog
|
|
// while the success subtitle kept "tasks" and "sessions" hardcoded, so the
|
|
// page header was half translated. `check-nonjsx-copy.mjs` cannot see it —
|
|
// the template's longest static chunk is " tasks · ", which `looksLikeCopy`
|
|
// rejects as a single lowercase word.
|
|
await i18n.changeLanguage("pseudo");
|
|
try {
|
|
expect(getSubtitle(sampleStats.global, false)).not.toMatch(/\b(tasks?|sessions?)\b/);
|
|
} finally {
|
|
await i18n.changeLanguage("en");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("statsReducer", () => {
|
|
const initial = { stats: null, error: null };
|
|
|
|
it("fetch action clears stats and error", () => {
|
|
const stale = { stats: sampleStats, error: "previous" };
|
|
expect(statsReducer(stale, { type: "fetch" })).toEqual({ stats: null, error: null });
|
|
});
|
|
|
|
it("success stores stats and clears any prior error", () => {
|
|
const errored = { stats: null, error: "boom" };
|
|
expect(statsReducer(errored, { type: "success", stats: sampleStats })).toEqual({
|
|
stats: sampleStats,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it("failure stores the error and clears stats", () => {
|
|
const loaded = { stats: sampleStats, error: null };
|
|
expect(statsReducer(loaded, { type: "failure", error: "nope" })).toEqual({
|
|
stats: null,
|
|
error: "nope",
|
|
});
|
|
});
|
|
|
|
it("does not return the same reference as the input", () => {
|
|
const next = statsReducer(initial, { type: "fetch" });
|
|
expect(next).not.toBe(initial);
|
|
});
|
|
});
|
|
|
|
describe("toPanelState", () => {
|
|
it("returns loading when neither stats nor error is set", () => {
|
|
expect(toPanelState(null, null)).toEqual({ kind: "loading" });
|
|
});
|
|
|
|
it("returns ready when stats are available", () => {
|
|
expect(toPanelState(sampleStats, null)).toEqual({ kind: "ready", stats: sampleStats });
|
|
});
|
|
|
|
it("returns error when an error is present", () => {
|
|
expect(toPanelState(null, "boom")).toEqual({ kind: "error", message: "boom" });
|
|
});
|
|
|
|
it("prefers error over stale stats so panels never linger in a ready+error mix", () => {
|
|
expect(toPanelState(sampleStats, "boom")).toEqual({ kind: "error", message: "boom" });
|
|
});
|
|
});
|
|
|
|
describe("buildStatsSummary", () => {
|
|
it("includes completion %, top repo, and git line", () => {
|
|
const summary = buildStatsSummary(sampleStats, "Last Month", 4);
|
|
expect(summary).toContain("*Kandev Stats - Last Month*");
|
|
expect(summary).toContain("10 total (4 done, 2 in progress) · 40% completion");
|
|
expect(summary).toContain("Completed (Last Month): 4");
|
|
expect(summary).toContain("Top repo: kandev (6 tasks)");
|
|
expect(summary).toContain("12 commits, +1,200/-800");
|
|
});
|
|
|
|
it("falls back to em-dash and no-git when there is no activity", () => {
|
|
const empty: StatsResponse = {
|
|
...sampleStats,
|
|
global: emptyGlobal,
|
|
repository_stats: [],
|
|
git_stats: {
|
|
total_commits: 0,
|
|
total_files_changed: 0,
|
|
total_insertions: 0,
|
|
total_deletions: 0,
|
|
},
|
|
};
|
|
const summary = buildStatsSummary(empty, "All Time", 0);
|
|
expect(summary).toContain("Top repo: -");
|
|
expect(summary).toContain("Git: no git activity");
|
|
expect(summary).toContain("0 total (0 done, 0 in progress) · - completion");
|
|
});
|
|
});
|