Controller: layouts for the media timing review and duplicate card dialogs (#313)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 01:38:26 -08:00
1 parent 696462a82e
commit 6d0f37ac21
50 files changed
+4555 -46

No files matched your search

@@ -0,0 +1,157 @@
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';
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']);
});