mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
216 lines
6.0 KiB
TypeScript
216 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|