mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
189 lines
6.7 KiB
TypeScript
189 lines
6.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
|
||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||
import {
|
||
CONTROLLER_FIELD_GROUPING_ACTIONS,
|
||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||
type ControllerFieldGroupingStep,
|
||
} from '../../shared/controller-field-grouping';
|
||
import { findControllerScreenBindingIssues } from '../../shared/controller-review';
|
||
import type { ControllerFieldGroupingAction, ResolvedControllerConfig } from '../../types';
|
||
import {
|
||
createControllerComboScreenLayout,
|
||
formatControllerScreenHints,
|
||
getControllerReviewInputContext,
|
||
} from './controller-screen-layout.js';
|
||
import type { GamepadLike } from './gamepad-controller.js';
|
||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||
|
||
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
|
||
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
|
||
|
||
// Standard-layout indices; faces follow config.buttonIndices (Xbox positions by default).
|
||
const BUTTON = { A: 0, B: 1, X: 2, Y: 3, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
|
||
type ButtonName = keyof typeof BUTTON;
|
||
|
||
function pad(pressed: ButtonName[] = []): GamepadLike {
|
||
const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false }));
|
||
for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true };
|
||
return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes: [0, 0, 0, 0], buttons };
|
||
}
|
||
|
||
/** The field grouping layout driving a fake dialog that follows the default flow. */
|
||
function createFieldGroupingLayout(config = CONFIG) {
|
||
const actions: ControllerFieldGroupingAction[] = [];
|
||
const state: { step: ControllerFieldGroupingStep } = { step: 'select' };
|
||
const layout = createControllerComboScreenLayout({
|
||
screen: CONTROLLER_FIELD_GROUPING_SCREEN,
|
||
getBindings: (resolved) => resolved.fieldGroupingBindings,
|
||
getStep: () => state.step,
|
||
dispatch: (action) => {
|
||
actions.push(action);
|
||
if (action === 'continue') state.step = 'preview';
|
||
if (action === 'back') state.step = 'select';
|
||
},
|
||
});
|
||
layout.activate(pad(), config, 0, { latchHeld: true });
|
||
return { layout, actions, state };
|
||
}
|
||
|
||
test('the default duplicate card layout binds every action but preview Cancel, without conflicts', () => {
|
||
for (const action of CONTROLLER_FIELD_GROUPING_ACTIONS) {
|
||
assert.equal(
|
||
CONFIG.fieldGroupingBindings[action.id]?.kind,
|
||
action.id === 'cancelMerge' ? 'none' : 'combo',
|
||
action.id,
|
||
);
|
||
}
|
||
for (const mapping of ['standard', ''] as const) {
|
||
for (const faceButtonLabels of ['xbox', 'nintendo'] as const) {
|
||
const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping);
|
||
assert.deepEqual(
|
||
findControllerScreenBindingIssues(
|
||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||
CONFIG.fieldGroupingBindings,
|
||
context,
|
||
),
|
||
{},
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('duplicate card hints read like the agreed layout', () => {
|
||
const hints = formatControllerScreenHints(
|
||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||
CONFIG.fieldGroupingBindings,
|
||
CONFIG,
|
||
'standard',
|
||
);
|
||
assert.deepEqual(hints, {
|
||
selectOriginal: '◀',
|
||
selectNew: '▶',
|
||
toggleDeleteDuplicate: 'X',
|
||
continue: 'A',
|
||
cancel: 'B',
|
||
confirmMerge: 'A',
|
||
back: 'B',
|
||
cancelMerge: null,
|
||
toggleView: 'Y',
|
||
scrollUp: '▲',
|
||
scrollDown: '▼',
|
||
});
|
||
});
|
||
|
||
test('the D-pad picks the card and X toggles delete on the first step', () => {
|
||
const { layout, actions } = createFieldGroupingLayout();
|
||
|
||
layout.poll(pad(['RIGHT']), CONFIG, 10);
|
||
layout.poll(pad(['RIGHT']), CONFIG, 2_000);
|
||
layout.poll(pad(), CONFIG, 2_010);
|
||
layout.poll(pad(['LEFT']), CONFIG, 2_020);
|
||
layout.poll(pad(['X']), CONFIG, 2_030);
|
||
// Up/down scroll only on the preview.
|
||
layout.poll(pad(['DOWN']), CONFIG, 2_040);
|
||
|
||
assert.deepEqual(actions, ['selectNew', 'selectOriginal', 'toggleDeleteDuplicate']);
|
||
});
|
||
|
||
test('B on the preview goes back; holding it does not also cancel', () => {
|
||
const { layout, actions, state } = createFieldGroupingLayout();
|
||
|
||
layout.poll(pad(['A']), CONFIG, 10);
|
||
assert.equal(state.step, 'preview');
|
||
// A still held after the step change does not confirm the merge.
|
||
layout.poll(pad(['A']), CONFIG, 20);
|
||
layout.poll(pad(), CONFIG, 30);
|
||
layout.poll(pad(['B']), CONFIG, 40);
|
||
assert.equal(state.step, 'select');
|
||
// B held across the step change is not a fresh press on the first step.
|
||
layout.poll(pad(['B']), CONFIG, 1_000);
|
||
layout.poll(pad(), CONFIG, 1_010);
|
||
layout.poll(pad(['B']), CONFIG, 1_020);
|
||
|
||
assert.deepEqual(actions, ['continue', 'back', 'cancel']);
|
||
});
|
||
|
||
test('preview scrolling repeats while the D-pad is held', () => {
|
||
const { layout, actions, state } = createFieldGroupingLayout();
|
||
state.step = 'preview';
|
||
|
||
layout.poll(pad(['DOWN']), CONFIG, 0);
|
||
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs - 1);
|
||
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs);
|
||
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs + repeatIntervalMs);
|
||
layout.poll(pad(), CONFIG, repeatDelayMs + repeatIntervalMs + 1);
|
||
layout.poll(pad(['Y']), CONFIG, 5_000);
|
||
layout.poll(pad(['Y']), CONFIG, 6_000);
|
||
|
||
assert.deepEqual(actions, ['scrollDown', 'scrollDown', 'scrollDown', 'toggleView']);
|
||
});
|
||
|
||
test('remapped duplicate card bindings replace the defaults', () => {
|
||
const config: ResolvedControllerConfig = {
|
||
...CONFIG,
|
||
fieldGroupingBindings: {
|
||
...CONFIG.fieldGroupingBindings,
|
||
back: { kind: 'none' },
|
||
cancelMerge: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null },
|
||
},
|
||
};
|
||
const { layout, actions, state } = createFieldGroupingLayout(config);
|
||
state.step = 'preview';
|
||
|
||
layout.poll(pad(['B']), config, 10);
|
||
|
||
assert.deepEqual(actions, ['cancelMerge']);
|
||
});
|
||
|
||
test('dialog hints follow the button style of the controller in use', () => {
|
||
const config: ResolvedControllerConfig = {
|
||
...CONFIG,
|
||
profiles: {
|
||
'pad-ps': {
|
||
label: 'DualSense',
|
||
faceButtonLabels: 'playstation',
|
||
buttonIndices: CONFIG.buttonIndices,
|
||
bindings: CONFIG.bindings,
|
||
reviewBindings: CONFIG.reviewBindings,
|
||
fieldGroupingBindings: CONFIG.fieldGroupingBindings,
|
||
},
|
||
},
|
||
};
|
||
const hintsFor = (gamepadId: string) => {
|
||
const padConfig = resolveControllerConfigForGamepad(config, gamepadId);
|
||
return formatControllerScreenHints(
|
||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||
padConfig.fieldGroupingBindings,
|
||
padConfig,
|
||
'standard',
|
||
);
|
||
};
|
||
|
||
assert.equal(hintsFor('pad-ps').continue, '╳');
|
||
assert.equal(hintsFor('pad-ps').toggleView, '△');
|
||
// Controllers without their own style use controller.faceButtonLabels.
|
||
assert.equal(hintsFor('other-pad').continue, 'A');
|
||
});
|