Narrow service and command composition

This commit is contained in:
vorotamoroz committed 2026-10-29 15:00:19 +00:00
1 parent 273079e827
commit 4027417bf5
22 files changed
+928 -202

No files matched your search

@@ -0,0 +1,227 @@
import { describe, expect, it, vi } from "vitest";
const liveSyncSettingHarness = vi.hoisted(() => ({
components: [] as unknown[],
settingElements: [] as unknown[],
}));
vi.mock("@/deps.ts", () => {
class StringComponent {
values: string[] = [];
changeHandler?: (value: string) => Promise<void>;
placeholder?: string;
inputEl = {
attributes: new Map<string, string>(),
setAttribute(name: string, value: string) {
this.attributes.set(name, value);
},
toggleClass: (_name: string, _enabled: boolean) => {},
};
setValue(value: string) {
this.values.push(value);
}
onChange(handler: (value: string) => Promise<void>) {
this.changeHandler = handler;
return this;
}
setPlaceholder(value: string) {
this.placeholder = value;
return this;
}
constructor() {
liveSyncSettingHarness.components.push(this);
}
}
class TextComponent extends StringComponent {}
class TextAreaComponent extends StringComponent {}
class Setting {
settingEl = {
classes: [] as { name: string; enabled: boolean }[],
toggleClass(name: string, enabled: boolean) {
this.classes.push({ name, enabled });
},
};
constructor(_containerEl: HTMLElement) {
liveSyncSettingHarness.settingElements.push(this.settingEl);
}
setDesc(_description: string | DocumentFragment) {
return this;
}
setName(_name: string | DocumentFragment) {
return this;
}
addText(callback: (component: TextComponent) => void) {
callback(new TextComponent());
return this;
}
addTextArea(callback: (component: TextAreaComponent) => void) {
callback(new TextAreaComponent());
return this;
}
setTooltip(_description: string) {
return this;
}
setDisabled(_disabled: boolean) {
return this;
}
}
return { Setting, TextComponent, TextAreaComponent, ButtonComponent: class {} };
});
vi.mock("./ObsidianLiveSyncSettingTab.ts", () => ({}));
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
LEVEL_ADVANCED: 1,
LEVEL_POWER_USER: 2,
statusDisplay: () => "",
}));
vi.mock("./settingConstants.ts", () => ({
getConfig: (key: string) => ({
name: key,
desc: `description for ${key}`,
placeHolder: `configured placeholder for ${key}`,
}),
}));
vi.mock("./settingComponentStyles.ts", () => ({ setButtonDestructiveState: vi.fn() }));
vi.mock("octagonal-wheels/collection", () => ({ unique: (items: unknown[]) => [...new Set(items)] }));
import { LiveSyncSetting } from "./LiveSyncSetting.ts";
type WireKind = "text" | "textarea";
type StringComponentHarness = {
values: string[];
changeHandler?: (value: string) => Promise<void>;
placeholder?: string;
inputEl: { attributes: Map<string, string> };
};
type SettingElementHarness = { classes: { name: string; enabled: boolean }[] };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function wireStringSetting(
kind: WireKind,
options: {
holdValue?: boolean;
isPassword?: boolean;
placeHolder?: string;
onUpdate?: () => { visibility?: boolean };
} = {}
) {
const key = kind === "text" ? "syncOnlyRegEx" : "ignoreFiles";
const events: string[] = [];
const editingSettings: Record<string, unknown> = { [key]: 17 };
const saveSettings = vi.fn(async (_keys: string[]) => {});
const requestUpdate = vi.fn(() => events.push("update"));
const env = {
settingComponents: [] as unknown[],
editingSettings,
saveSettings,
requestUpdate,
};
LiveSyncSetting.env = env as never;
const setting = new LiveSyncSetting({} as HTMLElement);
const onUpdate = vi.fn(options.onUpdate ?? (() => ({ visibility: false })));
const wireOptions = { ...options, onUpdate };
if (kind === "text") {
setting.autoWireText(key as never, wireOptions);
} else {
setting.autoWireTextArea(key as never, wireOptions);
}
return {
key,
events,
editingSettings,
saveSettings,
requestUpdate,
setting,
component: setting.autoWiredComponent as unknown as StringComponentHarness,
settingElement: setting.settingEl as unknown as SettingElementHarness,
onUpdate,
};
}
describe("LiveSyncSetting string controls", () => {
it.each(["text", "textarea"] as const)("wires %s through the shared string commit contract", async (kind) => {
const {
key,
events,
editingSettings,
saveSettings,
requestUpdate,
setting,
component,
settingElement,
onUpdate,
} = wireStringSetting(kind, { isPassword: true, placeHolder: "option placeholder" });
expect(component.values).toEqual(["17"]);
setting.invalidateValue?.();
expect(component.values).toEqual(["17"]);
editingSettings[key] = 18;
setting.invalidateValue?.();
expect(component.values).toEqual(["17", "18"]);
expect(component.inputEl.attributes.get("type")).toBe("password");
expect(setting.hasPassword).toBe(true);
expect(setting.updateHandlers.size).toBe(1);
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(setting._getComputedStatus()).toEqual({ visibility: false });
expect(onUpdate).toHaveBeenCalledTimes(2);
expect(settingElement.classes).toContainEqual({ name: "sls-setting-hidden", enabled: true });
if (kind === "text") {
expect(component.placeholder).toBe("configured placeholder for syncOnlyRegEx");
} else {
expect(component.placeholder).toBeUndefined();
}
const pendingSave = deferred();
saveSettings.mockImplementationOnce(async (keys) => {
events.push(`save:${keys.join(",")}`);
await pendingSave.promise;
events.push("saved");
});
const commit = component.changeHandler!("changed");
expect(editingSettings[key]).toBe("changed");
expect(saveSettings).toHaveBeenCalledWith([key]);
expect(events).toEqual([`save:${key}`]);
expect(requestUpdate).not.toHaveBeenCalled();
pendingSave.resolve();
await commit;
expect(events).toEqual([`save:${key}`, "saved", "update"]);
events.length = 0;
await component.changeHandler!("changed");
expect(saveSettings).toHaveBeenCalledTimes(1);
expect(events).toEqual(["update"]);
events.length = 0;
const failure = new Error("setting save failed");
saveSettings.mockImplementationOnce(async (keys) => {
events.push(`save:${keys.join(",")}`);
throw failure;
});
await expect(component.changeHandler!("failed")).rejects.toBe(failure);
expect(editingSettings[key]).toBe("failed");
expect(events).toEqual([`save:${key}`]);
expect(requestUpdate).toHaveBeenCalledTimes(2);
});
it.each(["text", "textarea"] as const)("keeps %s held values in the editing copy without saving", async (kind) => {
const { key, editingSettings, saveSettings, requestUpdate, component, events } = wireStringSetting(kind, {
holdValue: true,
});
await component.changeHandler!("held value");
expect(editingSettings[key]).toBe("held value");
expect(saveSettings).not.toHaveBeenCalled();
expect(requestUpdate).toHaveBeenCalledOnce();
expect(events).toEqual(["update"]);
});
});