mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
328 lines
9.8 KiB
TypeScript
328 lines
9.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { createRendererState } from '../state.js';
|
|
import { createControllerSelectModal } from './controller-select.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);
|
|
else tokens.add(entry);
|
|
return tokens.has(entry);
|
|
}
|
|
if (force) tokens.add(entry);
|
|
else tokens.delete(entry);
|
|
return force;
|
|
},
|
|
contains: (entry: string) => tokens.has(entry),
|
|
};
|
|
}
|
|
|
|
function createFakeElement() {
|
|
const attributes = new Map<string, string>();
|
|
const el = {
|
|
className: '',
|
|
textContent: '',
|
|
_innerHTML: '',
|
|
value: '',
|
|
disabled: false,
|
|
selected: false,
|
|
type: '',
|
|
children: [] as any[],
|
|
listeners: new Map<string, Array<(e?: any) => void>>(),
|
|
classList: createClassList(),
|
|
appendChild(child: any) {
|
|
this.children.push(child);
|
|
return child;
|
|
},
|
|
addEventListener(type: string, listener: (e?: any) => void) {
|
|
const existing = this.listeners.get(type) ?? [];
|
|
existing.push(listener);
|
|
this.listeners.set(type, existing);
|
|
},
|
|
dispatch(type: string) {
|
|
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(fakeEvent);
|
|
}
|
|
},
|
|
setAttribute(name: string, value: string) {
|
|
attributes.set(name, value);
|
|
},
|
|
getAttribute(name: string) {
|
|
return attributes.get(name) ?? null;
|
|
},
|
|
querySelector(selector: string) {
|
|
const match = selector.match(/^\[data-testid="(.+)"\]$/);
|
|
if (!match) return null;
|
|
const testId = match[1];
|
|
for (const child of el.children) {
|
|
if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) {
|
|
return child;
|
|
}
|
|
if (typeof child.querySelector === 'function') {
|
|
const nested = child.querySelector(selector);
|
|
if (nested) return nested;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
focus: () => {},
|
|
};
|
|
Object.defineProperty(el, 'innerHTML', {
|
|
get() {
|
|
return el._innerHTML;
|
|
},
|
|
set(v: string) {
|
|
el._innerHTML = v;
|
|
if (v === '') el.children.length = 0;
|
|
},
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function installFakeDom() {
|
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
|
const previousWindow = globals.window;
|
|
const previousDocument = globals.document;
|
|
|
|
Object.defineProperty(globalThis, 'document', {
|
|
configurable: true,
|
|
value: {
|
|
createElement: () => createFakeElement(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
restore: () => {
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildContext() {
|
|
const state = createRendererState();
|
|
state.controllerConfig = {
|
|
enabled: true,
|
|
preferredGamepadId: 'pad-1',
|
|
preferredGamepadLabel: 'pad-1',
|
|
smoothScroll: true,
|
|
scrollPixelsPerSecond: 900,
|
|
horizontalJumpPixels: 160,
|
|
stickDeadzone: 0.2,
|
|
triggerInputMode: 'auto',
|
|
triggerDeadzone: 0.5,
|
|
repeatDelayMs: 320,
|
|
repeatIntervalMs: 120,
|
|
buttonIndices: {
|
|
select: 6,
|
|
buttonSouth: 0,
|
|
buttonEast: 1,
|
|
buttonWest: 2,
|
|
buttonNorth: 3,
|
|
leftShoulder: 4,
|
|
rightShoulder: 5,
|
|
leftStickPress: 9,
|
|
rightStickPress: 10,
|
|
leftTrigger: 6,
|
|
rightTrigger: 7,
|
|
},
|
|
bindings: {
|
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
previousAudio: { kind: 'none' },
|
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
},
|
|
};
|
|
state.connectedGamepads = [
|
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
|
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
|
];
|
|
state.activeGamepadId = 'pad-1';
|
|
|
|
const dom = {
|
|
overlay: { classList: createClassList(), focus: () => {} },
|
|
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
|
controllerSelectClose: createFakeElement(),
|
|
controllerSelectPicker: createFakeElement(),
|
|
controllerSelectSummary: createFakeElement(),
|
|
controllerConfigList: createFakeElement(),
|
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
|
controllerSelectSave: createFakeElement(),
|
|
};
|
|
|
|
return { state, dom };
|
|
}
|
|
|
|
test('controller select modal saves preferred controller from dropdown selection', async () => {
|
|
const domHandle = installFakeDom();
|
|
const saved: unknown[] = [];
|
|
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: {
|
|
focus: () => {},
|
|
electronAPI: {
|
|
saveControllerConfig: async (update: unknown) => {
|
|
saved.push(update);
|
|
},
|
|
notifyOverlayModalClosed: () => {},
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const { state, dom } = buildContext();
|
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
|
modalStateReader: { isAnyModalOpen: () => false },
|
|
syncSettingsModalSubtitleSuppression: () => {},
|
|
});
|
|
|
|
modal.wireDomEvents();
|
|
modal.openControllerSelectModal();
|
|
state.controllerDeviceSelectedIndex = 1;
|
|
|
|
await modal.handleControllerSelectKeydown({
|
|
key: 'Enter',
|
|
preventDefault: () => {},
|
|
} as KeyboardEvent);
|
|
await Promise.resolve();
|
|
|
|
assert.deepEqual(saved, [
|
|
{
|
|
preferredGamepadId: 'pad-2',
|
|
preferredGamepadLabel: 'pad-2',
|
|
},
|
|
]);
|
|
} finally {
|
|
domHandle.restore();
|
|
}
|
|
});
|
|
|
|
test('controller select modal learn mode captures fresh button input and persists binding', async () => {
|
|
const domHandle = installFakeDom();
|
|
const saved: unknown[] = [];
|
|
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: {
|
|
focus: () => {},
|
|
electronAPI: {
|
|
saveControllerConfig: async (update: unknown) => {
|
|
saved.push(update);
|
|
},
|
|
notifyOverlayModalClosed: () => {},
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const { state, dom } = buildContext();
|
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
|
modalStateReader: { isAnyModalOpen: () => false },
|
|
syncSettingsModalSubtitleSuppression: () => {},
|
|
});
|
|
|
|
modal.wireDomEvents();
|
|
modal.openControllerSelectModal();
|
|
|
|
// In the new compact list layout, children are:
|
|
// [0] group header, [1] first binding row, [2] second binding row, ...
|
|
// Click the row to expand the inline edit panel
|
|
const firstRow = dom.controllerConfigList.children[1];
|
|
firstRow.dispatch('click');
|
|
|
|
// After expanding, the edit panel is inserted after the row:
|
|
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
|
|
const editPanel = dom.controllerConfigList.children[2];
|
|
// editPanel > inner > actions > learnButton
|
|
const inner = editPanel.children[0];
|
|
const actions = inner.children[1];
|
|
const learnButton = actions.children[0];
|
|
learnButton.dispatch('click');
|
|
|
|
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
|
value: 0,
|
|
pressed: false,
|
|
touched: false,
|
|
}));
|
|
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
|
modal.updateDevices();
|
|
|
|
await Promise.resolve();
|
|
|
|
assert.deepEqual(saved.at(-1), {
|
|
bindings: {
|
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
|
},
|
|
});
|
|
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
|
|
kind: 'button',
|
|
buttonIndex: 11,
|
|
});
|
|
} finally {
|
|
domHandle.restore();
|
|
}
|
|
});
|
|
|
|
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
|
const domHandle = installFakeDom();
|
|
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: {
|
|
focus: () => {},
|
|
electronAPI: {
|
|
saveControllerConfig: async () => {},
|
|
notifyOverlayModalClosed: () => {},
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const { state, dom } = buildContext();
|
|
state.connectedGamepads = [
|
|
{ id: 'same-pad', index: 0, mapping: 'standard', connected: true },
|
|
{ id: 'same-pad', index: 1, mapping: 'standard', connected: true },
|
|
];
|
|
state.activeGamepadId = 'same-pad';
|
|
|
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
|
modalStateReader: { isAnyModalOpen: () => false },
|
|
syncSettingsModalSubtitleSuppression: () => {},
|
|
});
|
|
|
|
modal.wireDomEvents();
|
|
modal.openControllerSelectModal();
|
|
|
|
const [firstOption, secondOption] = dom.controllerSelectPicker.children;
|
|
assert.notEqual(firstOption.value, secondOption.value);
|
|
|
|
dom.controllerSelectPicker.value = secondOption.value;
|
|
dom.controllerSelectPicker.dispatch('change');
|
|
|
|
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
|
} finally {
|
|
domHandle.restore();
|
|
}
|
|
});
|