diff --git a/locale/fr.json b/locale/fr.json index e42bcda..f1352ec 100755 --- a/locale/fr.json +++ b/locale/fr.json @@ -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" } diff --git a/src/__tests__/__mocks__/l10n.ts b/src/__tests__/__mocks__/l10n.ts new file mode 100644 index 0000000..9ce37b4 --- /dev/null +++ b/src/__tests__/__mocks__/l10n.ts @@ -0,0 +1,3 @@ +export default function t(str: string): string { + return str; +} diff --git a/src/__tests__/__mocks__/obsidian.ts b/src/__tests__/__mocks__/obsidian.ts new file mode 100644 index 0000000..ff738d3 --- /dev/null +++ b/src/__tests__/__mocks__/obsidian.ts @@ -0,0 +1,43 @@ +// Minimal Obsidian API mock for unit tests + +// moment is a global in Obsidian; provide a minimal stub +(globalThis as Record).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 { + app: unknown; + constructor(app: unknown) { this.app = app; } + open() {} + close() {} +} +export class SuggestModal { + 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; } +} diff --git a/src/__tests__/executeMacro.test.ts b/src/__tests__/executeMacro.test.ts new file mode 100644 index 0000000..07d39ef --- /dev/null +++ b/src/__tests__/executeMacro.test.ts @@ -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; + 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; + 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; + expect(spy.mock.calls.map((c: string[]) => c[0])).toEqual(["cmd-1", "cmd-2"]); + }); +}); diff --git a/src/__tests__/injectIcons.test.ts b/src/__tests__/injectIcons.test.ts new file mode 100644 index 0000000..308b957 --- /dev/null +++ b/src/__tests__/injectIcons.test.ts @@ -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): 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"); + }); +}); diff --git a/src/__tests__/isModeActive.test.ts b/src/__tests__/isModeActive.test.ts new file mode 100644 index 0000000..c726840 --- /dev/null +++ b/src/__tests__/isModeActive.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/locales.test.ts b/src/__tests__/locales.test.ts new file mode 100644 index 0000000..683ae6a --- /dev/null +++ b/src/__tests__/locales.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..e4a67c1 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,2 @@ +(globalThis as Record).moment = { locale: () => "en" }; +export {}; diff --git a/src/__tests__/styles.test.ts b/src/__tests__/styles.test.ts new file mode 100644 index 0000000..c1006f9 --- /dev/null +++ b/src/__tests__/styles.test.ts @@ -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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7f17458 --- /dev/null +++ b/vitest.config.ts @@ -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"), + }, + ], + }, +});