Files
SubMiner/src/renderer/modals/controller-select.test.ts

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();
}
});