Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
build
coverage
node_modules
promotional_materials/font/berlin.zip
scripts/.deps
Expand Down
5 changes: 4 additions & 1 deletion src/background/SpacingRulesHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ export class SpacingRulesHandler {
const text = `${insertSpaceBefore ? "\xA0" : ""}${lastChar}${
insertSpaceAfter ? "\xA0" : ""
}`;
if (text === lastChar) {
if (
text === lastChar &&
SPACING_RULES[lastChar].spaceBefore !== Spacing.REMOVE_SPACE
) {
return null;
}
return {
Expand Down
6 changes: 2 additions & 4 deletions src/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,9 @@ function onInstalled(details: chrome.runtime.InstalledDetails) {
} else if (details.reason === "update") {
const thisVersion = chrome.runtime.getManifest().version;
console.log(`Updated from ${details.previousVersion} to ${thisVersion}!`);
try {
migrateToLocalStore(details.previousVersion);
} catch (error) {
migrateToLocalStore(details.previousVersion).catch((error) => {
logError("migrateToLocalStore", error);
}
});
}
}

Expand Down
76 changes: 76 additions & 0 deletions tests/Migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { jest } from "@jest/globals";

const settingsGet = jest.fn<(key: string) => Promise<unknown>>();
const settingsSet =
jest.fn<(key: string, value: unknown) => Promise<unknown>>();
const settingsManagerCtor = jest.fn().mockImplementation(() => ({
get: settingsGet,
set: settingsSet,
}));

jest.unstable_mockModule("../src/shared/settingsManager", () => ({
SettingsManager: settingsManagerCtor,
}));

describe("migrateToLocalStore", () => {
let migrateToLocalStore: (lastVersion?: string) => Promise<void>;

beforeAll(async () => {
({ migrateToLocalStore } = await import("../src/background/Migration"));
});

beforeEach(() => {
jest.clearAllMocks();
(globalThis as { chrome: unknown }).chrome = {
runtime: {
getManifest: jest.fn(() => ({ version: "2026.2.1" })),
},
storage: {
sync: {
get: jest.fn((_: unknown, callback: (result: unknown) => void) =>
callback({ key: "value" }),
),
},
local: {
set: jest.fn(),
},
},
};
});

test("migrates sync storage to local storage for older versions", async () => {
settingsGet.mockResolvedValue("en");

await migrateToLocalStore("2023.01.01");

expect(global.chrome.storage.sync.get).toHaveBeenCalledWith(
null,
expect.any(Function),
);
expect(global.chrome.storage.local.set).toHaveBeenCalledWith({
key: "value",
});
expect(global.chrome.storage.local.set).toHaveBeenCalledWith({
lastVersion: "2026.2.1",
});
});

test("updates language and fallbackLanguage to full supported keys", async () => {
settingsGet.mockResolvedValueOnce("en").mockResolvedValueOnce("fr");

await migrateToLocalStore("2024.01.01");

expect(settingsSet).toHaveBeenCalledWith("language", "en_US");
expect(settingsSet).toHaveBeenCalledWith("fallbackLanguage", "fr_FR");
});

test("skips sync migration and language rewrite for new versions", async () => {
await migrateToLocalStore("2026.03.01");

expect(global.chrome.storage.sync.get).not.toHaveBeenCalled();
expect(settingsManagerCtor).not.toHaveBeenCalled();
expect(global.chrome.storage.local.set).toHaveBeenCalledWith({
lastVersion: "2026.2.1",
});
});
});
69 changes: 69 additions & 0 deletions tests/PresageEngine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { jest } from "@jest/globals";
import type { PresageModule } from "../src/background/PresageTypes";
import { PresageEngine } from "../src/background/PresageEngine";

describe("PresageEngine", () => {
test("initializes native Presage with callback/path and suggestion config", () => {
const callbackImplement = jest.fn((callbackImpl) => callbackImpl);
const config = jest.fn();

const module = {
PresageCallback: { implement: callbackImplement },
Presage: class {
constructor(
_callbackImpl: unknown,
public path: string,
) {}
config = config;
predictWithProbability() {
return {
size: () => 0,
get: () => ({ prediction: "" }),
};
}
},
FS: { writeFile: jest.fn() },
} as unknown as PresageModule;

const engine = new PresageEngine(module, { numSuggestions: 3 }, "en_US");

expect(callbackImplement).toHaveBeenCalledTimes(1);
expect(config).toHaveBeenCalledWith("Presage.Selector.SUGGESTIONS", "3");

engine.setConfig({ numSuggestions: 7 });
expect(config).toHaveBeenCalledWith("Presage.Selector.SUGGESTIONS", "7");
});

test("predict parses JSON predictions and keeps plain string predictions", () => {
const nativePredictions = [
{ prediction: '"hello"' },
{ prediction: "world" },
{ prediction: "null" },
];
const implement = jest.fn((callbackImpl) => callbackImpl);

const module = {
PresageCallback: {
implement,
},
Presage: class {
config = jest.fn();
constructor() {}
predictWithProbability() {
return {
size: () => nativePredictions.length,
get: (index: number) => nativePredictions[index],
};
}
},
FS: { writeFile: jest.fn() },
} as unknown as PresageModule;

const engine = new PresageEngine(module, { numSuggestions: 3 }, "en_US");
const predictions = engine.predict("input text");

const callbackArg = implement.mock.calls[0]?.[0] as { pastStream: string };
expect(callbackArg.pastStream).toBe("input text");
expect(predictions).toEqual(["hello", "world"]);
});
});
62 changes: 62 additions & 0 deletions tests/SpacingRulesHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
SPACE_CHARS,
SPACING_RULES,
Spacing,
SpacingRulesHandler,
} from "../src/background/SpacingRulesHandler";

describe("SpacingRulesHandler", () => {
test("exposes spacing constants through static getters", () => {
expect(SpacingRulesHandler.Spacing).toBe(Spacing);
expect(SpacingRulesHandler.SPACING_RULES).toBe(SPACING_RULES);
expect(SpacingRulesHandler.SPACE_CHARS).toBe(SPACE_CHARS);
});

test("returns null when rules are disabled, input is empty, or input too short", () => {
const disabled = new SpacingRulesHandler(true, false);
const enabled = new SpacingRulesHandler(true, true);

expect(disabled.applySpacingRules("a .")).toBeNull();
expect(enabled.applySpacingRules("")).toBeNull();
expect(enabled.applySpacingRules(".")).toBeNull();
});

test("inserts non-breaking space before opening punctuation", () => {
const handler = new SpacingRulesHandler(true, true);

expect(handler.applySpacingRules("a(")).toEqual({
text: "\xA0(",
length: 1,
});
});

test("removes preceding space and inserts trailing space for sentence punctuation", () => {
const handler = new SpacingRulesHandler(true, true);

expect(handler.applySpacingRules("a .")).toEqual({
text: ".\xA0",
length: 2,
});
});

test("removes preceding space even when trailing insertion is disabled", () => {
const handler = new SpacingRulesHandler(false, true);

expect(handler.applySpacingRules("a .")).toEqual({
text: ".",
length: 2,
});
});

test("does not change symbols configured with NO_CHANGE spacing", () => {
const handler = new SpacingRulesHandler(false, true);

expect(handler.applySpacingRules("a -")).toBeNull();
});

test("skips replacement when the previous-previous character is already a space", () => {
const handler = new SpacingRulesHandler(true, true);

expect(handler.applySpacingRules("a .")).toBeNull();
});
});
58 changes: 58 additions & 0 deletions tests/TemplateExpander.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { jest } from "@jest/globals";
import { DATE_TIME_VARIABLES } from "../src/shared/variables";
import { TemplateExpander } from "../src/background/TemplateExpander";

describe("TemplateExpander", () => {
afterEach(() => {
jest.restoreAllMocks();
});

test("parseStringTemplate replaces known placeholders", () => {
const result = TemplateExpander.parseStringTemplate("Hello ${name}!", {
name: "World",
});

expect(result).toBe("Hello World!");
});

test("parseStringTemplate keeps missing placeholders and preserves empty values", () => {
const result = TemplateExpander.parseStringTemplate(
"${known}-${missing}-${empty}",
{
known: "ok",
empty: "",
},
);

expect(result).toBe("ok-${missing}-");
});

test("getExpandedVariables returns empty object when variable expansion is disabled", () => {
expect(
TemplateExpander.getExpandedVariables("en_US", false, "HH:mm", "yyyy"),
).toEqual({});
});

test("getExpandedVariables uses date/time providers with language and formats", () => {
const timeSpy = jest
.spyOn(DATE_TIME_VARIABLES, "time")
.mockReturnValue("10:30");
const dateSpy = jest
.spyOn(DATE_TIME_VARIABLES, "date")
.mockReturnValue("2026-01-02");

const result = TemplateExpander.getExpandedVariables(
"fr_FR",
true,
"HH:mm",
"yyyy-MM-dd",
);

expect(timeSpy).toHaveBeenCalledWith("fr_FR", "HH:mm");
expect(dateSpy).toHaveBeenCalledWith("fr_FR", "yyyy-MM-dd");
expect(result).toEqual({
time: "10:30",
date: "2026-01-02",
});
});
});
58 changes: 58 additions & 0 deletions tests/TextExpansionManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { jest } from "@jest/globals";
import type { PresageModule } from "../src/background/PresageTypes";
import { TextExpansionManager } from "../src/background/TextExpansionManager";

describe("TextExpansionManager", () => {
test("writes lowercase expansions to file and updates all presage engines", () => {
const writeFile = jest.fn();
const configEn = jest.fn();
const configFr = jest.fn();

const module = {
FS: { writeFile },
} as unknown as PresageModule;

const manager = new TextExpansionManager(module, {
en_US: { libPresage: { config: configEn } },
fr_FR: { libPresage: { config: configFr } },
} as never);

manager.setTextExpansions([
["BRB", { phrase: "be right back" }],
["IDK", { phrase: "I don't know" }],
]);

expect(writeFile).toHaveBeenCalledWith(
"/textExpansions.txt",
'brb\t{"phrase":"be right back"}\n' + 'idk\t{"phrase":"I don\'t know"}\n',
);
expect(configEn).toHaveBeenCalledWith(
"Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS",
"/textExpansions.txt",
);
expect(configFr).toHaveBeenCalledWith(
"Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS",
"/textExpansions.txt",
);
});

test("handles empty expansion lists by writing an empty file", () => {
const writeFile = jest.fn();
const config = jest.fn();
const module = {
FS: { writeFile },
} as unknown as PresageModule;

const manager = new TextExpansionManager(module, {
en_US: { libPresage: { config } },
} as never);

manager.setTextExpansions([]);

expect(writeFile).toHaveBeenCalledWith("/textExpansions.txt", "");
expect(config).toHaveBeenCalledWith(
"Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS",
"/textExpansions.txt",
);
});
});
Loading
Loading