mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Merge main into shared runtime foundations
Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
commit
bd05dde39c
83 files changed
+6313
-1507
No files matched your search
@@ -13,13 +13,25 @@ import {
|
||||
|
||||
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
|
||||
|
||||
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
|
||||
const count = source.value.couchDB?.pull?.checkedChanges;
|
||||
export type FastForwardCheckedChanges = {
|
||||
push?: number;
|
||||
pull?: number;
|
||||
};
|
||||
|
||||
function readPositiveCheckedChanges(
|
||||
source: ReactiveValue<ReplicationStatics>,
|
||||
direction: keyof FastForwardCheckedChanges
|
||||
): number | undefined {
|
||||
const count = source.value.couchDB?.[direction]?.checkedChanges;
|
||||
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
|
||||
}
|
||||
|
||||
function copyCheckedChanges(counts: FastForwardCheckedChanges): FastForwardCheckedChanges {
|
||||
return { ...counts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Briefly displays changing CouchDB pull-feed comparison counts.
|
||||
* Briefly displays changing CouchDB push- and pull-feed comparison counts.
|
||||
*
|
||||
* Unrelated replication-stat changes do not extend the linger or redisplay an
|
||||
* expired count. A positive initial snapshot is displayed to late subscribers.
|
||||
@@ -27,46 +39,61 @@ function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>):
|
||||
export function createFastForwardCheckedChangesDisplay(
|
||||
source: ReactiveValue<ReplicationStatics>,
|
||||
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
|
||||
): DisposableReactiveValue<number | undefined> {
|
||||
const displayed = reactiveSource<number | undefined>(undefined);
|
||||
): DisposableReactiveValue<FastForwardCheckedChanges> {
|
||||
const displayed = reactiveSource<FastForwardCheckedChanges>({});
|
||||
const linger = Math.max(0, lingerMs);
|
||||
let lastObserved = readPositiveCheckedChanges(source);
|
||||
let clearTimer: number | undefined;
|
||||
const directions: (keyof FastForwardCheckedChanges)[] = ["push", "pull"];
|
||||
const lastObserved: FastForwardCheckedChanges = {};
|
||||
const clearTimers = new Map<keyof FastForwardCheckedChanges, number>();
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
const cancelClear = (direction: keyof FastForwardCheckedChanges) => {
|
||||
const timer = clearTimers.get(direction);
|
||||
if (timer === undefined) return;
|
||||
compatGlobal.clearTimeout(timer);
|
||||
clearTimers.delete(direction);
|
||||
};
|
||||
const scheduleClear = () => {
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
const scheduleClear = (direction: keyof FastForwardCheckedChanges) => {
|
||||
const timer = compatGlobal.setTimeout(() => {
|
||||
clearTimers.delete(direction);
|
||||
if (disposed) return;
|
||||
if (readPositiveCheckedChanges(source) !== lastObserved) {
|
||||
if (readPositiveCheckedChanges(source, direction) !== lastObserved[direction]) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
displayed.value = undefined;
|
||||
const nextDisplayed = copyCheckedChanges(displayed.value);
|
||||
delete nextDisplayed[direction];
|
||||
displayed.value = nextDisplayed;
|
||||
}, linger);
|
||||
clearTimers.set(direction, timer);
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = readPositiveCheckedChanges(source);
|
||||
if (nextCount === lastObserved) return;
|
||||
lastObserved = nextCount;
|
||||
cancelClear();
|
||||
if (nextCount === undefined) {
|
||||
displayed.value = undefined;
|
||||
return;
|
||||
const nextDisplayed = copyCheckedChanges(displayed.value);
|
||||
let changed = false;
|
||||
for (const direction of directions) {
|
||||
const nextCount = readPositiveCheckedChanges(source, direction);
|
||||
if (nextCount === lastObserved[direction]) continue;
|
||||
lastObserved[direction] = nextCount;
|
||||
cancelClear(direction);
|
||||
if (nextCount === undefined) {
|
||||
delete nextDisplayed[direction];
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextDisplayed[direction] = nextCount;
|
||||
scheduleClear(direction);
|
||||
changed = true;
|
||||
}
|
||||
displayed.value = nextCount;
|
||||
scheduleClear();
|
||||
if (changed) displayed.value = nextDisplayed;
|
||||
};
|
||||
|
||||
if (lastObserved !== undefined) {
|
||||
displayed.value = lastObserved;
|
||||
scheduleClear();
|
||||
for (const direction of directions) {
|
||||
const count = readPositiveCheckedChanges(source, direction);
|
||||
if (count === undefined) continue;
|
||||
lastObserved[direction] = count;
|
||||
displayed.value = { ...displayed.value, [direction]: count };
|
||||
scheduleClear(direction);
|
||||
}
|
||||
source.onChanged(update);
|
||||
|
||||
@@ -83,7 +110,7 @@ export function createFastForwardCheckedChangesDisplay(
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
for (const direction of directions) cancelClear(direction);
|
||||
source.offChanged(update);
|
||||
},
|
||||
};
|
||||
@@ -113,7 +140,7 @@ function remainingSequenceLabel(
|
||||
export function projectReplicationStatusLabel(
|
||||
stats: ReplicationStatics,
|
||||
networkStatus: RemoteActivityStatus,
|
||||
fastForwardCount?: number
|
||||
fastForwardCounts?: FastForwardCheckedChanges
|
||||
): ReplicationStatusProjection {
|
||||
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
|
||||
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
|
||||
@@ -154,14 +181,19 @@ export function projectReplicationStatusLabel(
|
||||
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
|
||||
if (isFastForwardChecking) icon = "⏩";
|
||||
|
||||
const visibleFastForwardCount =
|
||||
couchDB !== undefined &&
|
||||
typeof fastForwardCount === "number" &&
|
||||
Number.isFinite(fastForwardCount) &&
|
||||
fastForwardCount > 0
|
||||
? fastForwardCount
|
||||
: undefined;
|
||||
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
|
||||
const visibleFastForwardCounts: FastForwardCheckedChanges = {};
|
||||
if (couchDB !== undefined) {
|
||||
for (const direction of ["push", "pull"] as const) {
|
||||
const count = fastForwardCounts?.[direction];
|
||||
if (typeof count === "number" && Number.isFinite(count) && count > 0) {
|
||||
visibleFastForwardCounts[direction] = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fastForwardLabels = (["push", "pull"] as const)
|
||||
.filter((direction) => visibleFastForwardCounts[direction] !== undefined)
|
||||
.map((direction) => ` (⏩${direction === "push" ? "↑" : "↓"} ${visibleFastForwardCounts[direction]})`);
|
||||
const fastForwardLabel = fastForwardLabels.join("");
|
||||
|
||||
const parts: StatusBarPart[] = [];
|
||||
if (networkStatus.trackedRequestCount > 0) {
|
||||
@@ -177,13 +209,15 @@ export function projectReplicationStatusLabel(
|
||||
parts.push({ key: "network", text: networkActivity });
|
||||
}
|
||||
parts.push({ key: "sync", text: "Sync: " + icon });
|
||||
if (visibleFastForwardCount !== undefined) {
|
||||
for (const direction of ["push", "pull"] as const) {
|
||||
const count = visibleFastForwardCounts[direction];
|
||||
if (count === undefined) continue;
|
||||
parts.push({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: visibleFastForwardCount,
|
||||
key: `ff-${direction}`,
|
||||
text: ` (⏩${direction === "push" ? "↑" : "↓"} `,
|
||||
value: count,
|
||||
suffix: ")",
|
||||
title: "Compared CouchDB feed entries",
|
||||
title: `Compared CouchDB ${direction} feed entries`,
|
||||
});
|
||||
}
|
||||
parts.push({ key: "sent", text: " ↑ ", value: sent });
|
||||
|
||||
@@ -90,18 +90,22 @@ describe("projectReplicationStatusLabel", () => {
|
||||
const idleWithCount = projectReplicationStatusLabel(
|
||||
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
|
||||
noNetworkActivity,
|
||||
19
|
||||
{ push: 19, pull: 19 }
|
||||
);
|
||||
expect(idleWithCount.icon).toBe(icon);
|
||||
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
|
||||
expect(idleWithCount.parts.some((part) => part.key === "ff-push")).toBe(true);
|
||||
expect(idleWithCount.parts.some((part) => part.key === "ff-pull")).toBe(true);
|
||||
|
||||
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
|
||||
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, {
|
||||
push: 19,
|
||||
pull: 19,
|
||||
});
|
||||
expect(withoutCouchDB.icon).toBe(icon);
|
||||
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
expect(withoutCouchDB.parts.some((part) => part.key.startsWith("ff-"))).toBe(false);
|
||||
}
|
||||
|
||||
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
|
||||
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
expect(noFastForward.parts.some((part) => part.key.startsWith("ff-"))).toBe(false);
|
||||
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
|
||||
});
|
||||
|
||||
@@ -123,18 +127,40 @@ describe("projectReplicationStatusLabel", () => {
|
||||
couchDB: couchDBProgress("continuous", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
{ pull: 1200 }
|
||||
);
|
||||
expect(transferred.icon).toBe("⚡");
|
||||
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
|
||||
expect(transferred.message).toBe("Sync: ⚡ (⏩↓ 1200) ↑ 0 ↓ 7");
|
||||
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff-pull", "sent", "arrived"]);
|
||||
expect(transferred.parts[1]).toMatchObject({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
key: "ff-pull",
|
||||
text: " (⏩↓ ",
|
||||
value: 1200,
|
||||
suffix: ")",
|
||||
title: "Compared CouchDB pull feed entries",
|
||||
});
|
||||
|
||||
const pushChecking = projectReplicationStatusLabel(
|
||||
stats({
|
||||
couchDB: {
|
||||
phase: "continuous",
|
||||
push: { checkedChanges: 71975, completedBatches: 2878, checking: true },
|
||||
},
|
||||
}),
|
||||
noNetworkActivity,
|
||||
{ push: 71975 }
|
||||
);
|
||||
expect(pushChecking.icon).toBe("⏩");
|
||||
expect(pushChecking.message).toBe("Sync: ⏩ (⏩↑ 71975) ↑ 0 ↓ 0");
|
||||
expect(pushChecking.parts[1]).toMatchObject({ key: "ff-push", value: 71975 });
|
||||
|
||||
const bothChecking = projectReplicationStatusLabel(
|
||||
stats({ couchDB: couchDBProgress("continuous", 10, true) }),
|
||||
noNetworkActivity,
|
||||
{ push: 71975, pull: 10 }
|
||||
);
|
||||
expect(bothChecking.message).toBe("Sync: ⏩ (⏩↑ 71975) (⏩↓ 10) ↑ 0 ↓ 0");
|
||||
|
||||
const idle = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "CONNECTED",
|
||||
@@ -142,10 +168,10 @@ describe("projectReplicationStatusLabel", () => {
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
{ pull: 1200 }
|
||||
);
|
||||
expect(idle.icon).toBe("⚡");
|
||||
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
expect(idle.message).toBe("Sync: ⚡ (⏩↓ 1200) ↑ 0 ↓ 7");
|
||||
});
|
||||
|
||||
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
|
||||
@@ -154,7 +180,7 @@ describe("projectReplicationStatusLabel", () => {
|
||||
syncStatus: "ERRORED",
|
||||
couchDB: {
|
||||
...couchDBProgress("one-shot", 2, true),
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: true },
|
||||
push: { checkedChanges: 2, completedBatches: 1, checking: true },
|
||||
},
|
||||
}),
|
||||
noNetworkActivity
|
||||
@@ -181,41 +207,41 @@ describe("createFastForwardCheckedChangesDisplay", () => {
|
||||
);
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBe(1200);
|
||||
expect(display.value).toEqual({ pull: 1200 });
|
||||
vi.advanceTimersByTime(1000);
|
||||
source.value = stats({
|
||||
sent: 3,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
expect(display.value).toBe(1200);
|
||||
expect(display.value).toEqual({ pull: 1200 });
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
|
||||
expect(display.value).toBe(1200);
|
||||
expect(display.value).toEqual({ pull: 1200 });
|
||||
|
||||
source.value = stats({
|
||||
sent: 4,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBeUndefined();
|
||||
expect(display.value).toEqual({});
|
||||
|
||||
source.value = stats({
|
||||
syncStatus: "PAUSED",
|
||||
couchDB: couchDBProgress("continuous", 1200, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
expect(display.value).toEqual({});
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("continuous", 1201, true),
|
||||
});
|
||||
expect(display.value).toBe(1201);
|
||||
expect(display.value).toEqual({ pull: 1201 });
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("one-shot", 0, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
expect(display.value).toEqual({});
|
||||
|
||||
source.value = stats();
|
||||
expect(display.value).toBeUndefined();
|
||||
expect(display.value).toEqual({});
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
@@ -227,16 +253,46 @@ describe("createFastForwardCheckedChangesDisplay", () => {
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
|
||||
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
|
||||
|
||||
expect(display.value).toBe(5);
|
||||
expect(display.value).toEqual({ pull: 5 });
|
||||
});
|
||||
|
||||
it("does not display progress for providers without CouchDB replication state", () => {
|
||||
const source = reactiveSource(stats());
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBeUndefined();
|
||||
expect(display.value).toEqual({});
|
||||
source.value = stats({ couchDB: { phase: "continuous" } });
|
||||
expect(display.value).toBeUndefined();
|
||||
expect(display.value).toEqual({});
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("tracks push and pull independently and expires only the direction that stopped changing", () => {
|
||||
const source = reactiveSource(
|
||||
stats({
|
||||
couchDB: {
|
||||
phase: "catch-up",
|
||||
pull: { checkedChanges: 4, completedBatches: 1, checking: true },
|
||||
push: { checkedChanges: 40, completedBatches: 2, checking: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
const display = createFastForwardCheckedChangesDisplay(source, 100);
|
||||
|
||||
expect(display.value).toEqual({ push: 40, pull: 4 });
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = stats({
|
||||
couchDB: {
|
||||
phase: "catch-up",
|
||||
pull: { checkedChanges: 4, completedBatches: 1, checking: true },
|
||||
push: { checkedChanges: 41, completedBatches: 2, checking: true },
|
||||
},
|
||||
});
|
||||
expect(display.value).toEqual({ push: 41, pull: 4 });
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(display.value).toEqual({ push: 41 });
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(display.value).toEqual({});
|
||||
display.dispose();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,8 @@ export function createGeneralSettingSpecGroups({
|
||||
},
|
||||
{ key: "showStatusOnStatusbar", control: { type: "toggle" } },
|
||||
{ key: "hideFileWarningNotice", control: { type: "toggle" } },
|
||||
{ key: "syncGuardEnabled", control: { type: "toggle" } },
|
||||
{ key: "syncGuardMinimumFiles", control: { type: "number", min: 1 } },
|
||||
{
|
||||
key: "networkWarningStyle",
|
||||
control: {
|
||||
|
||||
+43
-2
@@ -1,6 +1,7 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SYNC_GUARD_SETTINGS, type SyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts";
|
||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
|
||||
@@ -152,6 +153,7 @@ type SettingsTabOptions = {
|
||||
};
|
||||
|
||||
function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
|
||||
const syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
|
||||
const core = {
|
||||
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
|
||||
confirm: {
|
||||
@@ -160,6 +162,10 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
syncGuardSettings,
|
||||
setSyncGuardSetting: vi.fn(async (key: keyof SyncGuardSettings, value: boolean | number) => {
|
||||
Object.assign(syncGuardSettings, { [key]: value });
|
||||
}),
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
@@ -179,8 +185,8 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
|
||||
initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
|
||||
_editingSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
|
||||
initialSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
@@ -335,6 +341,8 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
"showOnlyIconsOnEditor",
|
||||
"showStatusOnStatusbar",
|
||||
"hideFileWarningNotice",
|
||||
"syncGuardEnabled",
|
||||
"syncGuardMinimumFiles",
|
||||
"networkWarningStyle",
|
||||
]);
|
||||
expect(
|
||||
@@ -345,6 +353,39 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]);
|
||||
});
|
||||
|
||||
it("persists declarative SyncGuard controls through Obsidian's local settings service", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const setting = tab.services.setting as typeof tab.services.setting & {
|
||||
setSyncGuardSetting: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const initialCoreSettings = { ...tab.core.settings };
|
||||
|
||||
await tab.setControlValue("syncGuardEnabled", true);
|
||||
await tab.setControlValue("syncGuardMinimumFiles", 6);
|
||||
|
||||
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(1, "syncGuardEnabled", true);
|
||||
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(2, "syncGuardMinimumFiles", 6);
|
||||
expect(tab.getControlValue("syncGuardEnabled")).toBe(true);
|
||||
expect(tab.getControlValue("syncGuardMinimumFiles")).toBe(6);
|
||||
expect(tab.core.settings).toEqual(initialCoreSettings);
|
||||
expect(setting.saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes active settings when local SyncGuard preferences change elsewhere", async () => {
|
||||
const tab = createSettingsTab();
|
||||
tab.isShown = true;
|
||||
const refresh = vi.spyOn(tab, "requestPageRefresh").mockImplementation(() => undefined);
|
||||
const setting = tab.services.setting as typeof tab.services.setting & { syncGuardSettings: SyncGuardSettings };
|
||||
setting.syncGuardSettings.syncGuardEnabled = true;
|
||||
setting.syncGuardSettings.syncGuardMinimumFiles = 13;
|
||||
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.editingSettings.syncGuardEnabled).toBe(true);
|
||||
expect(tab.editingSettings.syncGuardMinimumFiles).toBe(13);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits the old Setup child page and keeps standard General and Advanced pages native", () => {
|
||||
const tab = createSettingsTab();
|
||||
const pages = collectPages(tab.getSettingDefinitions());
|
||||
|
||||
@@ -77,6 +77,11 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
|
||||
import {
|
||||
getSyncGuardSettings,
|
||||
isSyncGuardSettingKey,
|
||||
setSyncGuardSetting,
|
||||
} from "@/modules/services/ObsidianSettingService.ts";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
@@ -138,6 +143,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
if (
|
||||
this.isReconstructionRequiredSetting(k) ||
|
||||
this.isConfigurationEncryptionSetting(k) ||
|
||||
isSyncGuardSettingKey(k) ||
|
||||
k in OnDialogSettingsDefault
|
||||
) {
|
||||
// //@ts-ignore
|
||||
@@ -157,6 +163,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
|
||||
);
|
||||
await this.saveConfigurationEncryptionIfDirty(changedKeys);
|
||||
for (const key of changedKeys.filter(isSyncGuardSettingKey)) {
|
||||
await setSyncGuardSetting(this.services.setting, key, this.editingSettings[key]);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, key);
|
||||
}
|
||||
this.applySetting(changedKeys);
|
||||
this.reloadAllSettings();
|
||||
}
|
||||
@@ -177,6 +187,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
|
||||
for (const k of appliedKeys) {
|
||||
if (this.isConfigurationEncryptionSetting(k)) continue;
|
||||
if (isSyncGuardSettingKey(k)) {
|
||||
await setSyncGuardSetting(this.services.setting, k, this.editingSettings[k]);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, k);
|
||||
continue;
|
||||
}
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
await this.saveLocalSetting(k as keyof OnDialogSettings);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, k);
|
||||
@@ -307,7 +322,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
return { ...ret, ...getSyncGuardSettings(this.services.setting) };
|
||||
}
|
||||
computeAllLocalSettings(): Partial<OnDialogSettings> {
|
||||
const syncMode = this.editingSettings?.liveSync
|
||||
@@ -375,7 +390,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
return;
|
||||
}
|
||||
const localSetting = this.reloadAllLocalSettings();
|
||||
if (key in this.core.settings) {
|
||||
if (isSyncGuardSettingKey(key) || key in this.core.settings) {
|
||||
if (key in localSetting) {
|
||||
this.copySettingValue(this.initialSettings, localSetting, key);
|
||||
this.copySettingValue(this.editingSettings, localSetting, key);
|
||||
@@ -567,25 +582,25 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
const nativeTabIsShown =
|
||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||
if (this.isShown || nativeTabIsShown) {
|
||||
const newConf = this.core.settings;
|
||||
const keys = Object.keys(newConf) as (keyof ObsidianLiveSyncSettings)[];
|
||||
const newConf = { ...this.core.settings, ...getSyncGuardSettings(this.services.setting) };
|
||||
const keys = Object.keys(newConf) as AllSettingItemKey[];
|
||||
let hasLoaded = false;
|
||||
let catalogueVisibilityChanged = false;
|
||||
for (const k of keys) {
|
||||
if (isObjectDifferent(newConf[k], this.initialSettings?.[k])) {
|
||||
if (isObjectDifferent(Reflect.get(newConf, k), this.initialSettings?.[k])) {
|
||||
// Something has changed
|
||||
if (this.isDirty(k as AllSettingItemKey)) {
|
||||
if (this.isDirty(k)) {
|
||||
// And modified.
|
||||
this.core.confirm.askInPopup(
|
||||
`config-reloaded-${k}`,
|
||||
$msg("obsidianLiveSyncSettingTab.msgSettingModified", {
|
||||
setting: getConfName(k as AllSettingItemKey),
|
||||
setting: getConfName(k),
|
||||
}),
|
||||
(anchor) => {
|
||||
anchor.text = $msg("obsidianLiveSyncSettingTab.optionHere");
|
||||
anchor.addEventListener("click", () => {
|
||||
this.refreshSetting(k as AllSettingItemKey);
|
||||
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
|
||||
this.refreshSetting(k);
|
||||
if (this.changesPageCatalogue(k)) {
|
||||
this.requestCatalogueRefresh();
|
||||
} else {
|
||||
this.requestPageRefresh();
|
||||
@@ -595,12 +610,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
} else {
|
||||
// not modified
|
||||
this.refreshSetting(k as AllSettingItemKey);
|
||||
this.refreshSetting(k);
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
continue;
|
||||
}
|
||||
hasLoaded = true;
|
||||
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
|
||||
if (this.changesPageCatalogue(k)) {
|
||||
catalogueVisibilityChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ describe("General setting specifications", () => {
|
||||
"showOnlyIconsOnEditor",
|
||||
"showStatusOnStatusbar",
|
||||
"hideFileWarningNotice",
|
||||
"syncGuardEnabled",
|
||||
"syncGuardMinimumFiles",
|
||||
"networkWarningStyle",
|
||||
"lessInformationInLog",
|
||||
"showVerboseLog",
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
export {
|
||||
AllSettingDefault,
|
||||
OnDialogSettingsDefault,
|
||||
SettingInformation,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export type {
|
||||
AllSettings,
|
||||
AllSettingItemKey,
|
||||
AllStringItemKey,
|
||||
AllNumericItemKey,
|
||||
AllBooleanItemKey,
|
||||
OnDialogSettings,
|
||||
ValueOf,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export { OnDialogSettingsDefault } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export type { OnDialogSettings } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
|
||||
import {
|
||||
AllSettingDefault as CommonlibAllSettingDefault,
|
||||
SettingInformation as CommonlibSettingInformation,
|
||||
getConfig as getCommonlibConfig,
|
||||
getConfName as getCommonlibConfName,
|
||||
type AllSettingItemKey,
|
||||
type AllSettings as CommonlibAllSettings,
|
||||
type AllSettingItemKey as CommonlibAllSettingItemKey,
|
||||
type AllStringItemKey as CommonlibAllStringItemKey,
|
||||
type AllNumericItemKey as CommonlibAllNumericItemKey,
|
||||
type AllBooleanItemKey as CommonlibAllBooleanItemKey,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
import type { ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import {
|
||||
DEFAULT_SYNC_GUARD_SETTINGS,
|
||||
isSyncGuardSettingKey,
|
||||
type SyncGuardSettingKey,
|
||||
type SyncGuardSettings,
|
||||
} from "@/modules/services/ObsidianSettingService";
|
||||
|
||||
export type AllSettings = Omit<CommonlibAllSettings, SyncGuardSettingKey> & SyncGuardSettings;
|
||||
export type AllStringItemKey = CommonlibAllStringItemKey;
|
||||
export type AllNumericItemKey = Exclude<CommonlibAllNumericItemKey, SyncGuardSettingKey> | "syncGuardMinimumFiles";
|
||||
export type AllBooleanItemKey = Exclude<CommonlibAllBooleanItemKey, SyncGuardSettingKey> | "syncGuardEnabled";
|
||||
export type AllSettingItemKey = Exclude<CommonlibAllSettingItemKey, SyncGuardSettingKey> | SyncGuardSettingKey;
|
||||
export type ValueOf<T extends AllSettingItemKey> = AllSettings[T];
|
||||
|
||||
const syncGuardSettingInformation: Record<SyncGuardSettingKey, ConfigurationItem> = {
|
||||
syncGuardEnabled: {
|
||||
name: "Enable SyncGuard",
|
||||
desc: "Enable device-local SyncGuard protection during synchronisation.",
|
||||
},
|
||||
syncGuardMinimumFiles: {
|
||||
name: "SyncGuard minimum files",
|
||||
desc: "Minimum number of Vault files required for SyncGuard protection to apply.",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSettingDefault = { ...CommonlibAllSettingDefault, ...DEFAULT_SYNC_GUARD_SETTINGS };
|
||||
export const SettingInformation: Partial<Record<AllSettingItemKey, ConfigurationItem>> = {
|
||||
...CommonlibSettingInformation,
|
||||
...syncGuardSettingInformation,
|
||||
};
|
||||
|
||||
// Commonlib defaults `translate` to its English-only translator, so every caller which omits
|
||||
// it silently renders English regardless of `displayLanguage`. Default it to the LiveSync
|
||||
@@ -28,10 +53,12 @@ import { translateLiveSyncMessage } from "@/common/translation";
|
||||
|
||||
/** `getConfig` with the LiveSync catalogue applied by default. */
|
||||
export function getConfig(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
|
||||
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key];
|
||||
return getCommonlibConfig(key, translate);
|
||||
}
|
||||
|
||||
/** `getConfName` with the LiveSync catalogue applied by default. See `getConfig`. */
|
||||
export function getConfName(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
|
||||
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key].name;
|
||||
return getCommonlibConfName(key, translate);
|
||||
}
|
||||
@@ -314,14 +314,16 @@
|
||||
{#if processing}
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
{#if setupMode === "settings"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
<UserDecisions>
|
||||
<Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
{#if setupMode === "settings"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Decision
|
||||
title={translateMessage("Save without connecting")}
|
||||
disabled={!canProceed}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
gap: 0.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--keyboard-height, 0px);
|
||||
padding-bottom: 0;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
@@ -76,6 +76,10 @@
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.dialog-host :global(.button-group > button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dialog-host :global(.button-group) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,6 +8,36 @@ import {
|
||||
type SettingServiceDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts";
|
||||
|
||||
export type SyncGuardSettings = {
|
||||
syncGuardEnabled: boolean;
|
||||
syncGuardMinimumFiles: number;
|
||||
};
|
||||
|
||||
export type SyncGuardSettingKey = keyof SyncGuardSettings;
|
||||
|
||||
export const DEFAULT_SYNC_GUARD_SETTINGS: Readonly<SyncGuardSettings> = {
|
||||
syncGuardEnabled: false,
|
||||
syncGuardMinimumFiles: 20,
|
||||
};
|
||||
|
||||
export function isSyncGuardSettingKey(key: string): key is SyncGuardSettingKey {
|
||||
return key === "syncGuardEnabled" || key === "syncGuardMinimumFiles";
|
||||
}
|
||||
|
||||
/** Access the Obsidian host preferences from a service exposed through the shared hub type. */
|
||||
export function getSyncGuardSettings(setting: SettingService<ObsidianServiceContext>): Readonly<SyncGuardSettings> {
|
||||
return (setting as ObsidianSettingService<ObsidianServiceContext>).syncGuardSettings;
|
||||
}
|
||||
|
||||
export async function setSyncGuardSetting<K extends SyncGuardSettingKey>(
|
||||
setting: SettingService<ObsidianServiceContext>,
|
||||
key: K,
|
||||
value: SyncGuardSettings[K]
|
||||
): Promise<void> {
|
||||
await (setting as ObsidianSettingService<ObsidianServiceContext>).setSyncGuardSetting(key, value);
|
||||
}
|
||||
|
||||
export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSettings | undefined {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
|
||||
@@ -15,6 +45,13 @@ export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSe
|
||||
}
|
||||
|
||||
export class ObsidianSettingService<T extends ObsidianServiceContext> extends SettingService<T> {
|
||||
private _syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
|
||||
private legacySyncGuardSettings: Partial<SyncGuardSettings> = {};
|
||||
|
||||
get syncGuardSettings(): Readonly<SyncGuardSettings> {
|
||||
return this._syncGuardSettings;
|
||||
}
|
||||
|
||||
constructor(context: T, dependencies: SettingServiceDependencies) {
|
||||
super(context, dependencies);
|
||||
this.onSettingSaved.addHandler((settings) => {
|
||||
@@ -26,10 +63,62 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
override async loadSettings(): Promise<void> {
|
||||
await super.loadSettings();
|
||||
const storedEnabled = await this.config.getObject("syncGuardEnabled");
|
||||
const storedMinimumFiles = await this.config.getObject("syncGuardMinimumFiles");
|
||||
const enabled = typeof storedEnabled === "boolean" ? storedEnabled : this.legacySyncGuardSettings.syncGuardEnabled;
|
||||
const minimumFiles =
|
||||
typeof storedMinimumFiles === "number" && Number.isFinite(storedMinimumFiles) && storedMinimumFiles >= 1
|
||||
? storedMinimumFiles
|
||||
: this.legacySyncGuardSettings.syncGuardMinimumFiles;
|
||||
|
||||
this._syncGuardSettings = {
|
||||
syncGuardEnabled: enabled ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardEnabled,
|
||||
syncGuardMinimumFiles: minimumFiles ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardMinimumFiles,
|
||||
};
|
||||
if (storedEnabled === undefined && enabled !== undefined) {
|
||||
await this.config.setObject("syncGuardEnabled", enabled);
|
||||
}
|
||||
if (storedMinimumFiles === undefined && minimumFiles !== undefined) {
|
||||
await this.config.setObject("syncGuardMinimumFiles", minimumFiles);
|
||||
}
|
||||
}
|
||||
|
||||
async setSyncGuardSetting<K extends SyncGuardSettingKey>(key: K, value: SyncGuardSettings[K]): Promise<void> {
|
||||
if (key === "syncGuardMinimumFiles" && (typeof value !== "number" || !Number.isInteger(value) || value < 1)) {
|
||||
throw new RangeError("SyncGuard minimum files must be a positive integer");
|
||||
}
|
||||
if (this._syncGuardSettings[key] === value) return;
|
||||
await this.config.setObject(key, value);
|
||||
this._syncGuardSettings = { ...this._syncGuardSettings, [key]: value };
|
||||
// These preferences change only the workspace overlay. Re-applying all
|
||||
// replication settings would unnecessarily interrupt an active sync.
|
||||
this.context.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
|
||||
}
|
||||
|
||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
const persisted: Record<string, unknown> = { ...data };
|
||||
delete persisted.syncGuardEnabled;
|
||||
delete persisted.syncGuardMinimumFiles;
|
||||
return await this.context.liveSyncPlugin.saveData(persisted);
|
||||
}
|
||||
protected override async loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
|
||||
return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
|
||||
const data = normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
|
||||
if (!data) return undefined;
|
||||
const persisted: Record<string, unknown> = { ...data };
|
||||
const { syncGuardEnabled, syncGuardMinimumFiles } = persisted;
|
||||
this.legacySyncGuardSettings = {
|
||||
...(typeof syncGuardEnabled === "boolean" ? { syncGuardEnabled } : {}),
|
||||
...(typeof syncGuardMinimumFiles === "number" &&
|
||||
Number.isFinite(syncGuardMinimumFiles) &&
|
||||
syncGuardMinimumFiles >= 1
|
||||
? { syncGuardMinimumFiles }
|
||||
: {}),
|
||||
};
|
||||
delete persisted.syncGuardEnabled;
|
||||
delete persisted.syncGuardMinimumFiles;
|
||||
return persisted as unknown as ObsidianLiveSyncSettings;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
|
||||
import { getSyncGuardSettings, ObsidianSettingService, setSyncGuardSetting } from "./ObsidianSettingService.ts";
|
||||
|
||||
function createSettingService(
|
||||
vaultName: string,
|
||||
deviceValues: MemoryValueStore
|
||||
deviceValues: MemoryValueStore,
|
||||
savedData?: unknown,
|
||||
saveData = vi.fn(async (_settings: unknown) => undefined)
|
||||
): ObsidianSettingService<ObsidianServiceContext> {
|
||||
const context = createServiceContext() as unknown as ObsidianServiceContext;
|
||||
context.liveSyncPlugin = {
|
||||
loadData: vi.fn(async () => savedData),
|
||||
saveData,
|
||||
} as unknown as ObsidianServiceContext["liveSyncPlugin"];
|
||||
const APIService = {
|
||||
addLog: () => undefined,
|
||||
getSystemVaultName: () => vaultName,
|
||||
getAppID: () => "test-app",
|
||||
} as unknown as IAPIService;
|
||||
return new ObsidianSettingService(context, { APIService, deviceValues });
|
||||
}
|
||||
@@ -52,3 +61,102 @@ describe("normaliseObsidianSettingsData", () => {
|
||||
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncGuard device-local settings", () => {
|
||||
const savedSettings = { ...DEFAULT_SETTINGS, isConfigured: true, additionalSuffixOfDatabaseName: "" };
|
||||
|
||||
it("loads safe defaults when no local values were saved", async () => {
|
||||
const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings);
|
||||
|
||||
await setting.loadSettings();
|
||||
|
||||
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 });
|
||||
});
|
||||
|
||||
it("migrates preferences stored by earlier builds and excludes them from plugin data", async () => {
|
||||
const localValues = new MemoryValueStore();
|
||||
const saveData = vi.fn(async (_data: unknown) => undefined);
|
||||
const setting = createSettingService(
|
||||
"first-vault",
|
||||
localValues,
|
||||
{ ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 7 },
|
||||
saveData
|
||||
);
|
||||
|
||||
await setting.loadSettings();
|
||||
await setting.saveSettingData();
|
||||
|
||||
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 7 });
|
||||
await expect(setting.config.getObject("syncGuardEnabled")).resolves.toBe(true);
|
||||
await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(7);
|
||||
expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardEnabled");
|
||||
expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardMinimumFiles");
|
||||
});
|
||||
|
||||
it("keeps existing device-local values authoritative over legacy plugin data", async () => {
|
||||
const localValues = new MemoryValueStore();
|
||||
const setting = createSettingService(
|
||||
"first-vault",
|
||||
localValues,
|
||||
{ ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 5 }
|
||||
);
|
||||
await localValues.setObject("first-vault--syncGuardEnabled", false);
|
||||
await localValues.setObject("first-vault--syncGuardMinimumFiles", 30);
|
||||
|
||||
await setting.loadSettings();
|
||||
|
||||
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 30 });
|
||||
});
|
||||
|
||||
it("restores saved preferences after recreating the service", async () => {
|
||||
const localValues = new MemoryValueStore();
|
||||
const first = createSettingService("first-vault", localValues, savedSettings);
|
||||
await first.loadSettings();
|
||||
await setSyncGuardSetting(first, "syncGuardEnabled", true);
|
||||
await setSyncGuardSetting(first, "syncGuardMinimumFiles", 12);
|
||||
|
||||
const reloaded = createSettingService("first-vault", localValues, savedSettings);
|
||||
await reloaded.loadSettings();
|
||||
|
||||
expect(getSyncGuardSettings(reloaded)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 12 });
|
||||
});
|
||||
|
||||
it("rejects invalid file thresholds without changing local settings", async () => {
|
||||
const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings);
|
||||
await setting.loadSettings();
|
||||
const storedBefore = await setting.config.getObject("syncGuardMinimumFiles");
|
||||
|
||||
for (const invalid of [0, -1, 1.5, NaN, Infinity]) {
|
||||
await expect(setSyncGuardSetting(setting, "syncGuardMinimumFiles", invalid)).rejects.toThrow(RangeError);
|
||||
}
|
||||
|
||||
expect(getSyncGuardSettings(setting).syncGuardMinimumFiles).toBe(20);
|
||||
await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(storedBefore);
|
||||
});
|
||||
|
||||
it("persists edits only in this Vault's device store and notifies the guard", async () => {
|
||||
const localValues = new MemoryValueStore();
|
||||
const saveData = vi.fn(async (_data: unknown) => undefined);
|
||||
const first = createSettingService("first-vault", localValues, savedSettings, saveData);
|
||||
const second = createSettingService("second-vault", localValues, savedSettings);
|
||||
await first.loadSettings();
|
||||
await second.loadSettings();
|
||||
const initialSaveCount = saveData.mock.calls.length;
|
||||
const changed = vi.fn();
|
||||
const unrelatedReplicationSettingsSaved = vi.fn();
|
||||
first.context.events.onEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED, changed);
|
||||
first.context.events.onEvent(EVENT_SETTING_SAVED, unrelatedReplicationSettingsSaved);
|
||||
|
||||
await setSyncGuardSetting(first, "syncGuardEnabled", true);
|
||||
await setSyncGuardSetting(first, "syncGuardMinimumFiles", 8);
|
||||
await setSyncGuardSetting(first, "syncGuardEnabled", true);
|
||||
|
||||
expect(getSyncGuardSettings(first)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 8 });
|
||||
expect(getSyncGuardSettings(second)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 });
|
||||
await expect(localValues.getObject("first-vault--syncGuardEnabled")).resolves.toBe(true);
|
||||
await expect(localValues.getObject("first-vault--syncGuardMinimumFiles")).resolves.toBe(8);
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
expect(unrelatedReplicationSettingsSaved).not.toHaveBeenCalled();
|
||||
expect(saveData).toHaveBeenCalledTimes(initialSaveCount);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user