Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/uloop-simulate-keyboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Returns JSON with:
- `KeyStateReadUpdateType` (string, nullable): Set for `ReleaseAll`. The `InputState.currentUpdateType` at that device readback (`Editor`, `Dynamic`, `Fixed`, ...). Omitted when empty
- `KeyStateTrackedHeld` (boolean, nullable): Whether Unity CLI Loop's own key-hold tracker considered the key held. Set on KeyDown "already held" / KeyUp "not currently held" rejections, and on successful `KeyUp`
- `KeyStateDeviceIsPressed` (boolean, nullable): Whether `keyboard[key].isPressed` reported pressed at that same moment. Set on the same responses as `KeyStateTrackedHeld`
- `DeferredLatchSyncScheduled` (boolean): Set `true` on successful `ReleaseAll` / `KeyUp` when a one-shot player-update latch sync was scheduled. Omitted when false. The sync runs on the next Dynamic/Fixed/Manual Input System update (not Editor); while PlayMode is paused it therefore waits until resume. Gameplay polling during that same input update may still see a stale press; polling from `Update` after that input update should see the key up
- `InterruptedByPausePoint` / `PausePointId` / `PausePointHitCount` / `PausePointHits`: Pause-point interruption info (all nullable except the boolean). `PausePointHits` lists every marker hit during this input in hit order; `PausePointId` only names the latest one. See the Pause Point Inspection section above
- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge — verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `false`: the input may still have registered late, so check `pause-point-status` for a hit first — a blind retry can consume a re-enabled marker or double-fire the scenario
- `PressEdgeConsumedByUpdateType` / `PressEdgeAnyDynamicUpdateObserved` / `PressEdgeKeyAlreadyPressedBeforeQueue` (nullable): Diagnostics populated only when `PressEdgeObserved` is `false` (all `null` when the edge was observed). `Message` carries the same diagnosis as text. Read them before retrying:
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/uloop-simulate-keyboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Returns JSON with:
- `KeyStateReadUpdateType` (string, nullable): Set for `ReleaseAll`. The `InputState.currentUpdateType` at that device readback (`Editor`, `Dynamic`, `Fixed`, ...). Omitted when empty
- `KeyStateTrackedHeld` (boolean, nullable): Whether Unity CLI Loop's own key-hold tracker considered the key held. Set on KeyDown "already held" / KeyUp "not currently held" rejections, and on successful `KeyUp`
- `KeyStateDeviceIsPressed` (boolean, nullable): Whether `keyboard[key].isPressed` reported pressed at that same moment. Set on the same responses as `KeyStateTrackedHeld`
- `DeferredLatchSyncScheduled` (boolean): Set `true` on successful `ReleaseAll` / `KeyUp` when a one-shot player-update latch sync was scheduled. Omitted when false. The sync runs on the next Dynamic/Fixed/Manual Input System update (not Editor); while PlayMode is paused it therefore waits until resume. Gameplay polling during that same input update may still see a stale press; polling from `Update` after that input update should see the key up
- `InterruptedByPausePoint` / `PausePointId` / `PausePointHitCount` / `PausePointHits`: Pause-point interruption info (all nullable except the boolean). `PausePointHits` lists every marker hit during this input in hit order; `PausePointId` only names the latest one. See the Pause Point Inspection section above
- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge — verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `false`: the input may still have registered late, so check `pause-point-status` for a hit first — a blind retry can consume a re-enabled marker or double-fire the scenario
- `PressEdgeConsumedByUpdateType` / `PressEdgeAnyDynamicUpdateObserved` / `PressEdgeKeyAlreadyPressedBeforeQueue` (nullable): Diagnostics populated only when `PressEdgeObserved` is `false` (all `null` when the edge was observed). `Message` carries the same diagnosis as text. Read them before retrying:
Expand Down
84 changes: 84 additions & 0 deletions Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#if ULOOP_HAS_INPUT_SYSTEM
#nullable enable
using NUnit.Framework;
using UnityEngine.InputSystem.LowLevel;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode
{
/// <summary>
/// Verifies deferred latch-sync fire and one-shot consume rules independently of Input System callbacks.
/// </summary>
public sealed class DeferredPlayerLatchSyncDecisionTests
{
/// <summary>
/// Verifies a Dynamic player update both runs the sync and consumes the one-shot registration.
/// </summary>
[Test]
public void Decide_WhenDynamic_RunsSyncAndUnsubscribes()
{
DeferredLatchSyncTickDecision decision =
DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Dynamic);

Assert.That(decision.ShouldSync, Is.True);
Assert.That(decision.ShouldUnsubscribe, Is.True);
}

/// <summary>
/// Verifies a Fixed player update both runs the sync and consumes the one-shot registration.
/// </summary>
[Test]
public void Decide_WhenFixed_RunsSyncAndUnsubscribes()
{
DeferredLatchSyncTickDecision decision =
DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Fixed);

Assert.That(decision.ShouldSync, Is.True);
Assert.That(decision.ShouldUnsubscribe, Is.True);
}

/// <summary>
/// Verifies a Manual player update both runs the sync and consumes the one-shot registration.
/// </summary>
[Test]
public void Decide_WhenManual_RunsSyncAndUnsubscribes()
{
DeferredLatchSyncTickDecision decision =
DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Manual);

Assert.That(decision.ShouldSync, Is.True);
Assert.That(decision.ShouldUnsubscribe, Is.True);
}

/// <summary>
/// Verifies an Editor update neither syncs nor consumes the registration, so a paused
/// PlayMode session still syncs on the first player update after resume.
/// </summary>
[Test]
public void Decide_WhenEditor_DoesNotSyncAndDoesNotUnsubscribe()
{
DeferredLatchSyncTickDecision decision =
DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Editor);

Assert.That(decision.ShouldSync, Is.False);
Assert.That(decision.ShouldUnsubscribe, Is.False);
}

/// <summary>
/// Verifies the combined Default mask does not count as a player update. Why not HasFlag:
/// Default includes Editor, and treating it as fireable would consume the one-shot during
/// editor-only pause updates.
/// </summary>
[Test]
public void Decide_WhenDefaultMask_DoesNotSyncAndDoesNotUnsubscribe()
{
DeferredLatchSyncTickDecision decision =
DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Default);

Assert.That(decision.ShouldSync, Is.False);
Assert.That(decision.ShouldUnsubscribe, Is.False);
}
}
}
#endif
11 changes: 11 additions & 0 deletions Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 138 additions & 1 deletion Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public override void Setup()

public override void TearDown()
{
DeferredPlayerLatchSynchronizer.ResetForTests();
KeyboardKeyState.ReleaseAllKeys();
SimulateKeyboardOverlayState.Clear();
InputVisualizationCanvas[] canvases =
Expand Down Expand Up @@ -106,7 +107,11 @@ public IEnumerator ReleaseAll_AfterHeldKey_WiresReleasedKeyStatesOntoResponse()
Assert.That(dState!.DeviceIsPressedAfterRelease, Is.EqualTo(keyboard[Key.D].isPressed));
Assert.That(lastResponse.KeyStateReadUpdateType, Is.Not.Empty);
Assert.That(lastResponse.KeyStateReadUpdateType, Is.EqualTo(InputState.currentUpdateType.ToString()));
Assert.That(lastResponse.Message, Is.EqualTo("Released 1 key(s): D"));
Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.True);
Assert.That(
lastResponse.Message,
Is.EqualTo(
"Released 1 key(s): D A deferred latch sync will run on the next player input update."));
}

/// <summary>
Expand All @@ -125,6 +130,7 @@ public IEnumerator ReleaseAll_WhenNothingHeld_ReturnsEmptyReleasedKeysAndStates(
Assert.IsTrue(lastResponse.Success);
Assert.That(lastResponse.ReleasedKeys, Is.Empty);
Assert.That(lastResponse.ReleasedKeyStates, Is.Empty);
Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.False);
}

/// <summary>
Expand Down Expand Up @@ -152,6 +158,108 @@ public IEnumerator KeyUp_WhenHeld_ReportsTrackedAndDeviceKeyState()
Assert.That(lastResponse.KeyStateTrackedHeld, Is.EqualTo(KeyboardKeyState.IsKeyHeld(Key.W)));
Assert.That(lastResponse.KeyStateDeviceIsPressed, Is.Not.Null);
Assert.That(lastResponse.KeyStateDeviceIsPressed, Is.EqualTo(keyboard[Key.W].isPressed));
Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.True);
}

/// <summary>
/// Verifies KeyDown then ReleaseAll leaves the device unpressed after player update frames,
/// including the deferred latch-sync path.
/// </summary>
[UnityTest]
public IEnumerator ReleaseAll_AfterHeldKey_DeviceIsUnpressedAfterPlayerUpdateFrames()
{
yield return null;

yield return RunTool(new JObject
{
["action"] = KeyboardAction.KeyDown.ToString(),
["key"] = "D"
});
Assert.IsTrue(lastResponse.Success);

yield return RunTool(new JObject
{
["action"] = UnityCliLoopKeyboardAction.ReleaseAll.ToString()
});
Assert.IsTrue(lastResponse.Success);
Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.True);

for (int frame = 0; frame < 5; frame++)
{
InputSystem.Update();
yield return null;
}

Assert.That(keyboard[Key.D].isPressed, Is.False);
}

/// <summary>
/// Verifies KeyDown after ReleaseAll of the same key is not ForceSync-released by the
/// deferred latch callback: a tracked re-press must stay pressed across player updates.
/// </summary>
[UnityTest]
public IEnumerator KeyDown_AfterReleaseAll_SameKeyStaysPressedAcrossPlayerUpdates()
{
yield return null;

yield return RunTool(new JObject
{
["action"] = KeyboardAction.KeyDown.ToString(),
["key"] = "D"
});
Assert.IsTrue(lastResponse.Success);

yield return RunTool(new JObject
{
["action"] = UnityCliLoopKeyboardAction.ReleaseAll.ToString()
});
Assert.IsTrue(lastResponse.Success);

yield return RunTool(new JObject
{
["action"] = KeyboardAction.KeyDown.ToString(),
["key"] = "D"
});
Assert.IsTrue(lastResponse.Success);
Assert.That(keyboard[Key.D].isPressed, Is.True);
Assert.That(KeyboardKeyState.IsKeyHeld(Key.D), Is.True);

// Why re-arm: UnityTest frame pumps between ReleaseAll and the second KeyDown
// already consume the ReleaseAll one-shot. Scheduling again while the tracker
// holds is the stale-shaped callback the IsKeyHeld skip must ignore.
bool scheduled = DeferredPlayerLatchSynchronizer.Schedule(new Key[] { Key.D });
Assert.That(scheduled, Is.True);

for (int frame = 0; frame < 5; frame++)
{
InputSystem.Update();
yield return null;
}

Assert.That(keyboard[Key.D].isPressed, Is.True);
}

/// <summary>
/// Verifies Schedule plus a player update ForceSyncs a device press that the tracker
/// does not own, so deleting the onAfterUpdate subscription cannot satisfy this test.
/// </summary>
[UnityTest]
public IEnumerator Schedule_WhenDevicePressedWithoutTracker_ForceSyncsUnpressedOnNextPlayerUpdate()
{
yield return null;

InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D));
InputSystem.Update();
Assert.That(keyboard[Key.D].isPressed, Is.True);
Assert.That(KeyboardKeyState.IsKeyHeld(Key.D), Is.False);

bool scheduled = DeferredPlayerLatchSynchronizer.Schedule(new Key[] { Key.D });
Assert.That(scheduled, Is.True);

InputSystem.Update();
yield return null;

Assert.That(keyboard[Key.D].isPressed, Is.False);
}

private IEnumerator RunTool(JObject parameters)
Expand Down Expand Up @@ -247,6 +355,35 @@ public void AppendStillPressedNote_WhenNoKeyStillPressed_ReturnsMessageUnchanged

Assert.That(message, Is.EqualTo("Released 2 key(s): D, W"));
}

/// <summary>
/// Verifies the deferred latch-sync note matches the exact English sentence callers will see.
/// </summary>
[Test]
public void AppendDeferredLatchSyncNote_WhenScheduled_AppendsExactLiteral()
{
string message = SimulateKeyboardReleaseMessageFormatter.AppendDeferredLatchSyncNote(
"Released 1 key(s): D",
true);

Assert.That(
message,
Is.EqualTo(
"Released 1 key(s): D A deferred latch sync will run on the next player input update."));
}

/// <summary>
/// Verifies the deferred latch-sync note is omitted when nothing was scheduled.
/// </summary>
[Test]
public void AppendDeferredLatchSyncNote_WhenNotScheduled_ReturnsMessageUnchanged()
{
string message = SimulateKeyboardReleaseMessageFormatter.AppendDeferredLatchSyncNote(
"Released all keys (none were held).",
false);

Assert.That(message, Is.EqualTo("Released all keys (none were held)."));
}
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions Assets/Tests/PlayMode/SimulateKeyboardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public override void TearDown()
InputSettings settings = RequireInputSettings();
settings.updateMode = originalUpdateMode;
Time.timeScale = originalTimeScale;
DeferredPlayerLatchSynchronizer.ResetForTests();
KeyboardKeyState.ReleaseAllKeys();
SimulateKeyboardOverlayState.Clear();
InputVisualizationCanvas[] canvases =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#if ULOOP_HAS_INPUT_SYSTEM
#nullable enable

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Outcome of one deferred latch-sync callback tick: whether to run the player-view
/// sync, and whether to consume the one-shot registration.
/// </summary>
internal readonly struct DeferredLatchSyncTickDecision
{
internal DeferredLatchSyncTickDecision(bool shouldSync, bool shouldUnsubscribe)
{
ShouldSync = shouldSync;
ShouldUnsubscribe = shouldUnsubscribe;
}

internal bool ShouldSync { get; }

internal bool ShouldUnsubscribe { get; }
}
}
#endif

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#if ULOOP_HAS_INPUT_SYSTEM
#nullable enable
using UnityEngine.InputSystem.LowLevel;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Pure fire and one-shot consume rules for deferred player-view key latch sync.
/// </summary>
internal static class DeferredPlayerLatchSyncDecision
{
/// <summary>
/// Decides whether this Input System update should sync stale player latches and
/// then drop the callback. Why Editor is kept: pause only runs Editor updates, so
/// consuming the one-shot there would skip the first player update after resume.
/// Why not HasFlag: Default includes Editor and would look like a player update.
/// </summary>
internal static DeferredLatchSyncTickDecision Decide(InputUpdateType currentUpdateType)
{
bool isPlayerUpdate = currentUpdateType == InputUpdateType.Dynamic
|| currentUpdateType == InputUpdateType.Fixed
|| currentUpdateType == InputUpdateType.Manual;
return new DeferredLatchSyncTickDecision(isPlayerUpdate, isPlayerUpdate);
}
}
}
#endif

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading