Windows update (#49)

This commit is contained in:
2026-04-11 21:45:52 -07:00
committed by GitHub
parent 49e46e6b9b
commit 52bab1d611
168 changed files with 9732 additions and 1422 deletions
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, RuntimeOptionState } from '../../types';
import { createRendererState } from '../state.js';
import { createRuntimeOptionsModal } from './runtime-options.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) {
tokens.delete(entry);
return false;
}
tokens.add(entry);
return true;
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
return {
className: '',
textContent: '',
title: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
};
}
function createRuntimeOptionsListStub() {
return {
innerHTML: '',
appendChild: () => {},
querySelector: () => null,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function withRuntimeOptionsModal(
getRuntimeOptions: () => Promise<RuntimeOptionState[]>,
run: (input: {
modal: ReturnType<typeof createRuntimeOptionsModal>;
state: ReturnType<typeof createRendererState>;
overlayClassList: ReturnType<typeof createClassList>;
modalClassList: ReturnType<typeof createClassList>;
statusNode: {
textContent: string;
classList: ReturnType<typeof createClassList>;
};
syncCalls: string[];
}) => Promise<void> | void,
): Promise<void> {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const statusNode = {
textContent: '',
classList: createClassList(),
};
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const syncCalls: string[] = [];
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
electronAPI: {
getRuntimeOptions,
setRuntimeOptionValue: async () => ({ ok: true }),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: () => createElementStub(),
},
});
const modal = createRuntimeOptionsModal(
{
dom: {
overlay: { classList: overlayClassList },
runtimeOptionsModal: {
classList: modalClassList,
setAttribute: () => {},
},
runtimeOptionsClose: {
addEventListener: () => {},
},
runtimeOptionsList: createRuntimeOptionsListStub(),
runtimeOptionsStatus: statusNode,
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {
syncCalls.push('sync');
},
},
);
return Promise.resolve()
.then(() =>
run({
modal,
state,
overlayClassList,
modalClassList,
statusNode,
syncCalls,
}),
)
.finally(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: previousWindow,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: previousDocument,
});
});
}
test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Loading runtime options...');
assert.deepEqual(input.syncCalls, ['sync']);
deferred.resolve([
{
id: 'anki.autoUpdateNewCards',
label: 'Auto-update new cards',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
]);
await flushAsyncWork();
assert.equal(
input.statusNode.textContent,
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
assert.equal(input.statusNode.classList.contains('error'), false);
});
});
test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
deferred.reject(new Error('boom'));
await flushAsyncWork();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
assert.equal(input.statusNode.classList.contains('error'), true);
});
});