Skip to content
Closed
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
6 changes: 6 additions & 0 deletions __test__/main/chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class Storage {
async set(items) {
Object.assign(this.data, items);
}

async remove(keys) {
for (const key of keys) {
delete this.data[key];
}
}
}

// Mimics chrome.events.Event. Tests can fire registered listeners via dispatch().
Expand Down
4 changes: 2 additions & 2 deletions __test__/main/core/generator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test("Generator should return empty HTML when no words are provided", () => {
</span>
<span style="cursor:pointer;visibility:hidden;" data-md-pronunciation="test" data-md-hovervisible="true">🔊</span>
<br/>
<span style="font-size:small;color:#101010;">
<span style="font-size:small;color:#101010;unicode-bidi:plaintext;">
テスト
</span>
</div>`,
Expand All @@ -39,7 +39,7 @@ test("Generator should return empty HTML when no words are provided", () => {
</span>
<span style="cursor:pointer;visibility:hidden;" data-md-pronunciation="test" data-md-hovervisible="true">🔊</span>
<br/>
<span style="font-size:small;color:#101010;">
<span style="font-size:small;color:#101010;unicode-bidi:plaintext;">
テスト <span style="color:#003366;margin-left:1em;font-size:0.9em;">■TEST</span>
</span>
</div>`,
Expand Down
123 changes: 123 additions & 0 deletions __test__/options/logic/dict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/)
* Copyright 2018-present wtetsu
* Licensed under MIT
*/

import { beforeEach, expect, test, vi } from "vitest";
import Chrome from "../../main/chrome";

// dict.ts reads pack data via fetch(chrome.runtime.getURL(...)).
// Serve fake pack files from memory; unknown paths answer like a missing
// file (not ok), which exercises the registry fallback.
const PACK_FILES: Record<string, any> = {
"/data/packs.json": [
{ id: "en-ja", metaFile: "/data/dict.json" },
{ id: "en-fr", metaFile: "/data/dict-en-fr.json" },
{ id: "ja-fr", metaFile: "/data/dict-ja-fr.json" },
],
"/data/dict.json": { files: ["/data/dict0.json"] },
// "constructor" mirrors real ejdict-hand data (data/dict/a.json5 has it):
// a bare existing[head] lookup falls through to Object.prototype.constructor.
"/data/dict0.json": { cat: "キャット", dog: "イヌ", constructor: "構築者" },
"/data/dict-en-fr.json": { files: ["/data/dict-en-fr0.json"] },
"/data/dict-en-fr0.json": { cat: "chat", water: "eau", constructor: "constructeur" },
"/data/dict-ja-fr.json": { files: ["/data/dict-ja-fr0.json"] },
"/data/dict-ja-fr0.json": { 猫: "chat", 水: "eau" },
};

const fetchMock = (url: string) => {
// the test mock builds URLs as `chrome-extension://test/${path}`; with the
// leading slash in our paths that yields a double slash — normalize it
const path = String(url).replace("chrome-extension://test", "").replace(/^\/\//, "/");
const data = PACK_FILES[path];
if (data === undefined) {
return { ok: false } as Response;
}
return { ok: true, json: async () => data } as Response;
};

beforeEach(() => {
global.chrome = new Chrome() as any;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => fetchMock(url)),
);
});

test("getPacks reads the generated manifest", async () => {
const dict = await import("../../../src/options/logic/dict");
const packs = await dict.getPacks();
expect(packs.map((p) => p.id)).toEqual(["en-ja", "en-fr", "ja-fr"]);
});

test("getPacks falls back to the single-dictionary layout without a manifest", async () => {
const saved = PACK_FILES["/data/packs.json"];
delete PACK_FILES["/data/packs.json"];
const dict = await import("../../../src/options/logic/dict");
const packs = await dict.getPacks();
expect(packs).toEqual([{ id: "en-ja", metaFile: "/data/dict.json" }]);
// registration still works with the fallback registry
const count = await dict.registerDefaultDict(() => {});
expect(count).toBe(3);
PACK_FILES["/data/packs.json"] = saved;
});

test("getPacks falls back when fetching the manifest throws", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("Failed to fetch");
}),
);
const dict = await import("../../../src/options/logic/dict");
expect(await dict.getPacks()).toEqual([{ id: "en-ja", metaFile: "/data/dict.json" }]);
// restore the file-based mock for subsequent tests
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => fetchMock(url)),
);
});

test("registerPacks merges descriptions when two packs share a headword", async () => {
const dict = await import("../../../src/options/logic/dict");
await dict.registerPacks(["en-ja", "en-fr"], () => {});

const stored = await global.chrome.storage.local.get(["cat", "water", "dog", "constructor"]);
expect(stored.cat).toBe("キャット / chat");
expect(stored.water).toBe("eau");
expect(stored.dog).toBe("イヌ");
// "constructor" is a real English headword in both packs; the lookup must
// not fall through to Object.prototype.constructor (regression: crash on
// first-run registration when storage was empty).
expect(stored.constructor).toBe("構築者 / constructeur");
});

test("registerPacks ignores unknown pack ids", async () => {
const dict = await import("../../../src/options/logic/dict");
const count = await dict.registerPacks(["en-ja", "no-such-pack"], () => {});
expect(count).toBe(3);
});

test("syncInstalledPacks registers new packs and removes unselected ones", async () => {
const dict = await import("../../../src/options/logic/dict");

await dict.syncInstalledPacks(["en-ja", "ja-fr"], () => {});
let stored = await global.chrome.storage.local.get(["cat", "猫"]);
expect(stored.cat).toBe("キャット");
expect(stored["猫"]).toBe("chat");

Check notice on line 108 in __test__/options/logic/dict.test.ts

View workflow job for this annotation

GitHub Actions / build (26.x)

lint/complexity/useLiteralKeys

The computed expression can be simplified without the use of a string literal.

// Drop ja-fr: its keys are removed, en-ja keys are re-registered.
await dict.syncInstalledPacks(["en-ja"], () => {});
stored = await global.chrome.storage.local.get(["cat", "猫"]);
expect(stored["猫"]).toBeUndefined();

Check notice on line 113 in __test__/options/logic/dict.test.ts

View workflow job for this annotation

GitHub Actions / build (26.x)

lint/complexity/useLiteralKeys

The computed expression can be simplified without the use of a string literal.
expect(stored.cat).toBe("キャット");
});

test("syncInstalledPacks survives a stale id whose pack no longer exists", async () => {
const dict = await import("../../../src/options/logic/dict");
await dict.syncInstalledPacks(["en-ja"], () => {});
await global.chrome.storage.local.set({ "***** dict_packs *****": ["ghost"] });
const res = await dict.syncInstalledPacks(["en-ja"], () => {});
expect(res.registered).toBe(3);
});
89 changes: 89 additions & 0 deletions __test__/options/logic/packs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/)
* Copyright 2018-present wtetsu
* Licensed under MIT
*/

import { describe, expect, test } from "vitest";
import {
DEFAULT_PACK_IDS,
FALLBACK_PACKS,
mergeDescriptions,
packIdsFromSettings,
} from "../../../src/options/logic/packs";

const REGISTRY = [
{ id: "en-ja", metaFile: "/data/dict.json" },
{ id: "en-fr", metaFile: "/data/dict-en-fr.json" },
{ id: "de-fr", metaFile: "/data/dict-de-fr.json" },
];

describe("mergeDescriptions", () => {
test("returns incoming when there is no existing value", () => {
expect(mergeDescriptions(undefined, "value")).toBe("value");
});

test("returns existing when incoming is empty", () => {
expect(mergeDescriptions("existing", "")).toBe("existing");
});

test("returns existing when identical", () => {
expect(mergeDescriptions("same", "same")).toBe("same");
});

test("joins different descriptions with separator", () => {
expect(mergeDescriptions("AAA", "BBB")).toBe("AAA / BBB");
});

test("skips incoming when it is already contained in existing", () => {
expect(mergeDescriptions("AAA / BBB", "BBB")).toBe("AAA / BBB");
});

test("prefers incoming when it contains existing entirely", () => {
expect(mergeDescriptions("BBB", "AAA / BBB")).toBe("AAA / BBB");
});

test("treats non-string values (e.g. Object.prototype leak) as absent", () => {
expect(mergeDescriptions((() => {}) as unknown as string, "real")).toBe("real");
expect(mergeDescriptions("real", undefined)).toBe("real");
});
});

describe("packIdsFromSettings", () => {
test("undefined selection falls back to defaults present in the registry", () => {
expect(packIdsFromSettings(REGISTRY, undefined)).toEqual(DEFAULT_PACK_IDS);
});

test("empty selection falls back to defaults", () => {
expect(packIdsFromSettings(REGISTRY, [])).toEqual(DEFAULT_PACK_IDS);
});

test("unknown ids are ignored, falling back to defaults", () => {
expect(packIdsFromSettings(REGISTRY, ["no-such-pack"])).toEqual(DEFAULT_PACK_IDS);
});

test("valid ids pass through in order", () => {
expect(packIdsFromSettings(REGISTRY, ["de-fr", "en-ja"])).toEqual(["de-fr", "en-ja"]);
});

test("invalid ids are filtered out, valid ones kept", () => {
expect(packIdsFromSettings(REGISTRY, ["en-fr", "bogus"])).toEqual(["en-fr"]);
});

test("defaults missing from the registry fall back to the first available pack", () => {
const registry = [{ id: "xx-yy", metaFile: "/data/dict-xx-yy.json" }];
expect(packIdsFromSettings(registry, undefined)).toEqual(["xx-yy"]);
});
});

describe("FALLBACK_PACKS", () => {
test("describes the historical single-dictionary layout", () => {
expect(FALLBACK_PACKS).toEqual([{ id: "en-ja", metaFile: "/data/dict.json" }]);
});

test("default pack ids exist in the fallback registry", () => {
for (const id of DEFAULT_PACK_IDS) {
expect(FALLBACK_PACKS.some((p) => p.id === id)).toBe(true);
}
});
});
2 changes: 1 addition & 1 deletion data/manifest/manifest-chrome.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
},
"web_accessible_resources": [
{
"resources": ["data/rule.json", "data/dict*.json"],
"resources": ["data/rule.json", "data/packs.json", "data/dict*.json"],
"matches": ["<all_urls>"]
}
]
Expand Down
1 change: 1 addition & 0 deletions src/main/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const sync = {
const local = {
get: (keys?: Keys): Promise<Items> => ext().storage.local.get(keys),
set: (items: Items): Promise<void> => ext().storage.local.set(items),
remove: (keys: string[]): Promise<void> => ext().storage.local.remove(keys),
async pick<T = unknown>(key: string): Promise<T | undefined> {
const data = await local.get([key]);
return data?.[key] as T | undefined;
Expand Down
5 changes: 3 additions & 2 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const defaultSettings: MouseDictionarySettings = {
parseWordsLimit: 8,
lookupWithCapitalized: false,
initialPosition: "right",
dictionaryPacks: ["en-ja"],
skipPdfConfirmation: false,
pdfUrl: "",
backgroundColor: "#ffffff",
Expand Down Expand Up @@ -83,15 +84,15 @@ const defaultSettings: MouseDictionarySettings = {
</span>
<span style="cursor:pointer;visibility:hidden;" data-md-pronunciation="{{head}}" data-md-hovervisible="true">🔊</span>
<br/>
<span style="font-size:{{descFontSize}};color:{{descFontColor}};">
<span style="font-size:{{descFontSize}};color:{{descFontColor}};unicode-bidi:plaintext;">
{{{desc}}}
</span>
{{/isShort}}
{{#isShort}}
<span style="font-size:{{headFontSize}};color:{{headFontColor}};font-weight:bold;font-family:Georgia;">
{{head}}
</span>
<span style="color:#505050;font-size:x-small;">
<span style="color:#505050;font-size:x-small;unicode-bidi:plaintext;">
{{shortDesc}}
</span>
{{/isShort}}
Expand Down
1 change: 1 addition & 0 deletions src/main/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type MouseDictionaryBasicSettings = {
width: number;
height: number;
skipPdfConfirmation: boolean;
dictionaryPacks: string[];
};

export type MouseDictionaryAdvancedSettings = {
Expand Down
61 changes: 61 additions & 0 deletions src/options/component/organism/DictionaryPacks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/)
* Copyright 2018-present wtetsu
* Licensed under MIT
*/

import { useEffect, useState } from "react";
import { res } from "../../logic";
import type { DictionaryPack } from "../../types";
import { Button } from "../atom/Button";

type Props = {
busy: boolean;
packs: DictionaryPack[];
selectedPackIds: string[];
onSync: (packIds: string[]) => void;
};

export const DictionaryPacks: React.FC<Props> = (props) => {
const [selected, setSelected] = useState<Set<string>>(new Set(props.selectedPackIds));

useEffect(() => {
setSelected(new Set(props.selectedPackIds));
}, [props.selectedPackIds]);

const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelected(next);
};

const dirty = selected.size !== props.selectedPackIds.length || props.selectedPackIds.some((id) => !selected.has(id));

return (
<div style={{ marginTop: 10 }}>
<div>
{props.packs.map((pack) => (
<label key={pack.id} style={{ marginRight: 16, cursor: "pointer", fontSize: "90%" }}>
<input
type="checkbox"
checked={selected.has(pack.id)}
disabled={props.busy}
onChange={() => toggle(pack.id)}
/>{" "}
{pack.label ?? pack.id}
</label>
))}
</div>
<Button
type="primary"
text={res.get("applyDictionaryPacks")}
disabled={props.busy || !dirty || props.packs.length === 0}
onClick={() => props.onSync(Array.from(selected))}
/>
</div>
);
};
1 change: 1 addition & 0 deletions src/options/component/organism/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

export * from "./AdvancedSettings";
export * from "./BasicSettings";
export * from "./DictionaryPacks";
export * from "./LoadDictionary";
export * from "./OperationPanel";
export * from "./ReplaceRuleEditor";
Expand Down
Loading
Loading