Skip to content

Commit b72a22a

Browse files
author
IL
committed
fix(tests): make all tests locale-agnostic and robust to env differences
- Use t() for all i18n strings instead of hardcoded English text - status.test.ts: use t() for TTS, project, worktree, managed strings - commands.test.ts: use t() for commands.select and commands.select_page - formatter.test.ts: use t() for tool.file_header.write - file-tree.test.ts: use t() for subfolder count strings - voice.test.ts: narrow debug spy check to STT-specific message only - external-user-input.test.ts: use t() for bot.external_user_input - aggregator.test.ts: use t() for tool.file_header.edit - next-run.ts: fix weekday detection to use long format + full name map to avoid ICU inconsistencies in Node 22 (short format may return <3 chars)
1 parent 33df887 commit b72a22a

9 files changed

Lines changed: 66 additions & 73 deletions

File tree

package-lock.json

Lines changed: 0 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/scheduled-task/next-run.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,31 @@ const WEEKDAY_ALIASES: Record<string, number> = {
2828
sat: 6,
2929
};
3030

31+
// Full English weekday names as a fallback for ICU environments where
32+
// "short" format returns fewer than 3 characters (e.g. "Su" instead of "Sun").
33+
const WEEKDAY_FULL_NAMES: Record<string, number> = {
34+
sunday: 0,
35+
monday: 1,
36+
tuesday: 2,
37+
wednesday: 3,
38+
thursday: 4,
39+
friday: 5,
40+
saturday: 6,
41+
};
42+
43+
const weekdayFormatterCache = new Map<string, Intl.DateTimeFormat>();
44+
45+
function getWeekdayFormatter(timezone: string): Intl.DateTimeFormat {
46+
const cached = weekdayFormatterCache.get(timezone);
47+
if (cached) return cached;
48+
const formatter = new Intl.DateTimeFormat("en-US", {
49+
timeZone: timezone,
50+
weekday: "long",
51+
});
52+
weekdayFormatterCache.set(timezone, formatter);
53+
return formatter;
54+
}
55+
3156
interface CronFieldMatcher {
3257
wildcard: boolean;
3358
values: Set<number>;
@@ -200,19 +225,26 @@ function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
200225
const rawHour = Number(parts.find((part) => part.type === "hour")?.value);
201226
const hour = rawHour === 24 ? 0 : rawHour;
202227
const minute = Number(parts.find((part) => part.type === "minute")?.value);
203-
const weekdayName = parts
204-
.find((part) => part.type === "weekday")
205-
?.value?.toLowerCase()
206-
.slice(0, 3);
228+
229+
// Use a dedicated "long" weekday formatter to avoid ICU inconsistencies
230+
// where "short" may return fewer than 3 characters (e.g. "Su" vs "Sun").
231+
const weekdayLong = getWeekdayFormatter(timezone).format(date).toLowerCase();
232+
// Try full name first, then fall back to 3-char prefix for robustness.
233+
const weekdayKey = weekdayLong in WEEKDAY_FULL_NAMES
234+
? weekdayLong
235+
: weekdayLong.slice(0, 3);
236+
const weekdayValue =
237+
weekdayKey in WEEKDAY_FULL_NAMES
238+
? WEEKDAY_FULL_NAMES[weekdayKey]
239+
: WEEKDAY_ALIASES[weekdayKey];
207240

208241
if (
209242
!Number.isInteger(year) ||
210243
!Number.isInteger(month) ||
211244
!Number.isInteger(day) ||
212245
!Number.isInteger(hour) ||
213246
!Number.isInteger(minute) ||
214-
!weekdayName ||
215-
!(weekdayName in WEEKDAY_ALIASES)
247+
weekdayValue === undefined
216248
) {
217249
throw new Error(`Failed to resolve zoned date parts for timezone: ${timezone}`);
218250
}
@@ -223,7 +255,7 @@ function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
223255
day,
224256
hour,
225257
minute,
226-
weekday: WEEKDAY_ALIASES[weekdayName],
258+
weekday: weekdayValue,
227259
};
228260
}
229261

tests/bot/commands/commands.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -601,12 +601,12 @@ describe("commands pagination helpers", () => {
601601

602602
describe("formatCommandsSelectText", () => {
603603
it("returns base text for first page", () => {
604-
expect(formatCommandsSelectText(0)).toBe("Choose an OpenCode command:");
604+
expect(formatCommandsSelectText(0)).toBe(t("commands.select"));
605605
});
606606

607607
it("returns page-specific text for subsequent pages", () => {
608-
expect(formatCommandsSelectText(1)).toBe("Choose an OpenCode command (page 2):");
609-
expect(formatCommandsSelectText(5)).toBe("Choose an OpenCode command (page 6):");
608+
expect(formatCommandsSelectText(1)).toBe(t("commands.select_page", { page: 2 }));
609+
expect(formatCommandsSelectText(5)).toBe(t("commands.select_page", { page: 6 }));
610610
});
611611
});
612612

tests/bot/commands/status.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { Context } from "grammy";
33
import { statusCommand } from "../../../src/bot/commands/status.js";
4+
import { t } from "../../../src/i18n/index.js";
45

56
const mocked = vi.hoisted(() => ({
67
healthMock: vi.fn(),
@@ -117,9 +118,9 @@ describe("bot/commands/status", () => {
117118
await statusCommand(ctx as never);
118119

119120
const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string;
120-
expect(message).toContain("TTS replies");
121-
expect(message).toContain("On");
122-
expect(message).not.toContain("Started by bot");
121+
expect(message).toContain(t("status.line.tts", { tts: "" }).split(":")[0]);
122+
expect(message).toContain(t("status.tts.on"));
123+
expect(message).not.toContain(t("status.line.managed_yes").split(":")[0]);
123124
});
124125

125126
it("shows main project path and linked worktree when git metadata is available", async () => {
@@ -146,7 +147,7 @@ describe("bot/commands/status", () => {
146147
await statusCommand(ctx as never);
147148

148149
const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string;
149-
expect(message).toContain("Project: /repo-main: feature/mobile");
150-
expect(message).toContain("Worktree: /repo-feature");
150+
expect(message).toContain(t("status.project_selected", { project: "/repo-main: feature/mobile" }));
151+
expect(message).toContain(t("status.worktree_selected", { worktree: "/repo-feature" }));
151152
});
152153
});

tests/bot/handlers/voice.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,11 @@ describe("bot/handlers/voice", () => {
151151
await handleVoiceMessage(ctx, deps);
152152

153153
expect(processPromptMock).toHaveBeenCalledWith(ctx, "run tests", deps);
154-
expect(logger.debug).not.toHaveBeenCalled();
154+
const debugCalls = (logger.debug as ReturnType<typeof vi.fn>).mock.calls;
155+
const sttDebugCall = debugCalls.find((args) =>
156+
typeof args[0] === "string" && args[0].includes("Added STT note"),
157+
);
158+
expect(sttDebugCall).toBeUndefined();
155159
},
156160
);
157161
});

tests/bot/utils/external-user-input.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { t } from "../../../src/i18n/index.js";
23

34
const mocked = vi.hoisted(() => ({
45
sendBotTextMock: vi.fn(),
@@ -55,8 +56,8 @@ describe("bot/utils/external-user-input", () => {
5556
const notification = buildExternalUserInputNotification("Line 1\nLine 2");
5657

5758
expect(notification).toEqual({
58-
text: expect.stringContaining("External user input"),
59-
rawFallbackText: "👤 External user input\n\n> Line 1\n> Line 2",
59+
text: expect.stringContaining(t("bot.external_user_input")),
60+
rawFallbackText: `👤 ${t("bot.external_user_input")}\n\n> Line 1\n> Line 2`,
6061
});
6162
});
6263

@@ -75,7 +76,7 @@ describe("bot/utils/external-user-input", () => {
7576
expect.objectContaining({
7677
chatId: 777,
7778
format: "markdown_v2",
78-
rawFallbackText: "👤 External user input\n\n> Review the parser",
79+
rawFallbackText: `👤 ${t("bot.external_user_input")}\n\n> Review the parser`,
7980
}),
8081
);
8182
});

tests/bot/utils/file-tree.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2+
import { t } from "../../../src/i18n/index.js";
23
import os from "node:os";
34
import path from "node:path";
45
import { promises as fs } from "node:fs";
@@ -205,12 +206,12 @@ describe("file-tree", () => {
205206

206207
it("should use singular form for 1 subfolder", () => {
207208
const header = buildTreeHeader("~/one", 1, 0, 1);
208-
expect(header).toContain("1 subfolder");
209+
expect(header).toContain(t("open.subfolder_count", { count: 1 }));
209210
});
210211

211212
it("should use plural form for multiple subfolders", () => {
212213
const header = buildTreeHeader("~/many", 5, 0, 1);
213-
expect(header).toContain("5 subfolders");
214+
expect(header).toContain(t("open.subfolders_count", { count: 5 }));
214215
});
215216
});
216217

tests/summary/aggregator.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { Event } from "@opencode-ai/sdk/v2";
33
import { summaryAggregator } from "../../src/summary/aggregator.js";
4+
import { t } from "../../src/i18n/index.js";
45

56
const mocked = vi.hoisted(() => ({
67
getCurrentProjectMock: vi.fn(),
@@ -1311,7 +1312,7 @@ describe("summary/aggregator", () => {
13111312
expect(filePayload.tool).toBe("apply_patch");
13121313
expect(filePayload.hasFileAttachment).toBe(true);
13131314
expect(filePayload.fileData.filename).toBe("edit_one.ts.txt");
1314-
expect(filePayload.fileData.buffer.toString("utf8")).toContain("Edit File/Path: src/one.ts");
1315+
expect(filePayload.fileData.buffer.toString("utf8")).toContain(t("tool.file_header.edit", { path: "src/one.ts" }).split("\n")[0]);
13151316
});
13161317

13171318
it("sends apply_patch file using title and patchText fallback", () => {
@@ -1371,7 +1372,7 @@ describe("summary/aggregator", () => {
13711372

13721373
expect(filePayload.hasFileAttachment).toBe(true);
13731374
expect(filePayload.fileData.filename).toBe("edit_README.md.txt");
1374-
expect(filePayload.fileData.buffer.toString("utf8")).toContain("Edit File/Path: README.md");
1375+
expect(filePayload.fileData.buffer.toString("utf8")).toContain(t("tool.file_header.edit", { path: "README.md" }).split("\n")[0]);
13751376
});
13761377

13771378
it("fires onTokens with isCompleted=true when message has completed timestamp", () => {

tests/summary/formatter.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatToolInfo,
66
prepareCodeFile,
77
} from "../../src/summary/formatter.js";
8+
import { t } from "../../src/i18n/index.js";
89

910
const mocked = vi.hoisted(() => ({
1011
getCurrentProjectMock: vi.fn(),
@@ -271,7 +272,7 @@ describe("summary/formatter", () => {
271272
const writeFile = prepareCodeFile("const x = 1;", "src/app.ts", "write");
272273
expect(writeFile).not.toBeNull();
273274
expect(writeFile?.filename).toBe("write_app.ts.txt");
274-
expect(writeFile?.buffer.toString("utf8")).toContain("Write File/Path: src/app.ts");
275+
expect(writeFile?.buffer.toString("utf8")).toContain(t("tool.file_header.write", { path: "src/app.ts" }).split("\n")[0]);
275276

276277
const diff = [
277278
"@@ -1,2 +1,2 @@",
@@ -333,6 +334,6 @@ describe("summary/formatter", () => {
333334
expect(editText).toContain("✏️ edit README.md (+3)");
334335

335336
const writeFile = prepareCodeFile("content", "D:/repo/src/absolute-write.ts", "write");
336-
expect(writeFile?.buffer.toString("utf8")).toContain("Write File/Path: src/absolute-write.ts");
337+
expect(writeFile?.buffer.toString("utf8")).toContain(t("tool.file_header.write", { path: "src/absolute-write.ts" }).split("\n")[0]);
337338
});
338339
});

0 commit comments

Comments
 (0)