Skip to content

Commit 9f7274d

Browse files
committed
Fix Input Mapping V2 gesture-specific capture behavior - PR_26140_111-fix-input-mapping-v2-gesture-specific-capture
1 parent 05b2316 commit 9f7274d

4 files changed

Lines changed: 528 additions & 44 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# PR_26140_111-fix-input-mapping-v2-gesture-specific-capture
2+
3+
## Source Reading
4+
- Read `docs/dev/PROJECT_INSTRUCTIONS.md` before implementation.
5+
- Read `.codex/skills/repo-build/SKILL.md` for the repo BUILD workflow.
6+
- Read `docs/pr/BUILD_PR.md`; it still references unrelated Level 18 overlay runtime hardening, so this report treats the explicit PR_26140_111 request as the authoritative BUILD scope.
7+
- Read the targeted Input Mapping V2 files and immediate engine input dependency used by capture behavior.
8+
9+
## Implementation Summary
10+
- Updated `ToolStarterApp` capture sessions so completion clears the active Capture button and a single Capture click can commit only one valid mapping.
11+
- Made Keyboard Release capture wait for key down, show pending release feedback, and commit on matching key up.
12+
- Made Combo capture visibly pending after the first input and commit only after the second input.
13+
- Made Mouse Double Click capture wait for two same-button clicks within a threshold, with pending feedback after the first click.
14+
- Made Mouse Wheel gestures require a matching live wheel event after Capture Mouse; mismatched clicks or opposite wheel directions warn without creating mappings.
15+
- Tightened game controller capture validation so Button, Trigger, Stick, and DPad gestures only accept matching controller input kinds.
16+
- Preserved selected tile requirements, token deletion, scroll preservation, capture gating, schema shape, and sample JSON.
17+
18+
## Playwright Impact
19+
Playwright impacted: Yes.
20+
21+
Validated behavior:
22+
- Capture buttons unhighlight after successful capture.
23+
- Combo capture shows first-input pending state and commits after the second input.
24+
- Keyboard Release commits on release and displays Release.
25+
- Mouse Double Click commits only after the second click and rejects wheel input.
26+
- Wheel gestures reject click/opposite wheel input and commit matching wheel direction.
27+
- Game Controller Trigger rejects non-trigger button input.
28+
- Game Controller Button rejects standard trigger input.
29+
- Invalid gesture/input mismatches do not create mappings.
30+
31+
Expected pass behavior: all targeted and workspace-v2 Playwright tests pass with no page errors.
32+
Expected fail behavior: mismatched gestures produce actionable WARN status/log text and do not create mapping tokens.
33+
34+
## Validation
35+
PASS `node --check tools/input-mapping-v2/js/ToolStarterApp.js`
36+
PASS `node --check tools/input-mapping-v2/js/services/EngineInputSourceService.js`
37+
PASS `node --check tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
38+
PASS `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs -g "Input Mapping V2"` - 8 passed.
39+
PASS `npm run test:workspace-v2` - 67 passed.
40+
PASS `git diff --check` - no whitespace errors; PowerShell reported the existing LF to CRLF working-copy warning for the Playwright spec.
41+
42+
## V8 Coverage
43+
Runtime JavaScript changed, so Playwright V8 coverage artifacts were produced:
44+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
45+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
46+
47+
Coverage guardrail status: advisory only, no threshold enforced, no missing changed-runtime-JS warnings.
48+
49+
## Manual Test Notes
50+
1. Open Input Mapping V2, select an action, click Add, select Keyboard Release, click Capture Keyboard, press and release a key. Expected: no mapping until release; tile displays Release; Capture Keyboard unhighlights.
51+
2. Select Keyboard Combo, click Capture Keyboard, press Ctrl then S. Expected: first input shows pending feedback; second input commits one combo token.
52+
3. Select Mouse Double Click, click Capture Mouse, scroll the wheel, then double-click. Expected: wheel logs WARN and creates no token; double-click commits Double Click.
53+
4. Select Mouse Wheel Up, click Capture Mouse, click mouse, wheel down, then wheel up. Expected: invalid inputs warn; wheel up commits.
54+
5. With a standard mocked/connected controller, select Trigger and press a face button, then trigger; select Button and press a trigger, then face button. Expected: mismatches warn and only matching controls commit.
55+
56+
## Delta ZIP
57+
- `tmp/PR_26140_111-fix-input-mapping-v2-gesture-specific-capture_20260523_093905_delta.zip` created and verified with 10 repo-relative entries.
58+
59+
## Samples Smoke
60+
Full samples smoke test was not run, per request. Sample JSON was not touched.
61+
62+

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 214 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,13 @@ test.describe("Workspace Manager V2 bootstrap", () => {
17721772
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='MouseWheelUp']")
17731773
.click();
17741774
await page.locator("#inputMappingV2CaptureMouseButton").click();
1775+
await page.evaluate(() => {
1776+
window.dispatchEvent(new WheelEvent("wheel", {
1777+
bubbles: true,
1778+
cancelable: true,
1779+
deltaY: -120
1780+
}));
1781+
});
17751782

17761783
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Game Controller" })
17771784
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='GameControllerButton']")
@@ -1950,7 +1957,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
19501957
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseButton0']")).toHaveText("Mouse, Left Button, Click");
19511958

19521959
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Game Controller" })
1953-
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='GameControllerButton']")
1960+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='GameControllerStick']")
19541961
.click();
19551962
expect(await captureDisabledState()).toEqual({
19561963
gamepads: [false],
@@ -1983,14 +1990,14 @@ test.describe("Workspace Manager V2 bootstrap", () => {
19831990
await expect(fireTile.locator(".input-mapping-v2__input-token")).toHaveText([
19841991
"Keyboard, KeyH, Hold",
19851992
"Mouse, Left Button, Click",
1986-
"Game Controller, Axis 0+, Button"
1993+
"Game Controller, Axis 0+, Stick"
19871994
]);
19881995
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='KeyH:KeyboardHold']")).toHaveText("Keyboard, KeyH, Hold");
19891996
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseButton0']")).toHaveText("Mouse, Left Button, Click");
1990-
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Pad0:Axis0+:GameControllerButton']")).toHaveText("Game Controller, Axis 0+, Button");
1997+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Pad0:Axis0+']")).toHaveText("Game Controller, Axis 0+, Stick");
19911998
await expect(fireTile.locator(".input-mapping-v2__input-token[data-input-mapping-binding='KeyH:KeyboardHold']")).toHaveAttribute("title", "Keyboard\nKeyH\nHold");
19921999
await expect(fireTile.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseButton0']")).toHaveAttribute("title", "Mouse\nMouse Left Button");
1993-
await expect(fireTile.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Pad0:Axis0+:GameControllerButton']")).toHaveAttribute("title", /Arcade Flight Stick[\s\S]*Axis 0\+[\s\S]*Button/);
2000+
await expect(fireTile.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Pad0:Axis0+']")).toHaveAttribute("title", /Arcade Flight Stick[\s\S]*Axis 0\+/);
19942001
const tokenMetrics = await fireTile.locator(".input-mapping-v2__input-token").evaluateAll((tokens) => tokens.map((token) => ({
19952002
cardWidth: Math.round(token.closest(".input-mapping-v2__mapping-card").getBoundingClientRect().width),
19962003
flexBasis: getComputedStyle(token).flexBasis,
@@ -2090,7 +2097,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
20902097
key: "Control"
20912098
}));
20922099
});
2093-
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText(/Combo capture recorded Keyboard ControlLeft\. Press one more/);
2100+
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText(/Combo capture recorded Keyboard ControlLeft\. Waiting for second input\./);
20942101
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Combo:ControlLeft+KeyS']")).toHaveCount(0);
20952102
await expect(page.locator("#inspectorOutput")).not.toContainText('"binding": "Combo:ControlLeft+KeyS"');
20962103
await expect.poll(() => fireTile.evaluate((card) => card.scrollTop)).toBe(selectedScrollTop);
@@ -2130,6 +2137,208 @@ test.describe("Workspace Manager V2 bootstrap", () => {
21302137
}
21312138
});
21322139

2140+
test("honors Input Mapping V2 gesture-specific capture sessions", async ({ page }) => {
2141+
await page.setViewportSize({ width: 1600, height: 900 });
2142+
await page.addInitScript(() => {
2143+
const buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
2144+
window.__inputMappingV2MockGamepads = [{
2145+
axes: [0, 0, 0, 0],
2146+
buttons,
2147+
connected: true,
2148+
id: "Standard Controller",
2149+
index: 0,
2150+
mapping: "standard",
2151+
timestamp: 70
2152+
}];
2153+
Object.defineProperty(navigator, "getGamepads", {
2154+
configurable: true,
2155+
value: () => window.__inputMappingV2MockGamepads
2156+
});
2157+
});
2158+
const pageErrors = [];
2159+
page.on("pageerror", (error) => {
2160+
pageErrors.push(error.message);
2161+
});
2162+
const server = await openInputMappingV2(page);
2163+
try {
2164+
await expect(page.locator("body[data-tool-id='input-mapping-v2']")).toBeVisible();
2165+
await expect(page.locator(".input-mapping-v2__gamepad-capture-button[data-input-mapping-gamepad-index='0']")).toBeVisible();
2166+
2167+
await page.locator("#inputMappingV2ActionSelect").selectOption("cancel");
2168+
await page.locator("#inputMappingV2AddActionButton").click();
2169+
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Keyboard" })
2170+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='KeyboardRelease']")
2171+
.click();
2172+
await page.locator("#inputMappingV2CaptureKeyboardButton").click();
2173+
await page.keyboard.down("KeyP");
2174+
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText("Keyboard Release recorded KeyP. Waiting for release.");
2175+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='KeyP:KeyboardRelease']")).toHaveCount(0);
2176+
await expect(page.locator("#inputMappingV2CaptureKeyboardButton")).toHaveClass(/is-capturing/);
2177+
await page.keyboard.up("KeyP");
2178+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='KeyP:KeyboardRelease']")).toHaveText("Keyboard, KeyP, Release");
2179+
await expect(page.locator("#inputMappingV2CaptureKeyboardButton")).not.toHaveClass(/is-capturing/);
2180+
2181+
await page.locator("#inputMappingV2ActionSelect").selectOption("confirm");
2182+
await page.locator("#inputMappingV2AddActionButton").click();
2183+
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Keyboard" })
2184+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='KeyboardCombo']")
2185+
.click();
2186+
await page.locator("#inputMappingV2CaptureKeyboardButton").click();
2187+
await page.evaluate(() => {
2188+
window.dispatchEvent(new KeyboardEvent("keydown", {
2189+
bubbles: true,
2190+
cancelable: true,
2191+
code: "ControlLeft",
2192+
ctrlKey: true,
2193+
key: "Control"
2194+
}));
2195+
});
2196+
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText("Combo capture recorded Keyboard ControlLeft. Waiting for second input.");
2197+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Combo:ControlLeft+KeyS']")).toHaveCount(0);
2198+
await expect(page.locator("#inputMappingV2CaptureKeyboardButton")).toHaveClass(/is-capturing/);
2199+
await page.evaluate(() => {
2200+
window.dispatchEvent(new KeyboardEvent("keydown", {
2201+
bubbles: true,
2202+
cancelable: true,
2203+
code: "KeyS",
2204+
ctrlKey: true,
2205+
key: "s"
2206+
}));
2207+
});
2208+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Combo:ControlLeft+KeyS']")).toHaveText("Combo, Ctrl + S");
2209+
await expect(page.locator("#inputMappingV2CaptureKeyboardButton")).not.toHaveClass(/is-capturing/);
2210+
2211+
await page.locator("#inputMappingV2ActionSelect").selectOption("fire");
2212+
await page.locator("#inputMappingV2AddActionButton").click();
2213+
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Mouse" })
2214+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='MouseDoubleClick']")
2215+
.click();
2216+
await page.locator("#inputMappingV2CaptureMouseButton").click();
2217+
await page.evaluate(() => {
2218+
window.dispatchEvent(new WheelEvent("wheel", {
2219+
bubbles: true,
2220+
cancelable: true,
2221+
deltaY: -120
2222+
}));
2223+
});
2224+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Mouse Double Click capture expects mouse button input; wheel input ignored\./);
2225+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseButton0:MouseDoubleClick']")).toHaveCount(0);
2226+
await page.evaluate(() => {
2227+
window.dispatchEvent(new MouseEvent("mousedown", {
2228+
bubbles: true,
2229+
button: 0,
2230+
cancelable: true
2231+
}));
2232+
});
2233+
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText("Mouse Double Click recorded first click on Left Button. Waiting for second click.");
2234+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseButton0:MouseDoubleClick']")).toHaveCount(0);
2235+
await page.evaluate(() => {
2236+
window.dispatchEvent(new MouseEvent("mousedown", {
2237+
bubbles: true,
2238+
button: 0,
2239+
cancelable: true
2240+
}));
2241+
});
2242+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseButton0:MouseDoubleClick']")).toHaveText("Mouse, Left Button, Double Click");
2243+
await expect(page.locator("#inputMappingV2CaptureMouseButton")).not.toHaveClass(/is-capturing/);
2244+
2245+
await page.locator("#inputMappingV2ActionSelect").selectOption("jump");
2246+
await page.locator("#inputMappingV2AddActionButton").click();
2247+
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Mouse" })
2248+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='MouseWheelUp']")
2249+
.click();
2250+
await page.locator("#inputMappingV2CaptureMouseButton").click();
2251+
await page.evaluate(() => {
2252+
window.dispatchEvent(new MouseEvent("mousedown", {
2253+
bubbles: true,
2254+
button: 0,
2255+
cancelable: true
2256+
}));
2257+
});
2258+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Mouse Wheel Up capture expects wheel input; mouse click ignored\./);
2259+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseWheelUp']")).toHaveCount(0);
2260+
await page.evaluate(() => {
2261+
window.dispatchEvent(new WheelEvent("wheel", {
2262+
bubbles: true,
2263+
cancelable: true,
2264+
deltaY: 120
2265+
}));
2266+
});
2267+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Mouse Wheel Up capture expects Wheel Up; Wheel Down was ignored\./);
2268+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseWheelUp']")).toHaveCount(0);
2269+
await page.evaluate(() => {
2270+
window.dispatchEvent(new WheelEvent("wheel", {
2271+
bubbles: true,
2272+
cancelable: true,
2273+
deltaY: -120
2274+
}));
2275+
});
2276+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='MouseWheelUp']")).toHaveText("Mouse, Wheel Up, Wheel");
2277+
await expect(page.locator("#inputMappingV2CaptureMouseButton")).not.toHaveClass(/is-capturing/);
2278+
2279+
await page.locator("#inputMappingV2ActionSelect").selectOption("menu");
2280+
await page.locator("#inputMappingV2AddActionButton").click();
2281+
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Game Controller" })
2282+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='GameControllerTrigger']")
2283+
.click();
2284+
await page.evaluate(() => {
2285+
window.__inputMappingV2MockGamepads[0] = {
2286+
...window.__inputMappingV2MockGamepads[0],
2287+
buttons: window.__inputMappingV2MockGamepads[0].buttons.map((button, index) => ({ pressed: index === 0 })),
2288+
timestamp: 71
2289+
};
2290+
});
2291+
await page.locator(".input-mapping-v2__gamepad-capture-button[data-input-mapping-gamepad-index='0']").click();
2292+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Game Controller Trigger capture expects trigger input; A is button input\./, { timeout: 2500 });
2293+
await expect(page.locator(".input-mapping-v2__mapping-card[data-input-mapping-tile-action-id='menu'] .input-mapping-v2__input-token")).toHaveCount(0);
2294+
await page.evaluate(() => {
2295+
window.__inputMappingV2MockGamepads[0] = {
2296+
...window.__inputMappingV2MockGamepads[0],
2297+
buttons: window.__inputMappingV2MockGamepads[0].buttons.map((button, index) => ({ pressed: index === 6 })),
2298+
timestamp: 72
2299+
};
2300+
});
2301+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Pad0:Button6']")).toHaveText("Game Controller, LT, Trigger", { timeout: 2500 });
2302+
await expect(page.locator(".input-mapping-v2__gamepad-capture-button[data-input-mapping-gamepad-index='0']")).not.toHaveClass(/is-capturing/);
2303+
2304+
await page.evaluate(() => {
2305+
window.__inputMappingV2MockGamepads[0] = {
2306+
...window.__inputMappingV2MockGamepads[0],
2307+
buttons: window.__inputMappingV2MockGamepads[0].buttons.map(() => ({ pressed: false })),
2308+
timestamp: 73
2309+
};
2310+
});
2311+
await page.locator("#inputMappingV2ActionSelect").selectOption("start");
2312+
await page.locator("#inputMappingV2AddActionButton").click();
2313+
await page.locator(".input-mapping-v2__gesture-group", { hasText: "Game Controller" })
2314+
.locator(".input-mapping-v2__gesture-button[data-input-mapping-gesture-binding='GameControllerButton']")
2315+
.click();
2316+
await page.evaluate(() => {
2317+
window.__inputMappingV2MockGamepads[0] = {
2318+
...window.__inputMappingV2MockGamepads[0],
2319+
buttons: window.__inputMappingV2MockGamepads[0].buttons.map((button, index) => ({ pressed: index === 6 })),
2320+
timestamp: 74
2321+
};
2322+
});
2323+
await page.locator(".input-mapping-v2__gamepad-capture-button[data-input-mapping-gamepad-index='0']").click();
2324+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Game Controller Button capture expects button input; LT is trigger input\./, { timeout: 2500 });
2325+
await expect(page.locator(".input-mapping-v2__mapping-card[data-input-mapping-tile-action-id='start'] .input-mapping-v2__input-token")).toHaveCount(0);
2326+
await page.evaluate(() => {
2327+
window.__inputMappingV2MockGamepads[0] = {
2328+
...window.__inputMappingV2MockGamepads[0],
2329+
buttons: window.__inputMappingV2MockGamepads[0].buttons.map((button, index) => ({ pressed: index === 1 })),
2330+
timestamp: 75
2331+
};
2332+
});
2333+
await expect(page.locator(".input-mapping-v2__input-token[data-input-mapping-binding='Pad0:Button1']")).toHaveText("Game Controller, B, Button", { timeout: 2500 });
2334+
await expect(page.locator(".input-mapping-v2__gamepad-capture-button[data-input-mapping-gamepad-index='0']")).not.toHaveClass(/is-capturing/);
2335+
expect(pageErrors).toEqual([]);
2336+
} finally {
2337+
await workspaceV2CoverageReporter.stop(page);
2338+
await server.close();
2339+
}
2340+
});
2341+
21332342
test("launches Input Mapping V2 and captures keyboard mappings", async ({ page }) => {
21342343
await page.setViewportSize({ width: 1920, height: 900 });
21352344
const server = await openInputMappingV2(page);

0 commit comments

Comments
 (0)