diff --git a/.agents/skills/uloop-record-input/SKILL.md b/.agents/skills/uloop-record-input/SKILL.md index 40859135fa..d3481797b3 100644 --- a/.agents/skills/uloop-record-input/SKILL.md +++ b/.agents/skills/uloop-record-input/SKILL.md @@ -30,7 +30,7 @@ uloop record-input --action Stop --output-path scripts/my-play.json |-----------|------|---------|-------------| | `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | | `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter (for example `W,A,S,D,Space`). Empty records all common game keys | +| `--keys` | string | `""` | Comma-separated key filter of Input System Key enum names (for example `W,A,S,D,Space`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys | | `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | | `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | diff --git a/.claude/skills/uloop-record-input/SKILL.md b/.claude/skills/uloop-record-input/SKILL.md index 40859135fa..d3481797b3 100644 --- a/.claude/skills/uloop-record-input/SKILL.md +++ b/.claude/skills/uloop-record-input/SKILL.md @@ -30,7 +30,7 @@ uloop record-input --action Stop --output-path scripts/my-play.json |-----------|------|---------|-------------| | `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | | `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter (for example `W,A,S,D,Space`). Empty records all common game keys | +| `--keys` | string | `""` | Comma-separated key filter of Input System Key enum names (for example `W,A,S,D,Space`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys | | `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | | `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | diff --git a/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs new file mode 100644 index 0000000000..f62b1bdbce --- /dev/null +++ b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs @@ -0,0 +1,91 @@ +#if ULOOP_HAS_INPUT_SYSTEM +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine.InputSystem; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the record-input key filter accepts only defined key names. + /// + public sealed class InputRecordingKeyFilterTests + { + /// + /// Tests that named keys are accepted case-insensitively and no name is reported invalid. + /// + [Test] + public void ParseKeyFilter_WhenGivenKeyNames_KeepsThemAll() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("space, w"); + + Assert.IsEmpty(result.InvalidKeyNames); + Assert.IsNotNull(result.Filter); + Assert.AreEqual(new HashSet { Key.Space, Key.W }, result.Filter); + } + + /// + /// Tests that a bare digit is reported invalid instead of silently filtering the key whose + /// enum ordinal it happens to be. + /// + [Test] + public void ParseKeyFilter_WhenGivenAnOrdinal_ReportsItInvalid() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("3"); + + Assert.AreEqual(new[] { "3" }, result.InvalidKeyNames); + Assert.IsNull(result.Filter); + } + + /// + /// Tests that an invalid entry alongside a valid one is still reported, so a partially + /// applied filter is never mistaken for the requested one. + /// + [Test] + public void ParseKeyFilter_WhenOneEntryIsInvalid_ReportsThatEntry() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("W, 3"); + + Assert.AreEqual(new[] { "3" }, result.InvalidKeyNames); + } + + /// + /// Tests that a filter made only of empty entries is reported invalid: it would otherwise + /// record every key while looking like no filter was requested. + /// + [Test] + public void ParseKeyFilter_WhenEveryEntryIsEmpty_ReportsTheRawInputInvalid() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter(", ,"); + + Assert.AreEqual(new[] { ", ," }, result.InvalidKeyNames); + Assert.IsNull(result.Filter); + } + + /// + /// Tests that a trailing comma is harmless once another entry names a key. + /// + [Test] + public void ParseKeyFilter_WhenAnEntryIsEmptyBesideANamedKey_KeepsTheKey() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("W,"); + + Assert.IsEmpty(result.InvalidKeyNames); + Assert.AreEqual(new HashSet { Key.W }, result.Filter); + } + + /// + /// Tests that no filter and no invalid name is reported when the parameter is omitted. + /// + [Test] + public void ParseKeyFilter_WhenGivenNothing_ReportsNoFilter() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter(""); + + Assert.IsEmpty(result.InvalidKeyNames); + Assert.IsNull(result.Filter); + } + } +} +#endif diff --git a/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs.meta b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs.meta new file mode 100644 index 0000000000..fe4c257054 --- /dev/null +++ b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8afa56a901fe140609ebf4a9c0e8a193 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/KeyNameResolverTests.cs b/Assets/Tests/Editor/KeyNameResolverTests.cs new file mode 100644 index 0000000000..c4fe5df597 --- /dev/null +++ b/Assets/Tests/Editor/KeyNameResolverTests.cs @@ -0,0 +1,111 @@ +#if ULOOP_HAS_INPUT_SYSTEM +using NUnit.Framework; +using UnityEngine.InputSystem; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies key names resolve only through defined Key enum names. + /// + public sealed class KeyNameResolverTests + { + /// + /// Tests that a defined key name resolves regardless of the casing it was written in. + /// + [Test] + public void Resolve_WhenGivenADefinedNameInAnyCasing_ResolvesTheKey() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("space"); + + Assert.IsTrue(resolved); + Assert.AreEqual(Key.Space, key); + } + + /// + /// Tests that surrounding whitespace does not stop a defined name from resolving. + /// + [Test] + public void Resolve_WhenGivenAPaddedName_ResolvesTheKey() + { + (bool resolved, Key key) = KeyNameResolver.Resolve(" W "); + + Assert.IsTrue(resolved); + Assert.AreEqual(Key.W, key); + } + + /// + /// Tests that "Return" keeps resolving to Enter, the alias callers already relied on. + /// + [Test] + public void Resolve_WhenGivenTheReturnAlias_ResolvesEnter() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("Return"); + + Assert.IsTrue(resolved); + Assert.AreEqual(Key.Enter, key); + } + + /// + /// Tests that a bare digit is rejected instead of being read as an enum ordinal. + /// + [Test] + public void Resolve_WhenGivenAnOrdinal_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("3"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that a signed ordinal is rejected: Enum.TryParse accepted it as a value too. + /// + [Test] + public void Resolve_WhenGivenASignedOrdinal_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("-1"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that an ordinal outside the enum is rejected rather than producing an undefined key. + /// + [Test] + public void Resolve_WhenGivenAnUndefinedOrdinal_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("300"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that comma-separated names are rejected instead of being OR-ed into one value. + /// + [Test] + public void Resolve_WhenGivenCommaSeparatedNames_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("Space,Enter"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that the placeholder None value is rejected: it names no physical key. + /// + [Test] + public void Resolve_WhenGivenNone_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("None"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + } +} +#endif diff --git a/Assets/Tests/Editor/KeyNameResolverTests.cs.meta b/Assets/Tests/Editor/KeyNameResolverTests.cs.meta new file mode 100644 index 0000000000..1a6fa3479f --- /dev/null +++ b/Assets/Tests/Editor/KeyNameResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1157c682c6684c189f34872ba911a8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs new file mode 100644 index 0000000000..ff989663d6 --- /dev/null +++ b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs @@ -0,0 +1,57 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode +{ + /// + /// Test fixture that verifies record-input refuses to start when the key filter names no key. + /// Runs in PlayMode because the rejection sits behind the PlayMode preflight. + /// + public sealed class RecordInputKeyFilterRejectionTests + { + [TearDown] + public void TearDown() + { + // A regression here starts a real recording, which would leak into the next test. + if (InputRecorder.IsRecording) + { + InputRecorder.StopRecording(); + } + } + + /// + /// Tests that a key filter naming no key fails the command instead of recording every key. + /// + [UnityTest] + public IEnumerator RecordInput_WhenTheKeyFilterNamesNoKey_FailsWithoutRecording() + { + RecordInputSchema request = new() + { + Action = RecordInputAction.Start, + Keys = "3", + DelaySeconds = 0, + ShowOverlay = false + }; + + Task execution = new RecordInputUseCase().RecordInputAsync(request, CancellationToken.None); + while (!execution.IsCompleted) + { + yield return null; + } + + RecordInputResponse response = execution.Result; + Assert.IsFalse(response.Success, response.Message); + StringAssert.Contains("Invalid key name(s) in the keys filter: 3", response.Message); + Assert.IsFalse(InputRecorder.IsRecording); + } + } +} +#endif diff --git a/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs.meta b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs.meta new file mode 100644 index 0000000000..476c7bb13d --- /dev/null +++ b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2181da05a0170455b82212942745e63d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs index 90e5df343e..f7c77af1f0 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs @@ -92,14 +92,20 @@ public static string ResolveLatestRecording(string inputPath) return files.OrderByDescending(f => File.GetLastWriteTimeUtc(f)).First(); } - public static HashSet? ParseKeyFilter(string keys) + /// + /// Parses the comma-separated key filter into the keys to record. Entries that name no key + /// are reported rather than skipped: dropping them silently would record a different set of + /// keys than the caller asked for, and dropping all of them would record every key. + /// + public static KeyFilterParseResult ParseKeyFilter(string keys) { if (string.IsNullOrEmpty(keys)) { - return null; + return new KeyFilterParseResult(null, Array.Empty()); } HashSet filter = new(); + List invalidKeyNames = new(); string[] parts = keys.Split(','); for (int i = 0; i < parts.Length; i++) @@ -110,17 +116,26 @@ public static string ResolveLatestRecording(string inputPath) continue; } - if (Enum.TryParse(trimmed, ignoreCase: true, out Key key) && key != Key.None) - { - filter.Add(key); - } - else + (bool resolved, Key key) = KeyNameResolver.Resolve(trimmed); + if (!resolved) { - Debug.LogWarning($"[InputRecordingFileHelper] Unknown key name in filter: '{trimmed}'"); + invalidKeyNames.Add(trimmed); + continue; } + + filter.Add(key); + } + + if (filter.Count == 0 && invalidKeyNames.Count == 0) + { + // Every entry was empty (for example "," or " "), so the filter would fall back to + // recording every key while the response looked like no filter was ever given. + // Why not reject the empty entries themselves: a trailing comma in "W," is harmless + // once at least one entry names a key. + invalidKeyNames.Add(keys); } - return filter.Count > 0 ? filter : null; + return new KeyFilterParseResult(filter.Count > 0 ? filter : null, invalidKeyNames); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs new file mode 100644 index 0000000000..0c1abaa2e2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs @@ -0,0 +1,27 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections.Generic; +using UnityEngine.InputSystem; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Outcome of parsing a comma-separated key filter: the keys to record, plus every entry that + /// named no key. Why carry the rejected entries instead of dropping them: a filter that lost + /// entries records something other than what was asked for, and the caller cannot tell that + /// from a filter that was never given. + /// + internal sealed class KeyFilterParseResult + { + public KeyFilterParseResult(HashSet? filter, IReadOnlyList invalidKeyNames) + { + Filter = filter; + InvalidKeyNames = invalidKeyNames; + } + + public HashSet? Filter { get; } + + public IReadOnlyList InvalidKeyNames { get; } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs.meta new file mode 100644 index 0000000000..e816f6faac --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6ed0f03d8bd4407691333639c5611fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs new file mode 100644 index 0000000000..3de90c343c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs @@ -0,0 +1,68 @@ +#if ULOOP_HAS_INPUT_SYSTEM +using System; +using System.Collections.Generic; +using UnityEngine.InputSystem; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves Input System Key values from the key names tools accept, so every tool applies the + /// same rule for what counts as a key name. + /// + internal static class KeyNameResolver + { + // Immutable name-to-value map of the Input System Key enum, so key resolution never falls + // back to Enum.TryParse's ordinal and flag-combination behavior. + private static readonly IReadOnlyDictionary DefinedKeysByName = BuildDefinedKeysByName(); + + /// + /// Resolves a raw key name to its Key value, reporting whether it named a key at all. + /// + public static (bool resolved, Key key) Resolve(string keyName) + { + // Why not Enum.TryParse: it also accepts ordinals ("3"), signed ordinals ("+3"), + // whitespace-padded input, comma-separated names OR-ed together ("Space,Enter"), and + // undefined ordinals ("300") that later throw from the keyboard indexer. Only a name + // that is defined on the Key enum may resolve to a key. + string normalizedKey = NormalizeKeyName(keyName); + if (!DefinedKeysByName.TryGetValue(normalizedKey, out Key key) || key == Key.None) + { + return (false, Key.None); + } + + return (true, key); + } + + /// + /// Trims the raw name and applies the Return alias, giving callers the form the whitelist + /// is keyed by so their diagnostics can describe the same value that was looked up. + /// + public static string NormalizeKeyName(string keyName) + { + // Why trim here rather than at the whitelist comparison: Enum.TryParse used to accept + // whitespace-padded names, so padded correct input already worked. Blocking ambiguous + // input must not narrow correct input, and the alias has to see the padded form too. + string trimmed = keyName.Trim(); + if (string.Equals(trimmed, "Return", StringComparison.OrdinalIgnoreCase)) + { + return Key.Enter.ToString(); + } + + return trimmed; + } + + private static IReadOnlyDictionary BuildDefinedKeysByName() + { + string[] names = Enum.GetNames(typeof(Key)); + Array values = Enum.GetValues(typeof(Key)); + Dictionary keysByName = new(names.Length, StringComparer.OrdinalIgnoreCase); + for (int index = 0; index < names.Length; index++) + { + keysByName[names[index]] = (Key)values.GetValue(index); + } + + return keysByName; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs.meta new file mode 100644 index 0000000000..79bba0de6f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e3c265151ed404bb1aa2e42f44205e01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs index 2ca4bc94f9..dda4f02e88 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs @@ -141,7 +141,23 @@ private static async Task ExecuteStartAsync( } int delaySeconds = Mathf.Clamp(request.DelaySeconds, RecordInputConstants.MIN_DELAY_SECONDS, RecordInputConstants.MAX_DELAY_SECONDS); - HashSet? keyFilter = InputRecordingFileHelper.ParseKeyFilter(request.Keys); + KeyFilterParseResult keyFilterResult = InputRecordingFileHelper.ParseKeyFilter(request.Keys); + if (keyFilterResult.InvalidKeyNames.Count > 0) + { + // Why reject instead of recording what did parse: a recording is taken once, and a + // filter that quietly lost entries produces a file that looks like the requested + // one. Entries that all failed would record every key, the opposite of the request. + return new RecordInputResponse + { + Success = false, + Message = + $"Invalid key name(s) in the keys filter: {string.Join(", ", keyFilterResult.InvalidKeyNames)}. " + + "Use Input System Key enum names (e.g. \"W\", \"Space\", \"LeftShift\", \"Digit3\").", + Action = RecordInputAction.Start.ToString() + }; + } + + HashSet? keyFilter = keyFilterResult.Filter; if (request.ShowOverlay) { diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md index 40859135fa..d3481797b3 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md @@ -30,7 +30,7 @@ uloop record-input --action Stop --output-path scripts/my-play.json |-----------|------|---------|-------------| | `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | | `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter (for example `W,A,S,D,Space`). Empty records all common game keys | +| `--keys` | string | `""` | Comma-separated key filter of Input System Key enum names (for example `W,A,S,D,Space`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys | | `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | | `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 3520090e25..5fbce21030 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -78,15 +77,12 @@ public async Task ExecuteAsync( }; } - // Why not Enum.TryParse alone: it also accepts ordinals ("3"), signed ordinals ("+3"), - // whitespace-padded input, comma-separated names OR-ed together ("Space,Enter"), and - // undefined ordinals ("300") that later throw from the keyboard indexer. Only a name - // that is defined on the Key enum may resolve to a key. - string normalizedKey = NormalizeKeyName(parameters.Key); - if (!DefinedKeysByName.TryGetValue(normalizedKey, out Key key) || key == Key.None) + (bool resolved, Key key) = KeyNameResolver.Resolve(parameters.Key); + if (!resolved) { // Suggest from the normalized form so padding does not degrade the candidates, // while the message below still reports the raw input verbatim. + string normalizedKey = KeyNameResolver.NormalizeKeyName(parameters.Key); IReadOnlyList suggestions = KeyboardKeyNameSuggester.Suggest(normalizedKey); string suggestionText = suggestions.Count == 0 ? string.Empty @@ -242,23 +238,6 @@ private static void EnsureOverlayExists() OverlayCanvasFactory.EnsureExists(); } - // Immutable name-to-value map of the Input System Key enum, so key resolution never falls - // back to Enum.TryParse's ordinal and flag-combination behavior. - private static readonly IReadOnlyDictionary DefinedKeysByName = BuildDefinedKeysByName(); - - private static IReadOnlyDictionary BuildDefinedKeysByName() - { - string[] names = Enum.GetNames(typeof(Key)); - Array values = Enum.GetValues(typeof(Key)); - Dictionary keysByName = new(names.Length, StringComparer.OrdinalIgnoreCase); - for (int index = 0; index < names.Length; index++) - { - keysByName[names[index]] = (Key)values.GetValue(index); - } - - return keysByName; - } - /// /// Reports whether the raw key input is the numeric form that Enum.TryParse used to accept /// as an enum ordinal, so the rejection can explain what earlier runs actually pressed. @@ -289,18 +268,6 @@ private static bool LooksLikeNumericKeyInput(string keyName) return true; } - private static string NormalizeKeyName(string keyName) - { - // Why trim here rather than at the whitelist comparison: Enum.TryParse used to accept - // whitespace-padded names, so padded correct input already worked. Blocking ambiguous - // input must not narrow correct input, and the alias has to see the padded form too. - string trimmed = keyName.Trim(); - if (string.Equals(trimmed, "Return", StringComparison.OrdinalIgnoreCase)) - { - return Key.Enter.ToString(); - } - return trimmed; - } #endif } diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 6f3eb5e7e9..df36cb6ffd 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -654,7 +654,7 @@ }, "Keys": { "type": "string", - "description": "Comma-separated key filter (for example W,A,S,D,Space). Empty records all common game keys", + "description": "Comma-separated key filter of Input System Key enum names (for example W,A,S,D,Space). Case-insensitive. Digit keys use Digit0-Digit9 or Numpad0-Numpad9, not bare 0-9; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys", "default": "" }, "DelaySeconds": { diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 1ea01b792d..5750f15ebe 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "b9bc288d3a76099a1fe4cee8843e510bf5bbe632" + "sharedInputsHash": "0313515f1add4e1c394e1957c0aff7215e39e678" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 1c3005f88e..086c6efc93 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "f6f1ad4c29722943c9a66a64cfc68d706ff8b141" + "sharedInputsHash": "b85b6271eda91daf75fb620f635266c7a709f90b" }