mirror of
https://github.com/jsmorabito/obsidian-commander.git
synced 2026-07-22 06:40:31 +00:00
test: add vitest suite for macros, icons, modes, styles, and locales
Adds coverage for executeMacro, injectIcons, isModeActive, updateStyles/ removeStyles, and locale completeness, plus the Obsidian API mocks and setup needed to run them under jsdom. Updates a stale fr.json key to match the current English source string.
This commit is contained in:
parent
3bd5395f09
commit
b3af074174
10 changed files with 415 additions and 2 deletions
|
|
@ -69,9 +69,9 @@
|
|||
"Hide other Commands": "Masquer les autres commandes",
|
||||
"Double click to enter custom value": "Double-cliquez pour entrer une valeur personnalisée",
|
||||
"Choose custom spacing for Command Buttons": "Choisissez un espacement personnalisé pour les boutons de commande",
|
||||
"Change the spacing between commands.": "Modifier l'espacement entre les commandes.",
|
||||
"Change the spacing between commands. You can set different values on mobile and desktop.": "Modifier l'espacement entre les commandes.",
|
||||
"Warning": "Avertissement",
|
||||
"As of Obsidian 0.16.0 you need to explicitly enable the Tab Title Bar. Once enabled, you might need to restart Obsidian.": "À partir d'Obsidian 0.16.0, vous devez activer explicitement la barre de titre des onglets. Une fois activée, il se peut que vous deviez redémarrer Obsidian.",
|
||||
"As of Obsidian 0.16.0 you need to explicitly enable the View Header.": "À partir d'Obsidian 0.16.0, vous devez activer explicitement la barre de titre des onglets. Une fois activée, il se peut que vous deviez redémarrer Obsidian.",
|
||||
"Open Appearance Settings": "Ouvrir les paramètres d'apparence",
|
||||
"Explorer": "Explorateur"
|
||||
}
|
||||
|
|
|
|||
3
src/__tests__/__mocks__/l10n.ts
Normal file
3
src/__tests__/__mocks__/l10n.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function t(str: string): string {
|
||||
return str;
|
||||
}
|
||||
43
src/__tests__/__mocks__/obsidian.ts
Normal file
43
src/__tests__/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Minimal Obsidian API mock for unit tests
|
||||
|
||||
// moment is a global in Obsidian; provide a minimal stub
|
||||
(globalThis as Record<string, unknown>).moment = { locale: () => "en" };
|
||||
|
||||
export const Platform = {
|
||||
isDesktop: true,
|
||||
isMobile: false,
|
||||
};
|
||||
|
||||
export function setIcon(el: HTMLElement, icon: string): void {
|
||||
el.setAttribute("data-icon", icon);
|
||||
}
|
||||
|
||||
export function requireApiVersion(_version: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getIconIds(): string[] {
|
||||
return ["check", "x", "star", "home"];
|
||||
}
|
||||
|
||||
export class Plugin {}
|
||||
export class Modal {}
|
||||
export class Setting {}
|
||||
export class FuzzySuggestModal<T> {
|
||||
app: unknown;
|
||||
constructor(app: unknown) { this.app = app; }
|
||||
open() {}
|
||||
close() {}
|
||||
}
|
||||
export class SuggestModal<T> {
|
||||
app: unknown;
|
||||
constructor(app: unknown) { this.app = app; }
|
||||
open() {}
|
||||
close() {}
|
||||
}
|
||||
export class PluginSettingTab {
|
||||
app: unknown;
|
||||
plugin: unknown;
|
||||
containerEl: HTMLElement = document.createElement("div");
|
||||
constructor(app: unknown, plugin: unknown) { this.app = app; this.plugin = plugin; }
|
||||
}
|
||||
89
src/__tests__/executeMacro.test.ts
Normal file
89
src/__tests__/executeMacro.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Action } from "../types";
|
||||
import type { Macro } from "../types";
|
||||
import type CommanderPlugin from "../main";
|
||||
import CommanderPluginClass from "../main";
|
||||
|
||||
function buildPlugin(macros: Macro[]): CommanderPlugin {
|
||||
const executeCommandById = vi.fn();
|
||||
const plugin = {
|
||||
settings: { macros },
|
||||
app: {
|
||||
commands: { executeCommandById },
|
||||
},
|
||||
} as unknown as CommanderPlugin;
|
||||
plugin.executeMacro = CommanderPluginClass.prototype.executeMacro.bind(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
describe("executeMacro", () => {
|
||||
it("throws when macro id does not exist", async () => {
|
||||
const plugin = buildPlugin([]);
|
||||
await expect(plugin.executeMacro(99)).rejects.toThrow("Macro not found");
|
||||
});
|
||||
|
||||
it("executes COMMAND actions by calling executeCommandById", async () => {
|
||||
const macro: Macro = {
|
||||
name: "test",
|
||||
icon: "check",
|
||||
macro: [
|
||||
{ action: Action.COMMAND, commandId: "editor:undo" },
|
||||
{ action: Action.COMMAND, commandId: "editor:redo" },
|
||||
],
|
||||
};
|
||||
const plugin = buildPlugin([macro]);
|
||||
await plugin.executeMacro(0);
|
||||
const spy = plugin.app.commands.executeCommandById as ReturnType<typeof vi.fn>;
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(spy).toHaveBeenNthCalledWith(1, "editor:undo");
|
||||
expect(spy).toHaveBeenNthCalledWith(2, "editor:redo");
|
||||
});
|
||||
|
||||
it("executes LOOP action the correct number of times", async () => {
|
||||
const macro: Macro = {
|
||||
name: "loop-test",
|
||||
icon: "star",
|
||||
macro: [{ action: Action.LOOP, times: 3, commandId: "some:command" }],
|
||||
};
|
||||
const plugin = buildPlugin([macro]);
|
||||
await plugin.executeMacro(0);
|
||||
const spy = plugin.app.commands.executeCommandById as ReturnType<typeof vi.fn>;
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("handles DELAY action without throwing", async () => {
|
||||
const macro: Macro = {
|
||||
name: "delay-test",
|
||||
icon: "home",
|
||||
macro: [{ action: Action.DELAY, delay: 0 }],
|
||||
};
|
||||
const plugin = buildPlugin([macro]);
|
||||
await expect(plugin.executeMacro(0)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles EDITOR action without throwing", async () => {
|
||||
const macro: Macro = {
|
||||
name: "editor-test",
|
||||
icon: "home",
|
||||
macro: [{ action: Action.EDITOR }],
|
||||
};
|
||||
const plugin = buildPlugin([macro]);
|
||||
await expect(plugin.executeMacro(0)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("executes mixed action sequence in correct order", async () => {
|
||||
const macro: Macro = {
|
||||
name: "mixed",
|
||||
icon: "check",
|
||||
macro: [
|
||||
{ action: Action.COMMAND, commandId: "cmd-1" },
|
||||
{ action: Action.DELAY, delay: 0 },
|
||||
{ action: Action.COMMAND, commandId: "cmd-2" },
|
||||
],
|
||||
};
|
||||
const plugin = buildPlugin([macro]);
|
||||
await plugin.executeMacro(0);
|
||||
const spy = plugin.app.commands.executeCommandById as ReturnType<typeof vi.fn>;
|
||||
expect(spy.mock.calls.map((c: string[]) => c[0])).toEqual(["cmd-1", "cmd-2"]);
|
||||
});
|
||||
});
|
||||
68
src/__tests__/injectIcons.test.ts
Normal file
68
src/__tests__/injectIcons.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { injectIcons } from "../util";
|
||||
import type CommanderPlugin from "../main";
|
||||
import type { AdvancedToolbarSettings } from "../types";
|
||||
|
||||
function makePlugin(commands: Record<string, { icon?: string }>): CommanderPlugin {
|
||||
return {
|
||||
app: { commands: { commands } },
|
||||
} as unknown as CommanderPlugin;
|
||||
}
|
||||
|
||||
function makeSettings(
|
||||
mappedIcons: AdvancedToolbarSettings["mappedIcons"]
|
||||
): AdvancedToolbarSettings {
|
||||
return {
|
||||
rowHeight: 48,
|
||||
rowCount: 1,
|
||||
spacing: 0,
|
||||
buttonWidth: 48,
|
||||
columnLayout: false,
|
||||
mappedIcons,
|
||||
tooltips: false,
|
||||
heightOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("injectIcons", () => {
|
||||
it("sets the icon on a matching command", () => {
|
||||
const commands = { "my:command": { icon: "old-icon" } };
|
||||
const settings = makeSettings([{ iconID: "star", commandID: "my:command" }]);
|
||||
injectIcons(settings, makePlugin(commands));
|
||||
expect(commands["my:command"].icon).toBe("star");
|
||||
});
|
||||
|
||||
it("removes mappedIcon entry when command does not exist", () => {
|
||||
const commands = {};
|
||||
const settings = makeSettings([{ iconID: "star", commandID: "missing:command" }]);
|
||||
injectIcons(settings, makePlugin(commands));
|
||||
expect(settings.mappedIcons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves unrelated mapped icons intact", () => {
|
||||
const commands = {
|
||||
"cmd-a": { icon: "" },
|
||||
"cmd-b": { icon: "" },
|
||||
};
|
||||
const settings = makeSettings([
|
||||
{ iconID: "check", commandID: "cmd-a" },
|
||||
{ iconID: "x", commandID: "cmd-b" },
|
||||
]);
|
||||
injectIcons(settings, makePlugin(commands));
|
||||
expect(commands["cmd-a"].icon).toBe("check");
|
||||
expect(commands["cmd-b"].icon).toBe("x");
|
||||
expect(settings.mappedIcons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only prunes the missing command and keeps valid ones", () => {
|
||||
const commands = { "exists:cmd": { icon: "" } };
|
||||
const settings = makeSettings([
|
||||
{ iconID: "star", commandID: "missing:cmd" },
|
||||
{ iconID: "home", commandID: "exists:cmd" },
|
||||
]);
|
||||
injectIcons(settings, makePlugin(commands));
|
||||
expect(settings.mappedIcons).toHaveLength(1);
|
||||
expect(settings.mappedIcons[0].commandID).toBe("exists:cmd");
|
||||
expect(commands["exists:cmd"].icon).toBe("home");
|
||||
});
|
||||
});
|
||||
43
src/__tests__/isModeActive.test.ts
Normal file
43
src/__tests__/isModeActive.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { isModeActive } from "../util";
|
||||
import type CommanderPlugin from "../main";
|
||||
|
||||
function makePlugin(isMobile: boolean, appId = "desktop-app"): CommanderPlugin {
|
||||
return {
|
||||
app: { isMobile, appId },
|
||||
} as unknown as CommanderPlugin;
|
||||
}
|
||||
|
||||
describe("isModeActive", () => {
|
||||
it('returns true for mode "any" on desktop', () => {
|
||||
expect(isModeActive("any", makePlugin(false))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mode "any" on mobile', () => {
|
||||
expect(isModeActive("any", makePlugin(true))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mode "desktop" when not mobile', () => {
|
||||
expect(isModeActive("desktop", makePlugin(false))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for mode "desktop" when on mobile', () => {
|
||||
expect(isModeActive("desktop", makePlugin(true))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for mode "mobile" when on mobile', () => {
|
||||
expect(isModeActive("mobile", makePlugin(true))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for mode "mobile" when on desktop', () => {
|
||||
expect(isModeActive("mobile", makePlugin(false))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when mode matches the appId", () => {
|
||||
expect(isModeActive("my-vault", makePlugin(false, "my-vault"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when mode does not match appId and is not any/mobile/desktop", () => {
|
||||
expect(isModeActive("other-vault", makePlugin(false, "my-vault"))).toBe(false);
|
||||
});
|
||||
});
|
||||
60
src/__tests__/locales.test.ts
Normal file
60
src/__tests__/locales.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const LOCALE_DIR = path.resolve(__dirname, "../../locale");
|
||||
|
||||
function loadJson(file: string): Record<string, string> {
|
||||
return JSON.parse(readFileSync(path.join(LOCALE_DIR, file), "utf-8"));
|
||||
}
|
||||
|
||||
// Extract all {{placeholder}} tokens from a string
|
||||
function placeholders(str: string): string[] {
|
||||
return [...str.matchAll(/\{\{[^}]+\}\}/g)].map((m) => m[0]).sort();
|
||||
}
|
||||
|
||||
const en = loadJson("en.json");
|
||||
const enKeys = Object.keys(en);
|
||||
|
||||
const localeFiles = readdirSync(LOCALE_DIR).filter(
|
||||
(f) => f.endsWith(".json") && f !== "en.json"
|
||||
);
|
||||
|
||||
// Only validate locales that have been filled in (not empty stubs)
|
||||
const filledLocales = localeFiles.filter((f) => {
|
||||
const data = loadJson(f);
|
||||
return Object.keys(data).length > 0;
|
||||
});
|
||||
|
||||
describe("locale completeness (non-empty locales only)", () => {
|
||||
it.each(filledLocales)("%s has no missing keys", (file) => {
|
||||
const data = loadJson(file);
|
||||
const missing = enKeys.filter((k) => !(k in data));
|
||||
expect(missing, `Missing keys in ${file}`).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(filledLocales)("%s has no extra keys not in en.json", (file) => {
|
||||
const data = loadJson(file);
|
||||
const extra = Object.keys(data).filter((k) => !(k in en));
|
||||
expect(extra, `Extra keys in ${file}`).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(filledLocales)("%s preserves all {{placeholders}}", (file) => {
|
||||
const data = loadJson(file);
|
||||
const mismatches: string[] = [];
|
||||
for (const key of enKeys) {
|
||||
if (!(key in data)) continue;
|
||||
const expected = placeholders(en[key]);
|
||||
const actual = placeholders(data[key]);
|
||||
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
||||
mismatches.push(
|
||||
`"${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(mismatches, `Placeholder mismatches in ${file}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
2
src/__tests__/setup.ts
Normal file
2
src/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
(globalThis as Record<string, unknown>).moment = { locale: () => "en" };
|
||||
export {};
|
||||
79
src/__tests__/styles.test.ts
Normal file
79
src/__tests__/styles.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { updateStyles, removeStyles } from "../util";
|
||||
import type { AdvancedToolbarSettings } from "../types";
|
||||
|
||||
const defaultSettings: AdvancedToolbarSettings = {
|
||||
rowHeight: 48,
|
||||
rowCount: 1,
|
||||
spacing: 4,
|
||||
buttonWidth: 48,
|
||||
columnLayout: false,
|
||||
mappedIcons: [],
|
||||
tooltips: false,
|
||||
heightOffset: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.className = "";
|
||||
document.body.removeAttribute("style");
|
||||
});
|
||||
|
||||
describe("updateStyles", () => {
|
||||
it("sets CSS custom properties on document.body", () => {
|
||||
updateStyles(defaultSettings);
|
||||
const s = document.body.style;
|
||||
expect(s.getPropertyValue("--at-button-height")).toBe("48px");
|
||||
expect(s.getPropertyValue("--at-button-width")).toBe("48px");
|
||||
expect(s.getPropertyValue("--at-row-count")).toBe("1");
|
||||
expect(s.getPropertyValue("--at-spacing")).toBe("4px");
|
||||
expect(s.getPropertyValue("--at-offset")).toBe("0px");
|
||||
});
|
||||
|
||||
it("adds AT-row class when columnLayout is false", () => {
|
||||
updateStyles({ ...defaultSettings, columnLayout: false });
|
||||
expect(document.body.classList.contains("AT-row")).toBe(true);
|
||||
expect(document.body.classList.contains("AT-column")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds AT-column class when columnLayout is true", () => {
|
||||
updateStyles({ ...defaultSettings, columnLayout: true });
|
||||
expect(document.body.classList.contains("AT-column")).toBe(true);
|
||||
expect(document.body.classList.contains("AT-row")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds AT-multirow when rowCount > 1", () => {
|
||||
updateStyles({ ...defaultSettings, rowCount: 3 });
|
||||
expect(document.body.classList.contains("AT-multirow")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add AT-multirow when rowCount === 1", () => {
|
||||
updateStyles(defaultSettings);
|
||||
expect(document.body.classList.contains("AT-multirow")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds AT-no-toolbar when rowCount === 0", () => {
|
||||
updateStyles({ ...defaultSettings, rowCount: 0 });
|
||||
expect(document.body.classList.contains("AT-no-toolbar")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStyles", () => {
|
||||
it("removes CSS custom properties set by updateStyles", () => {
|
||||
updateStyles(defaultSettings);
|
||||
removeStyles();
|
||||
const s = document.body.style;
|
||||
expect(s.getPropertyValue("--at-button-height")).toBe("");
|
||||
expect(s.getPropertyValue("--at-button-width")).toBe("");
|
||||
expect(s.getPropertyValue("--at-row-count")).toBe("");
|
||||
expect(s.getPropertyValue("--at-spacing")).toBe("");
|
||||
expect(s.getPropertyValue("--at-offset")).toBe("");
|
||||
});
|
||||
|
||||
it("removes layout classes set by updateStyles", () => {
|
||||
updateStyles({ ...defaultSettings, rowCount: 2, columnLayout: true });
|
||||
removeStyles();
|
||||
expect(document.body.classList.contains("AT-multirow")).toBe(false);
|
||||
expect(document.body.classList.contains("AT-row")).toBe(false);
|
||||
expect(document.body.classList.contains("AT-column")).toBe(false);
|
||||
});
|
||||
});
|
||||
26
vitest.config.ts
Normal file
26
vitest.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: false,
|
||||
setupFiles: ["src/__tests__/setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "obsidian",
|
||||
replacement: path.resolve(__dirname, "src/__tests__/__mocks__/obsidian.ts"),
|
||||
},
|
||||
{
|
||||
find: /.*\/l10n$/,
|
||||
replacement: path.resolve(__dirname, "src/__tests__/__mocks__/l10n.ts"),
|
||||
},
|
||||
{
|
||||
find: "src",
|
||||
replacement: path.resolve(__dirname, "src"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue