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:
johnny1093 2026-07-11 09:13:56 -04:00
parent 3bd5395f09
commit b3af074174
10 changed files with 415 additions and 2 deletions

View file

@ -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"
}

View file

@ -0,0 +1,3 @@
export default function t(str: string): string {
return str;
}

View 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; }
}

View 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"]);
});
});

View 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");
});
});

View 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);
});
});

View 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
View file

@ -0,0 +1,2 @@
(globalThis as Record<string, unknown>).moment = { locale: () => "en" };
export {};

View 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
View 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"),
},
],
},
});