diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml new file mode 100644 index 0000000..005ac2f --- /dev/null +++ b/.github/workflows/release-preflight.yml @@ -0,0 +1,84 @@ +# Release preflight — required status check for develop -> master PRs. +# +# Installed into each package repo by: +# release.py install-preflight +# +# WHY THIS EXISTS +# These are solo repos: GitHub refuses to let a PR author review their own PR +# (HTTP 422 "Review cannot be requested from pull request author"), so a human +# review gate is unavailable. This gives a real blocking gate instead — the same +# package-local gates the release driver enforces, run before the merge rather +# than after it. +# +# THIS FILE MUST STAY OUT OF THE PUBLISHED TARBALL +# `.github/` IS packed by default — the published googlesheetimporter 0.7.2 asset +# still contains `package/.github/workflows/openai.yml`. Each package therefore +# lists `.github/` in its `.gitignore`, which Unity's packer uses as its +# pack-ignore list; git keeps tracking the file regardless, since .gitignore does +# not untrack existing paths. Measured on a real clone: 434 -> 433 entries, +# `.github` 1 -> 0, `Runtime` unchanged. +# +# Do NOT verify this on a copy with `.git` removed: the packer behaves differently +# without a repo and reports `.github` as excluded when it is not. `G24` also +# surfaces it as an unexpected added file if the ignore line is ever dropped. +# +# WHAT IT DOES NOT CHECK +# Only gates decidable from the package directory plus the base ref, so the check +# needs no token, no submodules and no network: G7 (bare SemVer), G8/G9 (CHANGELOG +# heading matches package.json and is newest+highest), G10 (date sane), G11 +# (version advances past master), G15 (the PR touches both files). Remote-state +# gates (G0-G6, G12-G14) and the whole tarball chain (G20-G27) run locally in +# `release.py preflight` / `pack` before the PR is opened. + +name: release-preflight + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +concurrency: + group: release-preflight-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preflight: + runs-on: ubuntu-latest + steps: + # `lfs: true` is load-bearing, not a nicety. The default (false) leaves every + # LFS-tracked file as a ~130-byte pointer stub, which would make G28 fail on + # every PR. With it on, G28 becomes a genuine check that the LFS objects are + # FETCHABLE from the remote — the exact failure that hit uiservice, where the + # working tree held stubs while the published 1.2.1 had real content. + - name: Check out the package + uses: actions/checkout@v4 + with: + fetch-depth: 0 + lfs: true + persist-credentials: false + + # The gate logic is shared rather than vendored into six repos, so there is + # one source of truth. `ref` is explicit: actions/checkout defaults to the + # target repo's DEFAULT branch (master), where the tooling does not exist + # yet — omitting it fails with "No such file or directory". Retarget this to + # master once the skill is merged there. + - name: Check out the release tooling + uses: actions/checkout@v4 + with: + repository: CoderGamester/Frameworks + ref: develop + path: .release-tooling + sparse-checkout: .claude/skills/unity-package-release/scripts + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Preflight + run: | + python3 .release-tooling/.claude/skills/unity-package-release/scripts/release.py \ + preflight-pr --path . --base "origin/${{ github.base_ref }}" diff --git a/.gitignore b/.gitignore index 0c49860..30aeff1 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ crashlytics-build.properties # Tests audit history (unity-tests-audit skill -- local developer state, never committed) .audit-history.md + +# CI config: tracked in git, excluded from the published UPM tarball +.github/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2c00760 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# GameLovers.Statechart - AI Agent Guide + +> **Companion files**: `CLAUDE.md` wraps this file for Claude Code — edit `AGENTS.md`, not `CLAUDE.md`. `README.md` is the user-facing entry point. + +## 1. Package Overview +- **Package**: `com.gamelovers.statechart` +- **Unity**: 2022.3+ (the only package in the family with a floor below 6000.0 — verify before assuming 6000.0-only APIs are safe here) +- **Dependencies** (see `package.json`) + - `com.cysharp.unitask` (2.5.10): `ITaskWaitState.WaitingFor(Func)` overload + +Hierarchical State Machine (HFSM / Statechart, per the [UML spec](http://www.omg.org/spec/UML) and [statecharts.github.io](https://statecharts.github.io/what-is-a-statechart.html)) — states can nest, split into parallel regions, and run async waits, all defined once in a constructor setup closure with no further runtime mutation. Smallest package in the family: no `Editor/` assembly, no `Samples~/`, single `Runtime/` assembly. + +## 2. Runtime Architecture (high level) + +### The chart itself +- **`Statechart`** (`Runtime/Statechart.cs`, implements `IStatechart : IStateMachineDebug`) is the entry point. Construct with `new Statechart(Action setup)` — the setup closure runs immediately and defines every state and transition; there is no API to add states after construction. Throws `MissingMemberException` if the setup never calls `factory.Initial(...)`. + - `Run()` — starts/resumes execution from wherever the chart is anchored. No-op if already running. + - `Trigger(IStatechartEvent trigger)` — processes an event with run-to-completion semantics. **No-op if the chart isn't running** (`Pause()`d or never `Run()` called) — a common "my event did nothing" cause. + - `Pause()` — stops processing; `Run()` resumes from the same point. + - `Reset()` — jumps back to the initial state. Does **not** implicitly pause or resume; if the chart was waiting on an event, it needs a fresh `Run()` to continue after reset. + - `LogsEnabled` (from `IStateMachineDebug`) — per-chart debug logging toggle; `IState.LogsEnabled` is the same toggle per-state. + - `CurrentState` (string name) is exposed **only under `#if UNITY_EDITOR`** — do not reference it from a runtime code path that also needs to compile for a player build. + - In `#if UNITY_EDITOR || DEBUG`, every state's `Validate()` runs once at construction — this is where the "missing transition target" / "transition loop" exceptions below come from. +- **`IStateFactory`** (`Runtime/IStateFactory.cs`) is passed into the setup closure — one factory instance per region (top-level chart, and one more per `Nest`/`Split` sub-region). Each factory method takes a `name` string (debug-only identity) and returns the state's own narrow interface: + + | Factory method | Returns | Purpose | + |---|---|---| + | `Initial(name)` | `IInitialState` | Entry point of the region. Exactly one per region. | + | `Final(name)` | `IFinalState` | Marks the region complete. | + | `State(name)` | `ISimpleState` | Blocks until an `Event(...)` transition fires. | + | `Transition(name)` | `ITransitionState` | Non-blocking; falls through to its target immediately. | + | `Nest(name)` | `INestState` | Opens one new nested (sequential) region. | + | `Choice(name)` | `IChoiceState` | Non-blocking; picks among `Transition().Condition(...)` branches. | + | `Wait(name)` | `IWaitState` | Blocks on `IWaitActivity` completion (and/or an event). | + | `TaskWait(name)` | `ITaskWaitState` | Blocks on a `Task`/`UniTask`; cannot process events while waiting. | + | `Split(name)` | `ISplitState` | Opens two-or-more new nested **parallel** regions. | + | `Leave(name)` | `ILeaveState` | Like `Final`, but its transition targets a state in an **ancestor** region, jumping out of the current `Nest`/`Split` by exactly one layer. | + +### State capability interfaces (`Runtime/IState.cs`) +Every concrete state interface above is composed from these narrower capability interfaces — check which ones a state implements to know what it can do: +- `IStateEnter.OnEnter(Action)` / `IStateExit.OnExit(Action)` — lifecycle callbacks. +- `IStateTransition.Transition()` → `ITransition` — unconditional transition (used by `Initial`/`Transition`/`Leave` states). +- `IStateEvent.Event(IStatechartEvent)` → `ITransition` — event-triggered transition (used by `State`/`Nest`/`Wait`/`Split` states). +- `ITransition.OnTransition(Action)` (chainable) + `.Target(IState)` (terminal — every transition must call this or `Statechart`'s validation throws). +- `ITransitionCondition : ITransition` adds `.Condition(Func)` — used exclusively by `IChoiceState.Transition()`; a choice with a failing condition does not transition (evaluate carefully — a choice state with no satisfied condition stalls the chart there). + +### Nesting and parallelism +- **`INestState.Nest(Action | NestedStateData)`** opens a new sequential sub-region; its returned `ITransition` fires once that sub-region reaches its `Final`. The `NestedStateData` overload (`Setup` + `ExecuteExit` + `ExecuteFinal` bools) controls whether the *parent* nest state's own `OnExit` and the *sub-region's* `IFinalState.OnEnter` actually run when leaving via a `Leave` state from inside vs. via normal completion — the plain `Action` overload defaults both to `true`. +- **`ISplitState.Split(params Action[] | NestedStateData[])`** opens N parallel sub-regions simultaneously; its `ITransition` fires only once **all** sub-regions reach their own `Final`. +- **`ILeaveState`** exits a `Nest`/`Split` region early, targeting a state in the parent region directly — bypasses the nest/split's own completion transition. Can only jump one region layer (an inner `Leave` inside a doubly-nested region still only reaches its immediate parent, not the top level). + +### Waiting states +- **`IWaitState.WaitingFor(Action)`** — the action receives an `IWaitActivity` (`Runtime/IWaitActivity.cs`); call `.Complete()` when the awaited work finishes, or `.Split()` first to fan out into multiple sub-activities whose *own* `.Complete()` calls all must return true before the parent completes. A `Wait` state also still processes `Event(...)` transitions (checked after `WaitingFor` since concurrency needs `WaitingFor` resolved first) — the doc comment on `IWaitState` explicitly notes this ordering. If a wait state is the active state when an ancestor `Nest`/`Split` exits, it force-completes itself and all inner activities rather than leaving them dangling. +- **`ITaskWaitState.WaitingFor(Func | Func)`** — blocks on true async work. Unlike `IWaitState`, **cannot process `Event(...)` transitions while waiting** (no `IStateEvent` in its interface composition) — if an ancestor region exits mid-wait, the exit itself is paused and any events that arrive during the wait are queued rather than dropped, to avoid a concurrency bottleneck. + +### Events +- **`IStatechartEvent`** (`Runtime/StatechartEvent.cs`) — equality is by an auto-incrementing `uint Id` assigned at construction, **not** by `Name`. Two `new StatechartEvent("Jump")` instances are never equal to each other; keep one instance per logical event and reuse it across every `.Event(theSameInstance)` call site that should respond to it. + +## 3. Key Directories / Files +- **Public interfaces** (root of `Runtime/`): `IState.cs` (all state-capability + concrete-state interfaces + `NestedStateData`), `ITransition.cs`, `IStateFactory.cs`, `IWaitActivity.cs`, `Statechart.cs` (+ `IStatechart`/`IStateMachineDebug`), `StatechartEvent.cs` (+ `IStatechartEvent`). +- **`Runtime/Internal/*`** — concrete state implementations (`InitialState`, `FinalState`, `SimpleState`, `TransitionState`, `NestState`, `SplitState`, `ChoiceState`, `WaitState`, `TaskWaitState`, `LeaveState`), `StateFactory`, `Transition`, `InnerStateData`, `StatechartUtils`. All `internal` — not part of the public surface; consumers only ever see the interfaces above. +- **Tests**: `Tests/Editor/*` (one asmdef, `GameLovers.Statechart.Editor.Tests`) — `StatechartTest.cs` (core lifecycle + validation-exception coverage), `StatechartStateTest.cs`, `StatechartTransitionTest.cs`, `StatechartChoiceTest.cs`, `StatechartNestTest.cs`, `StatechartSplitTest.cs`, `StatechartWaitTest.cs`, `StatechartTaskWaitTest.cs`, `StatechartLeaveTest.cs`, `StatechartNestSplit_IntegrationTest.cs`, `IMockCaler.cs` (mocked-callback interface used across the suite via NSubstitute). Before reading, editing, or creating any file in `Tests/`, you **MUST** read [`Tests/AGENTS.md`](Tests/AGENTS.md) first. +- **No `Editor/` assembly, no `Samples~/`, no `docs/`** — this package's entire surface is the interfaces above; there is no editor tooling and nothing to import as a sample. + +## 4. Important Behaviors / Gotchas +- **Setup-time validation, not always-on**: the exceptions below (`MissingMemberException`, `InvalidOperationException`) only fire from the `#if UNITY_EDITOR || DEBUG` validation pass in the `Statechart` constructor — a malformed chart in a release build without `DEBUG` defined will not be caught the same way. Always validate in-editor / in tests before shipping a chart's setup. +- **`Trigger` is a no-op unless running**: calling `Trigger(...)` before the first `Run()`, or after `Pause()`, silently does nothing — it does not queue the event for later. +- **Event identity is per-instance, not per-name**: see `IStatechartEvent` above — a fresh `new StatechartEvent("X")` never equals a previously created `"X"` event. Store event instances as fields/constants, not locals recreated per call. +- **`ITaskWaitState` cannot receive events while waiting**; `IWaitState` can, but only after its `WaitingFor` activities resolve first in a concurrent scenario. Picking the wrong one of these two for a state that needs to react to an event mid-wait is a common design mistake. +- **`ILeaveState` only jumps one region layer** — from inside a doubly-nested region, a `Leave` still only reaches the immediate parent, not further up. +- **`CurrentState` is Editor/DEBUG-only surface** on `Statechart` — don't wire game logic to it. + +## 5. Coding Standards (Unity 6 / C# 9.0) +- **C#**: C# 9.0 syntax; explicit namespaces (`GameLovers.StatechartMachine` for Runtime, `GameLoversEditor.StatechartMachine.Tests` for Tests); no global usings. +- **Assemblies**: `Runtime/GameLovers.Statechart.asmdef` has no Editor/UnityEditor reference — this package has no Editor assembly at all. Keep everything under `Runtime/Internal/` truly `internal`; the public surface is exactly the interfaces in section 2/3. +- **Async**: `Cysharp.Threading.Tasks` (UniTask) only appears in `ITaskWaitState.WaitingFor(Func)` — the plain-`Task` overload exists for consumers who don't want the UniTask dependency in their own state-setup code (the package dependency itself is unconditional either way). + +## 6. External Package Sources (for API lookups) +- UniTask: `Library/PackageCache/com.cysharp.unitask/` + +## 7. Common change workflows +- **Add a new state type**: define its public capability-composed interface in `Runtime/IState.cs`, add the corresponding `internal` implementation under `Runtime/Internal/`, and add the factory method to `IStateFactory` + `Runtime/Internal/StateFactory.cs`. Add test coverage under `Tests/Editor/` following the existing `StatechartTest.cs` naming. +- **Change validation behavior**: the per-state `Validate()` calls happen in `Statechart`'s constructor under `#if UNITY_EDITOR || DEBUG` — keep new validation failures as exceptions thrown from state `Validate()` implementations under `Runtime/Internal/`, matching the existing `MissingMemberException`/`InvalidOperationException` pattern. + +## 8. Update Policy +Update this file when: +- Public API changes (`IStatechart`, any state-capability interface in `IState.cs`, `IStateFactory`, `ITransition`, `IWaitActivity`, `IStatechartEvent`) +- Validation/exception behavior changes in the `Statechart` constructor's setup pass +- Nesting/splitting/leaving semantics change (region completion rules, `NestedStateData` flag behavior) +- Dependencies in `package.json` change (cross-check this file and `README.md` for stale references) diff --git a/AGENTS.md.meta b/AGENTS.md.meta new file mode 100644 index 0000000..03999ea --- /dev/null +++ b/AGENTS.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f4dd3a1eccb854e87865c2bdab823003 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ebbba..a234b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [0.9.5] - 2026-08-04 + +**Changed**: +- Replaced the Unity Package Starter Kit README with accurate Statechart usage and API guidance based on the package's actual runtime surface. +- Improved public API documentation and automated coverage of existing state creation, transition, nested/split, waiting, and validation behavior; this release does not change runtime behavior. + ## [0.9.4] - 2026-06-26 **Fixed** diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f642129 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# Claude Code Guide — Statechart + +This package's contributor/agent guide lives in `AGENTS.md`. +Claude Code will automatically import it below. + +@AGENTS.md + +## Claude-Specific Notes + +- Treat `AGENTS.md` as the source of truth. +- If anything in this file appears to conflict with `AGENTS.md`, prefer `AGENTS.md`. +- For user-facing usage, see `README.md`. diff --git a/CLAUDE.md.meta b/CLAUDE.md.meta new file mode 100644 index 0000000..ac8f2ca --- /dev/null +++ b/CLAUDE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 38fa2679facac4ae3ac416619f3dcb02 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 1486444..cf3de76 100644 --- a/README.md +++ b/README.md @@ -1,229 +1,103 @@ -# Package Starter Kit - -The purpose of this starter kit is to provide the data structure and development guidelines for new packages meant for the **Unity Package Manager (UPM)**. - -## Are you ready to become a package? -The Package Manager is a work in progress for Unity. Because of that, your package needs to meet these criteria to become an official Unity package: -- **Your code accesses public Unity C# APIs only.** -- **Your code doesn't require security, obfuscation, or conditional access control.** - - -## Package structure - -```none - - ├── package.json - ├── README.md - ├── CHANGELOG.md - ├── Third Party Notices.md - ├── Editor - │ ├── Undefined.Statechart.Editor.asmdef - │ └── EditorExample.cs - ├── Runtime - │ ├── Undefined.Statechart.asmdef - │ └── RuntimeExample.cs - ├── Tests - │ ├── .tests.json - │ ├── Editor - │ │ ├── Undefined.Statechart.Editor.Tests.asmdef - │ │ └── EditorExampleTest.cs - │ └── Runtime - │ ├── Undefined.Statechart.Tests.asmdef - │ └── RuntimeExampleTest.cs - ├── Samples - │ └── Example - │ ├── .sample.json - │ └── SampleExample.cs - └── Documentation - ├── StateChart.md - └── Images -``` - -## Develop your package -Package development works best within the Unity Editor. Here's how to get started: - -1. Enter your package name. The name you choose should contain your default organization followed by the name you typed. For example: `Undefined.Statechart`. - -2. [Enter the information](#FillOutFields) for your package in the `package.json` file. - -3. [Rename and update](#Asmdef) assembly definition files. - -4. [Document](#Doc) your package. - -5. [Add samples](#Populate) to your package (code & assets). - -6. [Validate](#Valid) your package. - -7. [Add tests](#Tests) to your package. - -8. Update the `CHANGELOG.md` file. - - Every new feature or bug fix should have a trace in this file. For more details on the chosen changelog format, see [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - -9. Make sure your package [meets all legal requirements](#Legal). - -10. Publish your package. - - - - -### Completing the package manifest - -You can either modify the package manifest (`package.json`) file directly in the Inspector or by using an external editor. - -To use the Inspector, select the `package.json` file in the Project browser. The **Package StateChart Manifest** page opens for editing. - -Update these required attributes in the `package.json` file: - -| **Attribute name:** | **Description:** | -| ------------------- | ------------------------------------------------------------ | -| **name** | The officially registered package name. This name must conform to the [Unity Package Manager naming convention](https://docs.unity3d.com/Manual/upm-manifestPkg.html#name), which uses reverse domain name notation. For example:
`"com.[YourCompanyName].[your-package-name]"` | -| **displayName** | A user-friendly name to appear in the Unity Editor (for example, in the Project Browser, the Package Manager window, etc.). For example:
`"Terrain Builder SDK"`
__NOTE:__ Use a display name that will help users understand what your package is intended for. | -| **version** | The package version number (**'MAJOR.MINOR.PATCH"**). This value must respect [semantic versioning](http://semver.org/). For more information, see [Package version](https://docs.unity3d.com/Manual/upm-manifestPkg.html#pkg-ver) in the Unity User Manual. | -| **unity** | The lowest Unity version the package is compatible with. If omitted, the package is considered compatible with all Unity versions.

The expected format is "**<MAJOR>.<MINOR>**" (for example, **2018.3**). | -| **description** | A brief description of the package. This is the text that appears in the [details view](upm-ui-details) of the Packages window. Any [UTF-8](https://en.wikipedia.org/wiki/UTF-8) character code is supported. This means that you can use special formatting character codes, such as line breaks (**\n**) and bullets (**\u25AA**). | - -Update the following recommended fields in file **package.json**: - -| **Attribute name:** | **Description:** | -| ------------------- | ------------------------------------------------------------ | -| **dependencies** | A map of package dependencies. Keys are package names, and values are specific versions. They indicate other packages that this package depends on. For more information, see [Dependencies](https://docs.unity3d.com/Manual/upm-dependencies.html) in the Unity User Manual.

**NOTE**: The Package Manager does not support range syntax, only **SemVer** versions. | -| **keywords** | An array of keywords used by the Package Manager search APIs. This helps users find relevant packages. | - - - - -### Updating the Assembly Definition files - -You must associate scripts inside a package to an assembly definition file (.asmdef). Assembly definition files are the Unity equivalent to a C# project in the .NET ecosystem. You must set explicit references in the assembly definition file to other assemblies (whether in the same package or in external packages). See [Assembly Definitions](https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html) for more details. - -Use these conventions for naming and storing your assembly definition files to ensure that the compiled assembly filenames follow the [.NET Framework Design Guidelines](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/): - -* Store Editor-specific code under a root editor assembly definition file: - - `Editor/Undefined.Statechart.Editor.asmdef` - -* Store runtime-specific code under a root runtime assembly definition file: - - `Runtime/Undefined.Statechart.asmdef` - -* Configure related test assemblies for your editor and runtime scripts: - - `Tests/Editor/Undefined.Statechart.Editor.Tests.asmdef` - - `Tests/Runtime/Undefined.Statechart.Tests.asmdef` - -To get a more general view of a recommended package folder layout, see [Package layout](https://docs.unity3d.com/Manual/cus-layout.html). - - - - -### Providing documentation - -Use the `Documentations~/StateChart.md` documentation file to create preliminary, high-level documentation. This document should introduce users to the features and sample files included in your package. Your package documentation files will be used to generate online and local docs, available from the Package Manager UI. - -**Document your public APIs** -* All public APIs need to be documented with **XmlDoc**. -* API documentation is generated from [XmlDoc tags](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/xmldoc/xml-documentation-comments) included with all public APIs found in the package. See [Editor/EditorExample.cs](Editor/EditorExample.cs) for an example. - - - - - -### Adding Assets to your package - -If your package contains a sample, rename the `Samples/Example` folder, and update the `.sample.json` file in it. - -In the case where your package contains multiple samples, you can make a copy of the `Samples/Example` folder for each sample, and update the `.sample.json` file accordingly. - -Similar to `.tests.json` file, there is a `"createSeparatePackage"` field in `.sample.json`. If set to true, the CI will create a separate package for the sample. - -Delete the `Samples` folder altogether if your package does not need samples. - -As of Unity release 2019.1, the Package Manager recognizes the `/Samples` directory in a package. Unity doesn't automatically import samples when a user adds the package to a Project. However, users can click a button in the details view of a package in the **Packages** window to optionally import samples into their `/Assets` directory. - - - - - -### Validating your package - -Before you publish your package, you need to make sure that it passes all the necessary validation checks by using the Package Validation Suite extension (optional). - -Once you install the Validation Suite package, a **Validate** button appears in the details view of a package in the **Packages** window. To install the extension, follow these steps: - -1. Point your Project manifest to a staging registry by adding this line to the manifest: - `"registry": "https://staging-packages.unity.com"` -2. Install the **Package Validation Suite v0.3.0-preview.13** or above from the **Packages** window in Unity. Make sure the package scope is set to **All Packages**, and select **Show preview packages** from the **Advanced** menu. -3. After installation, a **Validate** button appears in the **Packages** window. Click the button to run a series of tests, then click the **See Results** button for additional information: - * If it succeeds, a green bar with a **Success** message appears. - * If it fails, a red bar with a **Failed** message appears. - -**NOTE:** The validation suite is still in preview. - - - - - -### Adding tests to your package - -All packages must contain tests. Tests are essential for Unity to ensure that the package works as expected in different scenarios. - -**Editor tests** -* Write all your Editor Tests in `Tests/Editor` - -**Playmode Tests** - -* Write all your Playmode Tests in `Tests/Runtime`. - -#### Separating the tests from the package - -You can create a separate package for the tests, which allows you to exclude a large number of tests and Assets from being published in your main package, while still making it easy to test it. - -Open the `Tests/.tests.json` file and set the **createSeparatePackage** attribute: - -| **Value to set:** | **Result:** | -| ----------------- | ------------------------------------------------------------ | -| **true** | CI creates a separate package for these tests. At publish time, the Package Manager adds metadata to link the packages together. | -| **false** | Keep the tests as part of the published package. | - - - - -### Meeting the legal requirements - -You can use the Third Party Notices.md file to make sure your package meets any legal requirements. For example, here is a sample license file from the Unity Timeline package: - -``` -Unity Timeline copyright © 2017-2019 Unity Technologies ApS - -Licensed under the Unity Companion License for Unity-dependent projects--see [Unity Companion License](http://www.unity3d.com/legal/licenses/Unity_Companion_License). - -Unless expressly provided otherwise, the Software under this license is made available strictly on an “AS IS” BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions. - -``` - - - -#### Third Party Notices - -If your package has third-party elements, you can include the licenses in a Third Party Notices.md file. You can include a **Component Name**, **License Type**, and **Provide License Details** section for each license you want to include. For example: - -``` -This package contains third-party software components governed by the license(s) indicated below: - -Component Name: Semver - -License Type: "MIT" - -[SemVer License](https://github.com/myusername/semver/blob/master/License.txt) - -Component Name: MyComponent - -License Type: "MyLicense" - -[MyComponent License](https://www.mycompany.com/licenses/License.txt) - -``` - -**NOTE**: Any URLs you use should point to a location that contains the reproduced license and the copyright information (if applicable). +# GameLovers Statechart + +[![Unity Version](https://img.shields.io/badge/Unity-2022.3%2B-blue.svg)](https://unity3d.com/get-unity/download) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) + +A Hierarchical Finite State Machine (Statechart / HFSM) for Unity — states can nest into sub-regions, split into parallel regions, and block on async waits, all defined once in a single setup closure with no runtime mutation of the chart's shape. + +## Why Use This Package? + +Plain FSMs get unwieldy once a game state has sub-states of its own (a "Playing" state that is itself "Loading" → "Countdown" → "InProgress"), or needs two things happening at once (an animation playing while input is disabled). A Statechart — per the [UML spec](http://www.omg.org/spec/UML) and the broader [statecharts model](https://statecharts.github.io/what-is-a-statechart.html) — solves both by letting a state open its own nested region (`Nest`) or two parallel regions (`Split`), instead of flattening everything into one state graph. + +### Key Features +- **10 state types** covering the common Statechart vocabulary: `Initial`, `Final`, `State` (event-blocking), `Transition` (pass-through), `Choice` (conditional branch), `Wait` (activity-blocking), `TaskWait` (async-blocking), `Nest` (sequential sub-region), `Split` (parallel sub-regions), `Leave` (early exit to a parent region). +- **Fluent setup** — one constructor closure defines the entire chart; no separate registration step. +- **Async-aware waiting** — `TaskWait` states block on a `Task` or `UniTask` directly. +- **Editor-time validation** — malformed setups (missing initial state, transition with no target, transition loops) throw immediately at construction in the Editor / Debug builds. + +## System Requirements + +- **[Unity](https://unity.com/download)** (v2022.3+) — the only package in the GameLovers family that doesn't require Unity 6 +- **[UniTask](https://github.com/Cysharp/UniTask)** (v2.5.10+) — for the `ITaskWaitState.WaitingFor(Func)` overload + +Dependencies are automatically resolved when installing via Unity Package Manager. + +## Installation + +### Via Unity Package Manager (Recommended) + +1. Open Unity Package Manager (`Window` → `Package Manager`) +2. Click `+` → `Add package from git URL` +3. Enter: `https://github.com/CoderGamester/Statechart-HFSM.git` + +### Via manifest.json + +```json +{ + "dependencies": { + "com.gamelovers.statechart": "https://github.com/CoderGamester/Statechart-HFSM.git" + } +} +``` + +## Key Components + +| Type | Purpose | +|---|---| +| `Statechart` | The chart itself — `Run()` / `Pause()` / `Trigger(event)` / `Reset()` | +| `IStateFactory` | Passed into the setup closure; one factory method per state type (`Initial`, `Final`, `State`, `Transition`, `Choice`, `Wait`, `TaskWait`, `Nest`, `Split`, `Leave`) | +| `ITransition` / `ITransitionCondition` | `.OnTransition(action).Target(state)`; `Choice` transitions add `.Condition(() => bool)` | +| `IStatechartEvent` / `StatechartEvent` | Event identity is per-instance — keep one instance per logical event | +| `IWaitActivity` | Passed into a `Wait` state's `WaitingFor(...)`; call `.Complete()` to unblock, or `.Split()` to fan out | + +## Quick Start + +```csharp +using GameLovers.StatechartMachine; +using UnityEngine; + +var jumpEvent = new StatechartEvent("Jump"); + +var statechart = new Statechart(factory => +{ + var initial = factory.Initial("Initial"); + var idle = factory.State("Idle"); + var jumping = factory.State("Jumping"); + var final = factory.Final("Final"); + + initial.Transition().Target(idle); + + idle.Event(jumpEvent).OnTransition(() => Debug.Log("Jumping!")).Target(jumping); + idle.OnEnter(() => Debug.Log("Entered Idle")); + + jumping.OnEnter(() => Debug.Log("Entered Jumping")); + jumping.Event(jumpEvent).Target(final); // second Jump ends the chart + + final.OnEnter(() => Debug.Log("Done")); +}); + +statechart.Run(); +statechart.Trigger(jumpEvent); // Idle -> Jumping +statechart.Trigger(jumpEvent); // Jumping -> Final +``` + +Every state is created via the `factory` passed into the constructor closure — there is no separate registration call, and the chart's shape cannot be changed after construction. See [AGENTS.md](AGENTS.md) for nested regions (`Nest`), parallel regions (`Split`), async waits (`TaskWait`), and the full state-type reference. + +## Related docs + +| Document | Purpose | +|---|---| +| [AGENTS.md](AGENTS.md) | Contributor/agent guide — full state-type reference, architecture, gotchas | +| [CHANGELOG.md](CHANGELOG.md) | Version history | + +## Contributing + +Contributions are welcome! See [AGENTS.md](AGENTS.md) for architecture details, coding standards, and common workflows. + +## Support + +- **Issues**: [Report bugs or request features](https://github.com/CoderGamester/Statechart-HFSM/issues) + +## License + +MIT — see [LICENSE.md](LICENSE.md). diff --git a/Runtime/IState.cs b/Runtime/IState.cs index 5c1e4f1..61d6e23 100644 --- a/Runtime/IState.cs +++ b/Runtime/IState.cs @@ -251,6 +251,10 @@ public NestedStateData(Action setup) ExecuteFinal = true; } + /// + /// Wraps a setup delegate as nested-state data with both exit and final execution enabled, + /// so a nested state can be declared from a bare lambda. + /// public static implicit operator NestedStateData(Action setup) { return new NestedStateData(setup); diff --git a/Runtime/Internal/LeaveState.cs b/Runtime/Internal/LeaveState.cs index 3b7c363..8d3e467 100644 --- a/Runtime/Internal/LeaveState.cs +++ b/Runtime/Internal/LeaveState.cs @@ -10,6 +10,7 @@ internal class LeaveState : StateInternal, ILeaveState { private readonly IList _onEnter = new List(); + /// The transition this leave state hands back to the parent region, set when the state is configured. internal ITransitionInternal LeaveTransition { get; private set; } public LeaveState(string name, IStateFactoryInternal factory) : base(name, factory) diff --git a/Runtime/Internal/SplitState.cs b/Runtime/Internal/SplitState.cs index 61eb1ac..62a22d8 100644 --- a/Runtime/Internal/SplitState.cs +++ b/Runtime/Internal/SplitState.cs @@ -79,6 +79,9 @@ public override void Validate() OnValidate(); } + /// + /// Fails fast in editor and debug builds when the split declares no inner states. + /// protected void OnValidate() { #if UNITY_EDITOR || DEBUG diff --git a/Runtime/Internal/StateFactory.cs b/Runtime/Internal/StateFactory.cs index fe39537..296f324 100644 --- a/Runtime/Internal/StateFactory.cs +++ b/Runtime/Internal/StateFactory.cs @@ -41,7 +41,6 @@ internal interface IStateFactoryInternal : IStateFactory /// /// Adds the given list of to this to building upon /// - /// void Add(IList states); } diff --git a/Runtime/Internal/StateInternal.cs b/Runtime/Internal/StateInternal.cs index 2116a8b..42410d1 100644 --- a/Runtime/Internal/StateInternal.cs +++ b/Runtime/Internal/StateInternal.cs @@ -63,6 +63,7 @@ internal abstract class StateInternal : IStateInternal /// public string CreationStackTrace { get; } + /// True when either this state or its owning statechart has logging switched on. protected bool IsStateLogsEnabled => LogsEnabled || _stateFactory.Data.Statechart.LogsEnabled; protected StateInternal(string name, IStateFactoryInternal stateFactory) @@ -150,6 +151,9 @@ public override string ToString() /// public abstract void Validate(); + /// + /// Returns the transition this state takes for the given event, or null when it does not handle it. + /// protected abstract ITransitionInternal OnTrigger(IStatechartEvent statechartEvent); private void TriggerEnter(IStateInternal state) diff --git a/Runtime/Internal/Transition.cs b/Runtime/Internal/Transition.cs index 6a47fb5..4e2f00e 100644 --- a/Runtime/Internal/Transition.cs +++ b/Runtime/Internal/Transition.cs @@ -25,7 +25,6 @@ internal interface ITransitionInternal : ITransitionCondition /// Checks the defined transition condition. /// Returns true if the condition is met, false otherwise /// - /// bool CheckCondition(); /// /// Trigger the defined transition diff --git a/Runtime/Internal/WaitActivity.cs b/Runtime/Internal/WaitActivity.cs index cec65a2..218eb81 100644 --- a/Runtime/Internal/WaitActivity.cs +++ b/Runtime/Internal/WaitActivity.cs @@ -39,9 +39,7 @@ private WaitActivity() Id = ++_idRef; } - /// - /// This constructor is called externally in - /// + // Public despite the type being internal: WaitState constructs it from outside this file. public WaitActivity(Action onComplete) : this() { _onComplete = onComplete; diff --git a/Runtime/Statechart.cs b/Runtime/Statechart.cs index 4397f46..9bf451f 100644 --- a/Runtime/Statechart.cs +++ b/Runtime/Statechart.cs @@ -6,8 +6,8 @@ namespace GameLovers.StatechartMachine { /// - /// Interface to help debug the state chart - /// + /// Interface to help debug the state chart + /// public interface IStateMachineDebug { /// @@ -61,6 +61,7 @@ public class Statechart : IStatechart public bool LogsEnabled { get; set; } #if UNITY_EDITOR + /// Name of the state currently active. Editor-only, for debugging. public string CurrentState => _currentState.Name; #endif diff --git a/Runtime/StatechartEvent.cs b/Runtime/StatechartEvent.cs index 24d7ab4..4054b26 100644 --- a/Runtime/StatechartEvent.cs +++ b/Runtime/StatechartEvent.cs @@ -41,21 +41,27 @@ public StatechartEvent(string name) Name = name; } + /// + /// Two events match on alone, so distinct instances with the same name differ. + /// public bool Equals(IStatechartEvent statechartEvent) { return statechartEvent != null && Id == statechartEvent.Id; } + /// public override bool Equals(object obj) { return obj is IStatechartEvent chartEvent && Equals(chartEvent); } + /// public override int GetHashCode() { return (int) Id; } + /// public override string ToString() { return Name; diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md new file mode 100644 index 0000000..96b2024 --- /dev/null +++ b/Tests/AGENTS.md @@ -0,0 +1,312 @@ +# GameLovers.Statechart Tests — AI Agent Guide + +This file contains testing conventions for the `com.gamelovers.statechart` package. It is the source of truth when reading, editing, or creating test files under `Tests/`. + +For runtime architecture, gotchas, and package-level context, see the parent [`AGENTS.md`](../AGENTS.md). + +§1 and §2 are shared verbatim across every GameLovers package. A change to either must be applied to all six `Tests/AGENTS.md` files in the same working session, one commit per submodule. + +## 1. ADMIT — Test Admission Test + +A proposed test is admitted only if all five answers are YES. Record the first two +as comments on the test itself. + +| | Question | +|---|---| +| **A1 DEFECT** | Can you name the defect in one sentence, referencing a production file and symbol? "It could break" is not a defect. | +| **A2 RED** | Can you name the exact production edit — one line or one branch, identified by `file` + `symbol` — that makes this test fail? If no such single edit exists, the test pins nothing. | +| **A3 PACKAGE** | Does every assertion read a value this package computed? Reject assertions on `new X() != new X()`, `!= null` on a freshly constructed object, default struct/enum values, or anything the C# spec or the Unity engine already guarantees. | +| **A4 CHEAPEST** | Is this the cheapest tier that covers the defect? EditMode beats PlayMode; a `[TestCase]` row on an existing fixture beats a new `[Test]`; a new `[Test]` beats a new fixture. Grep before writing. | +| **A5 UNIQUE** | Does no existing test already fail on the A2 edit? Grep the symbol under test across `Tests/` first. | +| **A6 ENVIRONMENT** | Would this assertion's outcome change if project configuration changed — a renderer feature installed or removed, an Addressables catalog built, a sample imported, a quality tier switched? If yes, the test must **read** that state, not assume one value of it. | + +**A6 in practice.** A6 is not A3. A3 asks whether the package computed the value; A6 +asks whether the test assumed which value it would be. A test can satisfy A3 and still +fail A6 — reading a package-computed flag is fine, hard-coding the expectation that the +flag is `false` is not. + +The concrete instance: `UiBackdropBlurPresenterFeatureTests` unconditionally expected +the "no renderer feature installed" error. Production only logs it when +`UiBackdropBlurRendererFeature.IsInstalled` is false. Batchmode never instantiates the +URP renderer, so the flag was false and all five tests passed; in the Editor the feature +registers from the project's renderer asset, the flag is true, production correctly stays +silent, and all five failed. The fixture was really asserting *"this project has no blur +renderer feature"* — a fact about the repo, not about the code under test. + +The fix shape is always the same: branch the expectation on the state instead of assuming +it, and leave the assertions that are actually the subject untouched. + +```csharp +if (UiBackdropBlurRendererFeature.IsInstalled) return; // production logs nothing +LogAssert.Expect(LogType.Error, ...); +``` + +If a test genuinely needs one specific value of ambient state, it must establish that +state itself in `[SetUp]` and restore it in `[TearDown]` — never inherit it. + +**A5-bis — inherited-type coverage.** Before proposing a fixture for a type that +derives from or wraps another tested type, grep `Tests/` for the derived type's +name and for paired `[SetUp]` fields. Base-and-derived pairs are tested jointly in +the base's fixture unless the derived type adds new public surface. + +**Two mechanical disqualifiers** — violate one and the test is rejected: + +- **D1 — tautology.** If the only assertion is `Assert.DoesNotThrow`, + `Assert.IsNotNull`, or a disjunction of `Contains(...)` substrings, the test + fails A2 unless you write down what *would* throw, be null, or not match. A + substring disjunction that includes a string the input itself embeds is + unfalsifiable by construction. +- **D2 — name/body contract.** The test name is a claim. If deleting the + production feature the name mentions leaves the test green, the name is a lie. + +**Smoke exemption, by directory.** Fixtures under `Smoke/` are exempt from A1 and +A2 and may assert construction-without-throwing only. Their defect class is "the +assembly no longer loads / bootstrap regressed", which is real and not expressible +otherwise. The exemption is by directory, not by assertion shape — a Unit test +that only asserts `IsNotNull` is still rejected. + +## 2. RCR — Revert and Confirm Red + +> Every new or strengthened test must be observed failing, once, against a +> one-line production revert, before it is committed. + +Line coverage proves a line executed. It does not prove any test would notice if +that line were wrong. RCR is the cheap substitute for mutation testing, and it is +what makes a coverage number trustworthy. + +**Procedure** (~90 seconds per test): + +1. Write the test. Run it. Green. +2. Apply the A2 edit — invert the comparison, delete the guard clause, return + early, comment out the one line. **One line only**: a broad deletion proves + nothing, because it would also "fail" a tautological test via a compile error. +3. Run only that test. It must be **RED**, and the failure message must name the + thing you broke. A red-by-`NullReferenceException` does not count — that is the + test crashing, not asserting. +4. `git checkout -- `. Re-run. Green. +5. Record the mutation in the test's header comment. + +**Recording format** — on the test, not in a separate ledger. A ledger rots the +moment a test is renamed; a comment travels with the test, appears in every diff +that touches it, and lets a reviewer re-run the mutation in 30 seconds. + +```csharp +[Test] +// ADMIT: +// RCR: → RED (). +public void Method_Condition_ExpectedResult() +``` + +**Anchor on `file` + `symbol`, never `file:line`.** Line numbers rot on the first +unrelated edit above them — a stale `:474` pointing at a method that moved to `:464` +sends the next reader to the wrong code and quietly destroys the comment's value. + +**Budget: four lines is the target, six is the ceiling.** One sentence of ADMIT, +one of RCR, wrapped. This obeys the repo-wide rule in the root `AGENTS.md` +(§ Code comments): *"One sentence usually suffices. Multi-paragraph rationale is a +smell."* Anything past the ceiling belongs in the commit body or `docs/`, not on the +test. Two things in particular must NOT appear here: +- **Change narration.** *"An earlier version of this test was a tautology"* is diff + context; the root `AGENTS.md` forbids it outright. A comment states the code's + permanent condition, not its history. Put it in the commit message. +- **Investigation transcript.** The empirical detail that convinced *you* is not + what the next reader needs. They need the mutation and the expected failure. + +The one extension worth its lines is a **negative** result: naming a nearby edit +that looks like a valid mutation but is NOT one (because it is already guarded, or +because it reddens a sibling test instead). That stops the next reader repeating a +dead end, and it cannot be recovered from the code. + +Also add one line per new test to the commit body: `RCR: `. +That makes `git log --grep=RCR` the audit surface. + +**UNFALSIFIABLE — the one honest exemption.** Some correct tests provably have no +one-line mutation. The commonest case is **double-guarded validation**: an +unconfigured object trips two independent guards, so disabling either leaves the +other throwing. Deleting such a test would lose real coverage, so it is exempt — +but only on the same terms as §13, never as a shrug: + +```csharp +// RCR: none exists — trips both and ; disabling either +// leaves the other throwing (verified). Double-covered, not single-line falsifiable. +``` + +The reason must be falsifiable and must record that a mutation was actually tried +and observed green. "Couldn't find one" is not a reason — that is an unfinished RCR, +not an exemption. + +**Verdicts for a test that resists mutation.** Work out which of four it is; they +have different answers: + +| Finding | Test | Action | +|---|---|---| +| **A3 reject** — no line in `Runtime/` or `Editor/` participates; the assertion is C#- or Unity-guaranteed | pins nothing, ever | **Delete** | +| **A5 duplicate** — the only mutation that reddens it already belongs to a sibling | pins nothing new | **Delete**, naming the surviving sibling in the commit body | +| **D2 overclaim** — the name promises behaviour the body cannot detect | name is a lie | **Strengthen the assertion**, or rename to what it actually checks | +| **UNFALSIFIABLE** — real behaviour, but double-guarded or otherwise unbreakable one line at a time | valid | **Keep**, with the exemption comment above | +| **SHARED-PATH** — no unique one-line pin, but the test was *observed* reddening under a broader mutation | valid | **Keep**, recording the covering mutation and its blast radius | + +**SHARED-PATH exists because blast radius measures specificity, not value.** A test that +only reddens under a broad mutation still catches that regression — an integration test +that dies when `UiService.CloseUi` is gutted is doing its job, even though no single line +is *its* line. Without this row the table offers only delete-or-strengthen, and such tests +get deleted for the crime of being integration tests. + +```csharp +// ADMIT: exercises 's ; no unique one-line pin. +// RCR: no isolated mutation — reddens under 's mutation (radius N, verified). +// Shared-path coverage, not a duplicate. +``` + +The radius must be a **recorded observation**, not an estimate. This is also the row most +easily abused: "some mutation somewhere reddened it" is not the standard. Distinguish it +from A5 by asking what the covering mutation actually broke — if it broke the one narrow +guard the sibling owns, this is a duplicate; if it broke a path both tests legitimately +traverse, this is shared-path coverage. + +A cluster of tests that all die to the same broad mutation is **over-provisioned, not +individually worthless**. Thinning it is a deliberate editorial decision made by a human +looking at what each assertion adds — never an automatic consequence of the verdict pass. + +**A3 is checked first, and it is the commonest way the exemption gets abused.** +UNFALSIFIABLE is for behaviour this package genuinely owns but cannot be broken one +line at a time. It is *never* for behaviour the package does not own. The tell is in +the reason itself: if you find yourself writing "no line in Runtime/ participates", +"the only edit is a compile error", or "these are C#'s zero-init values", you have +found an A3 reject and the verdict is **delete** — a field-only struct's assignment +and default values are the language's guarantees, not yours. Writing that sentence +under an UNFALSIFIABLE heading launders a test §1 would never have admitted. + +Prove the class before acting. An A5 duplicate is confirmed when the sibling's +mutation is observed reddening both; a D2 overclaim is confirmed when the mutation +the name implies leaves the test green; an A3 reject is confirmed when no production +symbol appears anywhere in the causal chain behind the assertion. + +**Two consequences, stated so RCR does not become theatre:** + +- A test with no `// RCR:` line — and no UNFALSIFIABLE exemption — is not trusted + coverage. In an audit it is a suspect by default. **`Smoke/` is exempt here too**, on the + same directory basis as §1: its defect class is "the assembly no longer loads", which has + no one-line mutation, so demanding an RCR line there flags those fixtures forever. The + exemption is the directory, not the assertion shape. +- **"Unannotated" is three states, not one, and they need different actions.** A test with no + `// RCR:` line may have been (a) observed RED with the write-back lost, (b) seen reddening + only as collateral inside another test's blast radius, or (c) never probed. Only (c) needs a + probe; (a) needs the recorded observation written back; (b) is SHARED-PATH evidence, not a + unique pin. Check `.test-all/rcr/` before probing, and never write prepared annotation text + without a matching `RED-OK` for that test — prepared text also exists for tests that were + never probed, and writing it fabricates a verified claim. +- **Benchmarks are included, inverted:** a performance test must be observed + *changing its number* when the measured operation is removed from the measured + body. A benchmark whose measured region does not contain the workload is a + tautology in `Measure` clothing. + +## 3. Placement Rules + +Not yet documented — this package has not been through a test audit. There is a +single `Tests/Editor/` assembly and no PlayMode tests today. See the parent +[`AGENTS.md`](../AGENTS.md) for runtime architecture until this section is filled in. + +## 4. Namespace and Suppression + +Not yet documented — see existing files under `Tests/Editor/` for the current +(unaudited) convention. + +## 5. Naming + +Not yet documented. + +## 6. Mock / Helper Types + +Not yet documented. Note: NSubstitute is referenced in this package's test asmdef. + +## 7. Black-Box / Reflection Policy + +Not yet documented. Note: there is no `InternalsVisibleTo` grant from `Runtime/` +to the test assembly in this package as of this writing — verify before assuming +internal access is available. + +## 8. Fields and Setup + +Not yet documented. + +## 9. Assertion Style + +Not yet documented. + +## 10. PlayMode Test Cleanup + +None — this package has no PlayMode assembly. + +## 11. Performance Tests + +Not yet documented. No dedicated performance fixtures exist today. + +## 12. Test Directory Layout + +| Directory | Contents | +|---|---| +| `Tests/Editor/` | The package's entire automated suite (EditMode only) | + +## 13. Coverage Register + +**Baseline — runtime assembly: 84.0% (862/1025), measured 2026-08-04.** +This package has no Editor assembly. +Repo-wide runtime coverage is **74.1% (6609/8922)** across all 11 assemblies. + +Regenerate with `Tools/coverage.sh`, which prints the runtime/Editor split. Steer by +the **runtime** figure: Editor code is ~48% of coverable lines and accepted-untestable, +so the combined number (41.1%) can never meaningfully move. Sanity-check any rerun by +confirming `MathfloatP` reports ~1002 coverable lines — a smaller figure means +`-debugCodeOptimization` was missing and the denominator silently shrank ~40%. + + +Every untested symbol worth naming is ACCEPTED (justified — do not re-report), +OPEN (a real gap, owed a test), or CLOSED (the gap was filled). An untested symbol +in none of the three is an audit finding. + +**A CLOSED row must name the commit AND the observation that closed it, including the +environment the observation came from.** A row closed on "the fix landed" is still OPEN: +the fix is the edit, the closure is the evidence. This is what kept the uiservice A6 row +open until the Editor half ran — the edit was in and batchmode was green, and neither of +those was the thing in doubt. + +**Closing a row means re-deriving its claim against current source, never reading the +commit that claimed to fix it.** Re-check every symbol and fixture the row names. A partial +fix and a complete one produce the same green suite and the same confident commit message, +so the commit cannot be the evidence for its own completeness. Recorded instance: the +mobileservices editor-static row nearly closed on a commit that genuinely did stop fixtures +inheriting statics — for two of the three fixtures the row named. The third was found by +grepping which fixtures touch each static, and it was passing only because its siblings +happened to restore the static in their `finally` blocks. + +An ACCEPTED row needs one of exactly three falsifiable reasons: +- **(i) no branching** — zero conditionals, so there is no behaviour to pin. +- **(ii) engine-owned** — the assertion would target Unity/OS behaviour + (`[DllImport]`, `AndroidJavaObject`, Addressables statics). +- **(iii) harness-impossible** — the state cannot be fabricated in EditMode or + PlayMode, **with the specific blocker named**. + +"Low value", "hard to test", and "covered by manual QA" are NOT valid reasons. If +none of the three applies, the row is OPEN. + +ACCEPTED is dated and **expires on edit**: if the symbol's file changes, the +reason is re-checked in that PR. A `(i) no branching` row is void the moment +someone adds an `if`. + +OPEN is the only place a deletion may park coverage. A test removed for weakness +either had a stronger sibling (named in the commit body) or leaves an OPEN row. +The count of OPEN rows is the honest coverage-debt number. + +| Symbol (file:line) | State | Reason / Owed | Recorded | +| 8 production edits reddening only collaterally (`Runtime/**State.cs`) | OPEN | Measured 2026-08-04 from `.test-all/rcr/unowned-edits.json`: 8 edits produced RED but never an `isolated` verdict, spread thinly (`LeaveState.cs` 2, `SplitState.cs` 2, `WaitState.cs` 1). Low enough to be noise rather than a pattern; recorded for completeness so the number is not rediscovered as a finding. | 2026-08-04 | +|---|---|---|---| + +Empty — this package has not yet been through a coverage audit. Do not assume an +untested symbol here is accepted; it is simply unreviewed. + +## 14. Update Policy + +Update this file when this package is next audited for test coverage, and when +§1/§2 change upstream (propagate to all six `Tests/AGENTS.md` files in the same +session). diff --git a/Tests/AGENTS.md.meta b/Tests/AGENTS.md.meta new file mode 100644 index 0000000..7dbfdc1 --- /dev/null +++ b/Tests/AGENTS.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dd971827213c488abf5c61b0a884b206 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/CLAUDE.md b/Tests/CLAUDE.md new file mode 100644 index 0000000..dc2dc03 --- /dev/null +++ b/Tests/CLAUDE.md @@ -0,0 +1,12 @@ +# Claude Code Guide — GameLovers Statechart Tests + +This folder's testing conventions live in `AGENTS.md`. +Claude Code will automatically import it below. + +@AGENTS.md + +## Claude-Specific Notes + +- Treat `AGENTS.md` as the source of truth. +- If anything in this file appears to conflict with `AGENTS.md`, prefer `AGENTS.md`. +- For package-level architecture and runtime gotchas, see `../AGENTS.md`. diff --git a/Tests/CLAUDE.md.meta b/Tests/CLAUDE.md.meta new file mode 100644 index 0000000..814925a --- /dev/null +++ b/Tests/CLAUDE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eedaa618f8d4416b9fdfc7169526220c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/StatechartChoiceTest.cs b/Tests/Editor/StatechartChoiceTest.cs index a6da2be..d04da66 100644 --- a/Tests/Editor/StatechartChoiceTest.cs +++ b/Tests/Editor/StatechartChoiceTest.cs @@ -22,6 +22,10 @@ public void Init() } [Test] + // ADMIT: ChoiceState.OnTrigger returns the first transition whose CheckCondition() is TRUE, so with + // condition1 false and condition2 true the chart takes the second branch, not the first. + // RCR: ChoiceState.cs OnTrigger — invert `if (_transitions[i].CheckCondition())` → RED + // (OnTransitionCall(1) is received and OnTransitionCall(2) is not). public void SimpleTest() { var statechart = new Statechart(SetupChoiceState); @@ -42,6 +46,10 @@ public void SimpleTest() } [Test] + // ADMIT: ChoiceState.OnTrigger scans _transitions in declaration order and returns on the FIRST true + // condition, so two simultaneously-true conditions resolve deterministically to the earlier one. + // RCR: ChoiceState.cs OnTrigger — reverse the scan to `for (var i = _transitions.Count - 1; i >= 0; + // i--)` → RED (the later branch wins). SimpleTest stays green: only one condition is true there. public void ChoiceState_MultipleTrueConditions_PicksFirstTransition() { var statechart = new Statechart(SetupChoiceState); @@ -65,6 +73,11 @@ public void ChoiceState_MultipleTrueConditions_PicksFirstTransition() } [Test] + // ADMIT: ChoiceState.Validate rejects a choice state with NO transitions at all. + // RCR: no single-line mutation exists — the empty case trips BOTH independent guards + // (!hasTransitionWithCondition and noTransitionConditionCount == 0), so disabling either one + // leaves the other still throwing. Verified: narrowing the first to `&& _transitions.Count > 0` + // left this test green. Double-covered belt-and-braces, not single-line falsifiable. public void ChoiceState_MissingTransitions_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -75,6 +88,11 @@ public void ChoiceState_MissingTransitions_ThrowsException() } [Test] + // ADMIT: ChoiceState.Validate also rejects a choice state whose only transition carries no condition — + // that is a TransitionState, not a choice — via the same !hasTransitionWithCondition guard. + // RCR: ChoiceState.cs Validate — narrow the guard to `!hasTransitionWithCondition && + // _transitions.Count == 0` → RED (the one-unconditional-transition case no longer throws). The + // sibling ChoiceState_MissingTransitions_ThrowsException stays green under this edit. public void ChoiceState_MissingConditionTransition_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -87,6 +105,10 @@ public void ChoiceState_MissingConditionTransition_ThrowsException() } [Test] + // ADMIT: ChoiceState.Validate requires a fallback transition with no condition, so a choice whose + // every condition evaluates false still has somewhere to go instead of silently stalling. + // RCR: ChoiceState.cs Validate — change `if (noTransitionConditionCount == 0)` to `if (false)` → RED + // (no InvalidOperationException). public void ChoiceState_OnlyConditionTransition_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -99,6 +121,10 @@ public void ChoiceState_OnlyConditionTransition_ThrowsException() } [Test] + // ADMIT: ChoiceState.Validate rejects any transition left without a Target, naming the offending + // transition index, rather than deferring to a null dereference at run time. + // RCR: ChoiceState.cs Validate — change `if (_transitions[i].TargetState == null)` to `if (false)` → + // RED (no InvalidOperationException; validation instead falls through to the next guard). public void ChoiceState_WithoutTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -113,6 +139,10 @@ public void ChoiceState_WithoutTarget_ThrowsException() } [Test] + // ADMIT: ChoiceState.Validate rejects a transition targeting its own choice state, which would + // re-evaluate the same conditions forever. + // RCR: ChoiceState.cs Validate — change `if (_transitions[i].TargetState.Id == Id)` to `if (false)` → + // RED (no InvalidOperationException). public void StateTransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartLeaveTest.cs b/Tests/Editor/StatechartLeaveTest.cs index 289c0c4..9ad716b 100644 --- a/Tests/Editor/StatechartLeaveTest.cs +++ b/Tests/Editor/StatechartLeaveTest.cs @@ -23,6 +23,10 @@ public void Init() } [Test] + // ADMIT: LeaveState.Enter fans out its OnEnter actions, so a nested region's leave state still runs its + // entry hook on the way back out to the layer above. + // RCR: LeaveState.cs Enter — change the fan-out loop bound to `i < 0` → RED (StateOnEnterCall(0) never + // received). Also reddens the two siblings below, which assert the same hook. public void SimpleNestTest() { var statechart = new Statechart(SetupNest); @@ -41,6 +45,10 @@ public void SimpleNestTest() } [Test] + // ADMIT: SplitState.ProcessInnerStates hands control to the LEAVE state's own transition, not the split's, + // so leaving a region runs the leave's OnTransition and skips the split's completion transition. + // RCR: SplitState.cs ProcessInnerStates — change `: leaveState.LeaveTransition;` to `: _transition;` → RED + // (OnTransitionCall(2) fires instead of (1)). Also reddens the nest and only-leave siblings. public void SimpleSplitTest() { var statechart = new Statechart(SetupSplit); @@ -59,6 +67,10 @@ public void SimpleSplitTest() } [Test] + // ADMIT: SplitState.ProcessInnerStates detects a leave among its inner states and lets it win over the + // split's own completion path; without that detection the split completes normally instead of leaving. + // RCR: SplitState.cs ProcessInnerStates — disable the `is LeaveState state` capture → RED (leaveState stays + // null, so the split's own transition runs and OnTransitionCall(2) fires). Also reddens the two siblings. public void SplitState_OnlyLeaveInnerStates_LeaveFirstState() { var statechart = new Statechart(factory => @@ -90,6 +102,11 @@ public void SplitState_OnlyLeaveInnerStates_LeaveFirstState() } [Test] + // ADMIT: LeaveState.Validate rejects a leave state with no transition at all — the null-conditional half of + // its guard; the sibling below covers the transition-without-target half. + // RCR: LeaveState.cs Validate — change the guard to `LeaveTransition != null && LeaveTransition.TargetState + // == null` → RED (guard no longer fires; the layer check below dereferences null, so the thrown type is not + // InvalidOperationException). Verified isolated: the sibling below stays green. public void LeaveState_MissingConfiguration_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -100,6 +117,11 @@ public void LeaveState_MissingConfiguration_ThrowsException() } [Test] + // ADMIT: LeaveState.Validate rejects a transition declared without a Target — the `.TargetState == null` + // half of the same guard the sibling above exercises. + // RCR: LeaveState.cs Validate — change the guard to `LeaveTransition == null` → RED (guard no longer fires + // for a targetless transition, so the layer check throws the wrong type). Verified isolated: the sibling + // above stays green. public void LeaveState_MissingTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -112,6 +134,10 @@ public void LeaveState_MissingTarget_ThrowsException() } [Test] + // ADMIT: LeaveState.Transition() rejects a second call, so a leave state cannot end up with an ambiguous + // pair of exits where the later silently replaces the first. + // RCR: LeaveState.cs Transition — change `if (LeaveTransition != null)` to `if (false)` → RED (no + // InvalidOperationException; the second transition just overwrites). public void LeaveState_MultipleTransitions_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -131,6 +157,11 @@ public void LeaveState_MultipleTransitions_ThrowsException() } [Test] + // ADMIT: LeaveState.Validate requires the target to sit exactly one region layer ABOVE the leave state, so a + // leave pointing at its own layer is rejected rather than looping inside the region it means to exit. + // RCR: LeaveState.cs Validate — change the layer check's `RegionLayer - 1` to `RegionLayer` → RED (the + // same-layer target now passes). Verified: leaves the wrong-layer sibling below green, which is what + // separates the two halves of this guard. public void LeaveState_SameLayerTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -143,6 +174,10 @@ public void LeaveState_SameLayerTarget_ThrowsException() } [Test] + // ADMIT: the same layer check also rejects a target further than one layer up — a leave nested two regions + // deep may not jump straight to the outermost layer. + // RCR: LeaveState.cs Validate — change the layer check's `RegionLayer - 1` to `RegionLayer - 2` → RED (the + // two-layer jump now passes). Verified: leaves the same-layer sibling above green. public void LeaveState_WrongLayerTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory1 => diff --git a/Tests/Editor/StatechartNestSplit_IntegrationTest.cs b/Tests/Editor/StatechartNestSplit_IntegrationTest.cs index 380a447..a947541 100644 --- a/Tests/Editor/StatechartNestSplit_IntegrationTest.cs +++ b/Tests/Editor/StatechartNestSplit_IntegrationTest.cs @@ -30,6 +30,12 @@ public void Init() } [Test] + // ADMIT: WaitState.ForceComplete must complete the activity WITHOUT notifying the chart, so a nest + // force-completing an inner Wait does not re-enter Statechart.MoveNext and fire the wait's own + // transition. + // RCR: WaitState.cs ForceComplete — `_waitingActivity.ForceComplete();` to + // `_waitingActivity.Complete();` → RED (the callback re-enters and OnTransitionCall(1) fires). + // Also reddens the two Split+Wait siblings, which assert the same silence. public void NestedState_WaitStateInner_EventTrigger_ForceCompleteSuccess() { var statechart = new Statechart(SetupNest); @@ -51,6 +57,11 @@ public void NestedState_WaitStateInner_EventTrigger_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.DelayForceComplete must hand the triggering event to the blocked TaskWaitState + // so it replays when the task finishes, instead of dropping it. + // RCR: SplitState.cs DelayForceComplete — `taskState.EnqueuEvent(statechartEvent);` to + // `taskState.EnqueuEvent(null);` → RED (after the task completes the nest never takes its event + // transition; OnTransitionCall(3) is never received). Also reddens the Split sibling below. public async Task NestedState_TaskWaitStateInner_EventTrigger_QueueEvent() { _nestedStateData.Setup = SetupTaskWaitState; @@ -88,6 +99,11 @@ public async Task NestedState_TaskWaitStateInner_EventTrigger_QueueEvent() } [Test] + // ADMIT: SplitState.Exit must exclude a region parked on a LeaveState from the forced + // FinalState.Enter(), since the leave transition already owns that region's exit. + // RCR: SplitState.cs Exit — drop `&& !(innerState.CurrenState is LeaveState)` → RED (the leave + // region's final hook also fires: FinalOnEnterCall(0) received 3 times, not 2). Also reddens the + // TaskWait leave sibling below. public void SplitState_LeaveWaitStateInner_EventTrigger_LeaveExitSuccess() { var statechart = new Statechart(SetupLeaveSplit); @@ -110,6 +126,11 @@ public void SplitState_LeaveWaitStateInner_EventTrigger_LeaveExitSuccess() } [Test] + // ADMIT: SplitState.OnTrigger must drop events arriving while the split is paused on an unfinished + // inner task, so the pending Leave still wins once the task resolves. + // RCR: SplitState.cs OnTrigger — `if(_isPaused && !IsAllCompleted())` to `if(false)` → RED (the + // event transition fires instead: OnTransitionCall(3) received, (5) never). Isolated — the only + // test whose split is already paused when the event arrives. public async Task SplitState_LeaveTaskWaitStateInner_EventTrigger_QueueEvent_LeaveExitSuccess() { _nestedStateData.Setup = SetupTaskWaitState; @@ -149,6 +170,11 @@ public async Task SplitState_LeaveTaskWaitStateInner_EventTrigger_QueueEvent_Lea } [Test] + // ADMIT: SplitState.Exit must walk EVERY parallel region, so the region running alongside a + // force-completed Wait also gets its own exit hook. + // RCR: SplitState.cs Exit — `if (innerState.ExecuteExit)` to + // `if (innerState.ExecuteExit && i == 0)` → RED (StateOnExitCall(0) received once, not twice). + // Also reddens the other two-region force-complete tests; the single-region nests stay green. public void SplitState_WaitStateInner_EventTrigger_ForceCompleteSuccess() { var statechart = new Statechart(SetupSplit); @@ -170,6 +196,11 @@ public void SplitState_WaitStateInner_EventTrigger_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.DelayForceComplete must pause the split while an inner TaskWaitState is still + // running instead of completing it immediately on the event. + // RCR: SplitState.cs DelayForceComplete — `_isPaused = true;` to `_isPaused = false;` → RED (the + // split completes before the task finishes: OnTransitionCall(3) is received in the mid-task + // assertion block). Also reddens the Nest and Leave task siblings. public async Task SplitState_TaskWaitStateInner_EventTrigger_QueueEvent_CompleteSuccess() { _nestedStateData.Setup = SetupTaskWaitState; diff --git a/Tests/Editor/StatechartNestTest.cs b/Tests/Editor/StatechartNestTest.cs index 1b16bba..f7d4f1e 100644 --- a/Tests/Editor/StatechartNestTest.cs +++ b/Tests/Editor/StatechartNestTest.cs @@ -25,6 +25,11 @@ public void Init() } [Test] + // ADMIT: SplitState.ProcessInnerStates only lets the nest complete once its inner chart has reached a + // FinalState — any non-final inner state nulls the pending transition and keeps the nest parked. + // RCR: SplitState.cs ProcessInnerStates — change the `is not FinalState` branch to `else if (false)` → RED + // (the nest completes immediately). Broad by nature: reddens most of this fixture, since every test here + // depends on the nest not completing early. public void SimpleTest() { var statechart = new Statechart(SetupNest); @@ -44,6 +49,10 @@ public void SimpleTest() } [Test] + // ADMIT: SplitState.Enter fans out the nest's OnEnter actions before the inner chart runs, so a nest whose + // own transition has no Target still enters and drives its inner region. + // RCR: SplitState.cs Enter — replace the `_onEnter` fan-out source with an empty list → RED + // (StateOnEnterCall never received). Overlaps most of the fixture, which also asserts the entry hook. public void NestedState_WithoutTarget_Successful() { var statechart = new Statechart(factory => @@ -71,6 +80,10 @@ public void NestedState_WithoutTarget_Successful() } [Test] + // ADMIT: SplitState.ProcessInnerStates drains each inner state to a standstill (`while (nextState != null)`) + // before judging completion, so an inner event that chains several transitions lands on Final in one pass. + // RCR: SplitState.cs ProcessInnerStates — change the inner drain loop to `while (false)` → RED (the inner + // chart advances one step per outer trigger and never reaches Final). public void NestedState_InnerEventTrigger_CompleteSuccess() { var statechart = new Statechart(SetupNest); @@ -91,6 +104,11 @@ public void NestedState_InnerEventTrigger_CompleteSuccess() } [Test] + // ADMIT: same inner-drain contract as InnerEventTrigger_CompleteSuccess, with ExecuteFinal off. + // RCR: SplitState.cs ProcessInnerStates — inner drain loop to `while (false)` → RED. NOTE: no probed + // mutation separates this from the CompleteSuccess sibling — the ExecuteFinal flag is unreachable on this + // path because the inner state IS a FinalState by then, which the flag's own guard excludes. Suspected A5 + // duplicate pending a decision; not deleted without proof that no mutation distinguishes them. public void NestedState_InnerEventTrigger_DisableExecuteFinal_CompleteSuccess() { _nestedStateData.ExecuteFinal = false; @@ -113,6 +131,10 @@ public void NestedState_InnerEventTrigger_DisableExecuteFinal_CompleteSuccess() } [Test] + // ADMIT: same inner-drain contract, with ExecuteExit off. + // RCR: SplitState.cs ProcessInnerStates — inner drain loop to `while (false)` → RED. NOTE: as above, no + // probed mutation separates this from the CompleteSuccess sibling — flipping ExecuteExit in either direction + // leaves all four InnerEventTrigger variants green. Suspected A5 duplicate pending a decision. public void NestedState_InnerEventTrigger_DisableExecuteExit_CompleteSuccess() { _nestedStateData.ExecuteExit = false; @@ -135,6 +157,9 @@ public void NestedState_InnerEventTrigger_DisableExecuteExit_CompleteSuccess() } [Test] + // ADMIT: same inner-drain contract, with both ExecuteExit and ExecuteFinal off. + // RCR: SplitState.cs ProcessInnerStates — inner drain loop to `while (false)` → RED. NOTE: as above, + // indistinguishable from the CompleteSuccess sibling by every probed mutation. Suspected A5 duplicate. public void NestedState_InnerEventTrigger_DisableExecuteExitFinal_CompleteSuccess() { _nestedStateData.ExecuteFinal = false; @@ -158,6 +183,10 @@ public void NestedState_InnerEventTrigger_DisableExecuteExitFinal_CompleteSucces } [Test] + // ADMIT: SplitState.Enter rewinds every inner region to its InitialState, so re-entering a nest after Reset + // replays the inner chart instead of resuming where it stopped. + // RCR: SplitState.cs Enter — change `innerState.CurrenState = innerState.InitialState;` to keep the existing + // state when set → RED. Verified ISOLATED: the only test in this fixture that re-enters a nest. public void NestedState_InnerEventTrigger_RunResetRun_CompleteSuccess() { var statechart = new Statechart(SetupNest); @@ -181,6 +210,10 @@ public void NestedState_InnerEventTrigger_RunResetRun_CompleteSuccess() } [Test] + // ADMIT: SplitState.Exit runs each inner region's Exit when ExecuteExit is set, so force-completing a nest + // from the outside still tears the inner state down. + // RCR: SplitState.cs Exit — change `if (innerState.ExecuteExit)` to `if (false)` → RED. Leaves the three + // DisableExecuteExit siblings green, which is what separates the flag's two directions. public void NestedState_EventTrigger_ForceCompleteSuccess() { var statechart = new Statechart(SetupNest); @@ -201,6 +234,10 @@ public void NestedState_EventTrigger_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.Exit must HONOUR ExecuteFinal being off — a force-completed nest with the flag cleared + // must not synthesise an inner FinalState entry. + // RCR: SplitState.cs Exit — ignore the flag, `if (true && !(innerState.CurrenState is FinalState) ...)` → + // RED (the inner final hook fires when the caller disabled it). Leaves the flag-on siblings green. public void NestedState_EventTrigger_DisableExecuteFinal_ForceCompleteSuccess() { _nestedStateData.ExecuteFinal = false; @@ -223,6 +260,10 @@ public void NestedState_EventTrigger_DisableExecuteFinal_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.Exit must HONOUR ExecuteExit being off — a force-completed nest with the flag cleared + // must leave the inner state's exit hook alone. + // RCR: SplitState.cs Exit — ignore the flag, `if (true)` → RED (the inner exit hook fires when the caller + // disabled it). Leaves the flag-on siblings green. public void NestedState_EventTrigger_DisableExecuteExit_ForceCompleteSuccess() { _nestedStateData.ExecuteExit = false; @@ -245,6 +286,9 @@ public void NestedState_EventTrigger_DisableExecuteExit_ForceCompleteSuccess() } [Test] + // ADMIT: with both flags cleared, SplitState.Exit must skip the inner final hook as well as the inner exit. + // RCR: SplitState.cs Exit — ignore the ExecuteFinal flag, `if (true && !(innerState.CurrenState is + // FinalState) ...)` → RED. Leaves the flag-on siblings green. public void NestedState_EventTrigger_DisableExecuteExitFinal_ForceCompleteSuccess() { _nestedStateData.ExecuteFinal = false; @@ -268,6 +312,10 @@ public void NestedState_EventTrigger_DisableExecuteExitFinal_ForceCompleteSucces } [Test] + // ADMIT: the inner-drain contract holds per region, so a nest with several inner regions still lands each on + // Final in one pass. + // RCR: SplitState.cs ProcessInnerStates — inner drain loop to `while (false)` → RED (no region reaches + // Final). Shares this mutation with the single-region siblings above. public void MultipleNestedStates_InnerEventTrigger_CompleteSuccess() { _nestedStateData.Setup = SetupLayer0; @@ -297,6 +345,9 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: same multi-region inner-drain contract with both execute flags cleared. + // RCR: SplitState.cs ProcessInnerStates — inner drain loop to `while (false)` → RED. NOTE: as with the + // single-region variants, no probed mutation separates this from its flags-on sibling. Suspected A5. public void MultipleNestedStates_InnerEventTrigger_DisableExecuteExitFinal_CompleteSuccess() { _nestedStateData.ExecuteFinal = false; @@ -330,6 +381,10 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: SplitState.Exit synthesises the inner FinalState entry for every region that had not reached Final + // when the nest was force-completed from outside. + // RCR: SplitState.cs Exit — change `if (innerState.ExecuteFinal && ...)` to `if (false && ...)` → RED (the + // unfinished regions never get their final hook). Leaves the DisableExecuteFinal siblings green. public void MultipleNestedStates_EventTrigger_ForceCompleteSuccess() { _nestedStateData.Setup = SetupLayer0; @@ -359,6 +414,10 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: with both flags cleared, force-completing a multi-region nest must touch neither inner exits nor + // inner final hooks. + // RCR: SplitState.cs Exit — ignore ExecuteExit, `if (true)` → RED (inner exits fire when disabled). Leaves + // the flag-on siblings green. public void MultipleNestedStates_EventTrigger__DisableExecuteExitFinal_ForceCompleteSuccess() { _nestedStateData.ExecuteFinal = false; @@ -392,6 +451,11 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: a nest declared with no inner setup is rejected at construction rather than running as an empty + // region that can never complete. + // RCR: none exists — an empty nest trips BOTH NestState.Validate's `_innerStatesData.Count != 1` and + // SplitState.OnValidate's `_innerStatesData.Count == 0`. Disabling either leaves the other throwing (both + // directions verified green). Double-covered, not single-line falsifiable. public void NestedState_MissingConfiguration_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -402,6 +466,10 @@ public void NestedState_MissingConfiguration_ThrowsException() } [Test] + // ADMIT: SplitState.OnValidate rejects a nest whose completion transition targets the nest itself, which + // would re-enter the region forever. + // RCR: SplitState.cs OnValidate — change `if (_transition.TargetState?.Id == Id)` to `if (false)` → RED (no + // InvalidOperationException). Verified ISOLATED. public void NestedState_TransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartSplitTest.cs b/Tests/Editor/StatechartSplitTest.cs index 076fae8..34e9f5d 100644 --- a/Tests/Editor/StatechartSplitTest.cs +++ b/Tests/Editor/StatechartSplitTest.cs @@ -28,6 +28,11 @@ public void Init() } [Test] + // ADMIT: SplitState.ProcessInnerStates must hold the split open while any parallel region is still + // running, so Run() alone never fires the split's completion transition. + // RCR: SplitState.cs ProcessInnerStates — `else if (innerState.CurrenState is not FinalState)` to + // `else if (false)` → RED (the split completes on Run: OnTransitionCall(2) and FinalOnEnterCall(0) + // fire). Broad by nature: reddens every sibling that expects the split to stay open. public void SimpleTest() { var statechart = new Statechart(SetupSplit); @@ -47,6 +52,11 @@ public void SimpleTest() } [Test] + // ADMIT: SplitState.OnValidate must accept a split transition with no target — unlike WaitState, a + // split may fan out purely for its OnTransition side effect. + // RCR: SplitState.cs OnValidate — `if (_transition.TargetState?.Id == Id)` to + // `if (_transition.TargetState == null || _transition.TargetState.Id == Id)` → RED (construction + // now throws InvalidOperationException). The self-loop sibling stays green under this edit. public void SplitedState_WithoutTarget_Successful() { var statechart = new Statechart(factory => @@ -74,6 +84,11 @@ public void SplitedState_WithoutTarget_Successful() } [Test] + // ADMIT: SplitState.ProcessInnerStates must fan the triggering event into every parallel region, so + // both regions reach Final and the split's own transition fires exactly once. + // RCR: SplitState.cs ProcessInnerStates — `CurrenState.Trigger(statechartEvent)` to + // `CurrenState.Trigger(null)` → RED (OnTransitionCall(1) and (2) never received). Also reddens the + // three flag-disabling siblings below, which take the same path. public void SplitedState_InnerEventTrigger_CompleteSuccess() { var statechart = new Statechart(SetupSplit); @@ -94,6 +109,11 @@ public void SplitedState_InnerEventTrigger_CompleteSuccess() } [Test] + // ADMIT: SplitState.ProcessInnerStates must require ALL regions to be Final — one finished region + // out of two leaves the split on hold. + // RCR: SplitState.cs ProcessInnerStates — `else if (innerState.CurrenState is not FinalState)` to + // `else if (i > 0 && ...)`, so the first region no longer vetoes → RED (the split completes with + // region 0 still running). SimpleTest stays green: there region 1 still vetoes. public void SplitedState_InnerEventTrigger_HalfFinalized_OnHold() { _nestedStateData[0].Setup = factory => @@ -123,6 +143,12 @@ public void SplitedState_InnerEventTrigger_HalfFinalized_OnHold() } [Test] + // ADMIT: SplitState.ProcessInnerStates drives both regions to Final on the inner event even when + // the caller disabled NestedStateData.ExecuteFinal. + // RCR: SplitState.cs ProcessInnerStates — `CurrenState.Trigger(statechartEvent)` to `Trigger(null)` + // → RED (no region completes). NOTE: nothing separates this from the CompleteSuccess sibling — + // ExecuteFinal's guard in Exit also requires the region NOT be a FinalState, which it is here. + // Suspected A5 duplicate. public void SplitedState_InnerEventTrigger_DisableExecuteFinal_CompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -152,6 +178,11 @@ public void SplitedState_InnerEventTrigger_DisableExecuteFinal_CompleteSuccess() } [Test] + // ADMIT: SplitState.ProcessInnerStates drives both regions to Final on the inner event even when + // the caller disabled NestedStateData.ExecuteExit. + // RCR: SplitState.cs ProcessInnerStates — `CurrenState.Trigger(statechartEvent)` to `Trigger(null)` + // → RED (no region completes). NOTE: nothing separates this from the CompleteSuccess sibling — on + // this path ExecuteExit only calls FinalState.Exit(), a no-op. Suspected A5 duplicate. public void SplitedState_InnerEventTrigger_DisableExecuteExit_CompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -181,6 +212,11 @@ public void SplitedState_InnerEventTrigger_DisableExecuteExit_CompleteSuccess() } [Test] + // ADMIT: SplitState.ProcessInnerStates drives both regions to Final on the inner event with both + // NestedStateData flags disabled. + // RCR: SplitState.cs ProcessInnerStates — `CurrenState.Trigger(statechartEvent)` to `Trigger(null)` + // → RED (no region completes). NOTE: both flags are unreachable on this path (see the two siblings + // above), so nothing separates this from CompleteSuccess. Suspected A5 duplicate. public void SplitedState_InnerEventTrigger_DisableExecuteExitFinal_CompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -211,6 +247,11 @@ public void SplitedState_InnerEventTrigger_DisableExecuteExitFinal_CompleteSucce } [Test] + // ADMIT: SplitState.Enter must re-anchor every region to its InitialState, or a re-entered split + // resumes from the Final states left behind by the previous run. + // RCR: SplitState.cs Enter — `innerState.CurrenState = innerState.InitialState;` to + // `innerState.CurrenState ??= innerState.InitialState;` → RED (the second Run completes the split + // at once; OnTransitionCall(0) received 4 times, not 6). Only test here that re-enters a split. public void SplitedState_InnerEventTrigger_RunResetRun_CompleteSuccess() { var statechart = new Statechart(SetupSplit); @@ -234,6 +275,11 @@ public void SplitedState_InnerEventTrigger_RunResetRun_CompleteSuccess() } [Test] + // ADMIT: SplitState.Exit must run each unfinished region's own exit hook when the split is + // force-completed through its event transition. + // RCR: SplitState.cs Exit — `if (innerState.ExecuteExit)` to `if (false)` → RED (StateOnExitCall(0) + // never received). Leaves the two DisableExecuteExit siblings green, which is what separates the + // flag's directions; reddens the DisableExecuteFinal sibling, which expects the same hook. public void SplitedState_EventTrigger_ForceCompleteSuccess() { var statechart = new Statechart(SetupSplit); @@ -254,6 +300,11 @@ public void SplitedState_EventTrigger_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.Exit must honour NestedStateData.ExecuteExit=false and skip the inner region's + // exit hook while still finalising that region. + // RCR: SplitState.cs Exit — `if (innerState.ExecuteExit)` to `if (true)` → RED (StateOnExitCall(0) + // fires when the caller disabled it). Leaves the flag-on siblings green; also reddens the + // DisableExecuteExitFinal sibling. public void SplitedState_EventTrigger_DisableExecuteExit_ForceCompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -283,6 +334,11 @@ public void SplitedState_EventTrigger_DisableExecuteExit_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.Exit must honour NestedStateData.ExecuteFinal=false and skip the inner region's + // FinalState.Enter() when the split is force-completed. + // RCR: SplitState.cs Exit — ignore the flag, `if (true && !(innerState.CurrenState is FinalState) + // && ...)` → RED (FinalOnEnterCall(0) received 3 times, not 1). Leaves the flag-on siblings green; + // also reddens the DisableExecuteExitFinal sibling. public void SplitedState_EventTrigger_DisableExecuteFinal_ForceCompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -312,6 +368,11 @@ public void SplitedState_EventTrigger_DisableExecuteFinal_ForceCompleteSuccess() } [Test] + // ADMIT: SplitState.Exit must treat the two NestedStateData flags independently — turning + // ExecuteFinal off must not resurrect the exit hook the caller also disabled. + // RCR: SplitState.cs Exit — `if (innerState.ExecuteExit)` to + // `if (innerState.ExecuteExit || !innerState.ExecuteFinal)` → RED (StateOnExitCall(0) fires with + // both flags off). Green for every sibling that leaves one flag on; reddens the Multiple variant. public void SplitedState_EventTrigger_DisableExecuteExitFinal_ForceCompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -342,6 +403,11 @@ public void SplitedState_EventTrigger_DisableExecuteExitFinal_ForceCompleteSucce } [Test] + // ADMIT: SplitState.ProcessInnerStates must keep draining a region until it settles, or a region + // whose first state is itself a Split is entered but never driven. + // RCR: SplitState.cs ProcessInnerStates — `while (nextState != null)` to + // `while (nextState != null && nextState is not SplitState)` → RED (the inner split never runs; + // OnTransitionCall(0) received 3 times, not 5). Reddens the three nested-split siblings too. public void MultipleSplitedStates_InnerEventTrigger_CompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -385,6 +451,12 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: SplitState.ProcessInnerStates drives a nested split to completion on the inner event with + // both NestedStateData flags disabled at both layers. + // RCR: SplitState.cs ProcessInnerStates — `while (nextState != null)` to + // `while (nextState != null && nextState is not SplitState)` → RED (the inner split never runs). + // NOTE: the flags are unreachable on this path, so nothing separates this from the flags-on sibling + // above. Suspected A5 duplicate. public void MultipleSplitedStates_InnerEventTrigger_DisableExecuteExitFinal_CompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -432,6 +504,11 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: SplitState.Exit must cascade into a region whose current state is itself a SplitState, so + // the inner split's own exit and its regions' exits run as well. + // RCR: SplitState.cs Exit — `if (innerState.ExecuteExit)` to `if (innerState.ExecuteExit && + // !(innerState.CurrenState is SplitState))` → RED (StateOnExitCall(1) received once, not twice). + // Only test that force-completes over an unfinished inner split. public void MultipleSplitedStates_EventTrigger_ForceCompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -475,6 +552,11 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: SplitState.Exit must honour ExecuteExit=false even when the region holds an unfinished + // nested SplitState — the inner split is abandoned, not exited. + // RCR: SplitState.cs Exit — `if (innerState.ExecuteExit)` to + // `if (innerState.ExecuteExit || innerState.CurrenState is SplitState)` → RED (the inner split + // exits: StateOnExitCall(0) fires and StateOnExitCall(1) is received twice). Isolated. public void MultipleSplitedStates_EventTrigger__DisableExecuteExitFinal_ForceCompleteSuccess() { for (int i = 0; i < _nestedStateData.Length; i++) @@ -522,6 +604,11 @@ void SetupLayer0(IStateFactory factory) } [Test] + // ADMIT: SplitState.Validate must reject a split with no nested setup, which would otherwise + // dereference a null _transition inside OnValidate. + // RCR: none exists — an empty split trips BOTH SplitState.Validate's `_innerStatesData.Count < 2` + // and OnValidate's `_innerStatesData.Count == 0`; disabling either leaves the other throwing the + // same InvalidOperationException. Double-covered, not single-line falsifiable. public void SplitState_MissingConfiguration_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -532,6 +619,11 @@ public void SplitState_MissingConfiguration_ThrowsException() } [Test] + // ADMIT: SplitState.Validate must reject a split configured with a single region — one region is a + // Nest, not a Split. + // RCR: SplitState.cs Validate — `if (_innerStatesData.Count < 2)` to + // `if (_innerStatesData.Count < 1)` → RED (a one-region split constructs cleanly). The empty-split + // sibling stays green: it still throws from the same guard. NestState overrides Validate. public void SplitState_SingleConfiguration_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -544,6 +636,10 @@ public void SplitState_SingleConfiguration_ThrowsException() } [Test] + // ADMIT: SplitState.OnValidate must reject a split whose completion transition targets the split + // itself, which would loop forever at runtime. + // RCR: SplitState.cs OnValidate — `if (_transition.TargetState?.Id == Id)` to `if (false)` → RED + // (no InvalidOperationException). Isolated. public void SplitState_TransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartStateTest.cs b/Tests/Editor/StatechartStateTest.cs index 80a6e6b..010e68b 100644 --- a/Tests/Editor/StatechartStateTest.cs +++ b/Tests/Editor/StatechartStateTest.cs @@ -20,6 +20,10 @@ public void Init() } [Test] + // ADMIT: SimpleState.Exit fans out its OnExit actions when an event finally moves the state on, so a + // waiting state still runs its exit hook rather than being abandoned in place. + // RCR: SimpleState.cs Exit — change the fan-out loop bound to `i < 0` → RED (StateOnExitCall(1) is + // never received after Trigger). Also reddens the Pause/Reset siblings, which assert the same hook. public void SimpleTest() { var statechart = new Statechart(SetupStateFlow); @@ -41,6 +45,11 @@ public void SimpleTest() } [Test] + // ADMIT: StateInternal.Trigger runs a targetless event transition's action but does NOT exit the state + // — the `if (nextState == null)` early return fires TriggerTransition and returns without TriggerExit, + // so the chart stays put instead of falling out of the state with nowhere to go. + // RCR: StateInternal.cs Trigger — change `if (nextState == null)` to `if (false)` → RED (execution + // falls through to TriggerExit, so DidNotReceive().StateOnExitCall(1) fails). public void State_TransitionWithoutTarget_Succeeds() { var statechart = new Statechart(factory => @@ -70,6 +79,12 @@ public void State_TransitionWithoutTarget_Succeeds() } [Test] + // ADMIT: SimpleState.OnTrigger resolves events through its `_events` map, so an event the state never + // registered resolves to no transition and leaves the chart parked. + // RCR: none exists — the miss is double-guarded by StatechartEvent's Id-based GetHashCode AND Id-based + // Equals. Weakening either alone leaves the lookup missing: `GetHashCode => 0` still fails Equals in the + // shared bucket, and an always-true Equals is never consulted because the differing hash sends the probe + // to another bucket (both verified green). Double-covered, not single-line falsifiable. public void State_TriggerNotConfiguredEvent_NoEffect() { var statechart = new Statechart(SetupStateFlow); @@ -90,6 +105,10 @@ public void State_TriggerNotConfiguredEvent_NoEffect() } [Test] + // ADMIT: Statechart.Run re-arms _isRunning, so a chart resumed after Pause processes triggers again + // instead of staying inert (Trigger early-returns while !_isRunning). + // RCR: Statechart.cs Run — delete `_isRunning = true;` → RED (after Pause the chart never resumes, so + // OnTransitionCall(1) is never received). Also reddens siblings, which all Run() first. public void State_PauseRunStatechart_Success() { var statechart = new Statechart(SetupStateFlow); @@ -108,6 +127,10 @@ public void State_PauseRunStatechart_Success() } [Test] + // ADMIT: Statechart.Reset rewinds _currentState to the factory's initial state, so a Reset+Run replays + // the flow from the top — that replay is what makes the Received(2) counts below correct. + // RCR: Statechart.cs Reset — delete `_currentState = _stateFactory.InitialState;` → RED (no replay, so + // Received(2).OnTransitionCall(0) sees only 1 call). public void State_ResetRunStatechart_Success() { var statechart = new Statechart(SetupStateFlow); @@ -126,6 +149,10 @@ public void State_ResetRunStatechart_Success() } [Test] + // ADMIT: SimpleState.Validate rejects an event transition that targets its own state, which would + // re-enter the state forever rather than advancing. + // RCR: SimpleState.cs Validate — change `if (eventTransition.Value.TargetState?.Id == Id)` to + // `if (false)` → RED (no InvalidOperationException). public void StateTransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartTaskWaitTest.cs b/Tests/Editor/StatechartTaskWaitTest.cs index d9655de..50968d4 100644 --- a/Tests/Editor/StatechartTaskWaitTest.cs +++ b/Tests/Editor/StatechartTaskWaitTest.cs @@ -27,6 +27,10 @@ public void Init() } [Test] + // ADMIT: TaskWaitState.OnTrigger only returns its transition once the awaited task has set Completed, + // so the chart parks in the state for the task's duration instead of falling straight through. + // RCR: TaskWaitState.cs OnTrigger — change `return Completed ? _transition : null;` to `return null;` + // → RED (the awaited task finishes but the chart never advances). public async Task SimpleTest() { var statechart = new Statechart(SetupTaskWaitState); @@ -52,6 +56,12 @@ public async Task SimpleTest() } [Test] + // ADMIT: TaskWaitState.OnTrigger ignores the incoming event entirely — it never consults an event map, + // so a trigger arriving mid-task cannot pre-empt the await (unlike WaitState, which does honour events). + // RCR: TaskWaitState.cs OnTrigger — return `_transition` when `statechartEvent != null` → RED (the + // mid-task assertions below fire: the chart advances on Trigger instead of waiting for the task). Those + // assertions must stay BEFORE `_blocker = false` — once the task completes both paths reach the same + // final state and nothing downstream can tell them apart. public async Task TaskWait_EventTrigger_DoesNothing() { var statechart = new Statechart(SetupTaskWaitState); @@ -68,6 +78,11 @@ public async Task TaskWait_EventTrigger_DoesNothing() statechart.Trigger(_event); + // Discriminating window: the task is still pending, so an honoured event would show up here. + _caller.DidNotReceive().OnTransitionCall(1); + _caller.DidNotReceive().StateOnExitCall(0); + _caller.DidNotReceive().FinalOnEnterCall(0); + _blocker = false; await YieldWaitTask(); @@ -79,6 +94,10 @@ public async Task TaskWait_EventTrigger_DoesNothing() } [Test] + // ADMIT: the UniTask overload shares TaskWaitState.OnTrigger with the Task overload, so it ignores + // mid-await events for the same reason its sibling above does. + // RCR: TaskWaitState.cs OnTrigger — same edit as the Task sibling above; both go RED together, which + // is itself the point: the two overloads are not separately guarded. public async Task UniTaskWait_EventTrigger_DoesNothing() { var statechart = new Statechart(SetupUniTaskWaitState); @@ -106,6 +125,10 @@ public async Task UniTaskWait_EventTrigger_DoesNothing() } [Test] + // ADMIT: TaskWaitState.Validate rejects a task-wait state with no await action configured. + // RCR: no single-line mutation exists — this fixture's unconfigured state also has no transition, so + // it trips both the `_taskAwaitAction == null` and `_transition?.TargetState == null` guards; + // disabling either leaves the other throwing (verified). Double-covered, not single-line falsifiable. public void TaskWait_MissingConfiguration_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -116,6 +139,10 @@ public void TaskWait_MissingConfiguration_ThrowsException() } [Test] + // ADMIT: TaskWaitState.Validate rejects a completion transition with no Target, so a finished task + // always has somewhere to go. + // RCR: TaskWaitState.cs Validate — change `if (_transition?.TargetState == null)` to `if (false)` → + // RED (no InvalidOperationException). public void TaskWait_MissingTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -128,6 +155,10 @@ public void TaskWait_MissingTarget_ThrowsException() } [Test] + // ADMIT: TaskWaitState.Validate rejects a completion transition pointing back at its own state, which + // would re-run the task forever. + // RCR: TaskWaitState.cs Validate — change `if (_transition.TargetState?.Id == Id)` to `if (false)` → + // RED (no InvalidOperationException). public void TaskWait_TransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartTest.cs b/Tests/Editor/StatechartTest.cs index 5ec33a7..eca26c0 100644 --- a/Tests/Editor/StatechartTest.cs +++ b/Tests/Editor/StatechartTest.cs @@ -19,6 +19,10 @@ public void Init() } [Test] + // ADMIT: InitialState.Exit fans out every registered OnExit action, so the initial state's exit hook + // runs on the way to the final state rather than being skipped. + // RCR: InitialState.cs Exit — change the fan-out loop bound to `i < 0` → RED + // (_caller.InitialOnExitCall(0) is never received). public void SimpleTest() { var statechart = new Statechart(SetupSimpleFlow); @@ -31,6 +35,12 @@ public void SimpleTest() } [Test] + // ADMIT: InitialState.Validate rejects an initial state with NO transition at all — the null-conditional + // in `_transition?.TargetState == null` is what covers this half; the sibling test below covers the + // transition-exists-but-has-no-target half of the same guard. + // RCR: InitialState.cs Validate — change the guard to `_transition != null && _transition.TargetState + // == null` (no longer fires when _transition itself is null) → RED (no MissingMemberException). The + // sibling below stays green under this edit. public void InitialState_MissingTransition_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -41,6 +51,10 @@ public void InitialState_MissingTransition_ThrowsException() } [Test] + // ADMIT: InitialState.Validate also rejects a transition that was declared but never given a Target — + // the `.TargetState == null` half of the same guard the sibling test above exercises. + // RCR: InitialState.cs Validate — change the guard to `_transition == null` (no longer inspects + // TargetState) → RED (no MissingMemberException). The sibling above stays green under this edit. public void InitialState_TransitionWithoutTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -53,6 +67,10 @@ public void InitialState_TransitionWithoutTarget_ThrowsException() } [Test] + // ADMIT: InitialState.Validate rejects an initial state whose transition targets itself, which would + // otherwise build a chart that spins on entry. + // RCR: InitialState.cs Validate — change `if (_transition.TargetState.Id == Id)` to `if (false)` → RED + // (no InvalidOperationException). public void InitialState_StateTransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -65,6 +83,10 @@ public void InitialState_StateTransitionsLoop_ThrowsException() } [Test] + // ADMIT: InitialState.Transition() rejects a second Transition() call, so an initial state cannot end up + // with an ambiguous pair of outgoing transitions (the second would silently replace the first). + // RCR: InitialState.cs Transition — change `if (_transition != null)` to `if (false)` → RED (no + // InvalidOperationException; the second transition just overwrites the first). public void InitialState_MultipleTransitions_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -78,6 +100,10 @@ public void InitialState_MultipleTransitions_ThrowsException() } [Test] + // ADMIT: Statechart's constructor rejects a setup that never declared an initial state, rather than + // leaving _currentState null for Run() to dereference later. + // RCR: Statechart.cs ctor — change `if (_stateFactory.InitialState == null)` to `if (false)` → RED (no + // MissingMemberException at construction). public void NoInitialState_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -87,6 +113,10 @@ public void NoInitialState_ThrowsException() } [Test] + // ADMIT: StateFactory.Initial rejects a second initial state in the same region instead of silently + // replacing the first, which would make the chart's entry point depend on declaration order. + // RCR: StateFactory.cs Initial — change `if (InitialState != null)` to `if (false)` → RED (no + // InvalidOperationException). public void MultipleInitialStates_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -98,6 +128,10 @@ public void MultipleInitialStates_ThrowsException() } [Test] + // ADMIT: StateFactory.Final rejects a second final state in the same region, for the same reason as + // Initial above — the survivor would otherwise depend on declaration order. + // RCR: StateFactory.cs Final — change `if (FinalState != null)` to `if (false)` → RED (no + // InvalidOperationException). public void MultipleFinalState_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartTransitionTest.cs b/Tests/Editor/StatechartTransitionTest.cs index 53ee773..b94b087 100644 --- a/Tests/Editor/StatechartTransitionTest.cs +++ b/Tests/Editor/StatechartTransitionTest.cs @@ -19,6 +19,10 @@ public void Init() } [Test] + // ADMIT: TransitionState.Enter fans out its OnEnter actions as the chart passes through, so a + // pass-through state still runs its entry hook on the way to the next state. + // RCR: TransitionState.cs Enter — change the fan-out loop bound to `i < 0` → RED + // (_caller.StateOnEnterCall(0) is never received). public void SimpleTest() { var statechart = new Statechart(SetupTransitionFlow); @@ -34,6 +38,10 @@ public void SimpleTest() } [Test] + // ADMIT: TransitionState.Validate rejects a transition declared without a Target — the + // `.TargetState == null` half of its guard; the sibling below covers the no-transition-at-all half. + // RCR: TransitionState.cs Validate — change the guard to `_transition == null` (no longer inspects + // TargetState) → RED (no InvalidOperationException). The sibling below stays green under this edit. public void TransitionState_TransitionWithoutTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -46,6 +54,11 @@ public void TransitionState_TransitionWithoutTarget_ThrowsException() } [Test] + // ADMIT: TransitionState.Validate rejects a transition state with no outgoing transition at all — the + // null-conditional half of the same guard the sibling above exercises. Such a state would strand the + // chart with nowhere to advance to. + // RCR: TransitionState.cs Validate — change the guard to `_transition != null && + // _transition.TargetState == null` → RED (no InvalidOperationException). Sibling above stays green. public void TransitionState_TransitionWithoutTransition_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -56,6 +69,10 @@ public void TransitionState_TransitionWithoutTransition_ThrowsException() } [Test] + // ADMIT: TransitionState.Validate rejects a transition state targeting itself, which would spin the + // chart on entry rather than advancing. + // RCR: TransitionState.cs Validate — change `if (_transition.TargetState.Id == Id)` to `if (false)` → + // RED (no InvalidOperationException). public void TransitionState_TransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/Tests/Editor/StatechartWaitTest.cs b/Tests/Editor/StatechartWaitTest.cs index 8be7e3f..a03b76b 100644 --- a/Tests/Editor/StatechartWaitTest.cs +++ b/Tests/Editor/StatechartWaitTest.cs @@ -14,7 +14,7 @@ public class StatechartWaitTest private readonly IStatechartEvent _event = new StatechartEvent("Event"); private IMockCaller _caller; - private IWaitActivity activity; + private IWaitActivity _activity; [SetUp] public void Init() @@ -23,9 +23,13 @@ public void Init() } [Test] + // ADMIT: WaitState.OnTrigger only hands back its transition once the waiting _activity reports + // IsCompleted, so the chart parks in the state until the _activity is completed from outside. + // RCR: WaitState.cs OnTrigger — change `return _waitingActivity.IsCompleted ? _transition : null;` to + // `return null;` → RED (Complete() no longer advances; OnTransitionCall(1) never received). public async Task SimpleTest() { - var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => activity = waitActivity)); + var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => _activity = waitActivity)); statechart.Run(); @@ -37,8 +41,8 @@ public async Task SimpleTest() _caller.DidNotReceive().StateOnExitCall(0); _caller.DidNotReceive().FinalOnEnterCall(0); - await Task.Yield(); // To avoid race conditions with the activity creation - activity.Complete(); + await Task.Yield(); // To avoid race conditions with the _activity creation + _activity.Complete(); _caller.Received().OnTransitionCall(1); _caller.DidNotReceive().OnTransitionCall(2); @@ -47,11 +51,16 @@ public async Task SimpleTest() } [Test] + // ADMIT: WaitActivity.AreInnerCompleted requires EVERY split child to report IsCompleted before the + // parent _activity counts as done, so completing both children releases the wait. + // RCR: WaitActivity.cs AreInnerCompleted — change `if (!_activity.IsCompleted)` to `if (true)` (no + // child ever counts as complete) → RED (the chart never advances even with both completed). The + // on-hold sibling stays green: it expects no advance either way. public async Task SplitActivity_CompleteBoth_Success() { IWaitActivity activitySplit = null; - var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => activity = waitActivity)); + var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => _activity = waitActivity)); statechart.Run(); @@ -63,9 +72,9 @@ public async Task SplitActivity_CompleteBoth_Success() _caller.DidNotReceive().StateOnExitCall(0); _caller.DidNotReceive().FinalOnEnterCall(0); - await Task.Yield(); // To avoid race conditions with the activity creation - activitySplit = activity.Split(); - activity.Complete(); + await Task.Yield(); // To avoid race conditions with the _activity creation + activitySplit = _activity.Split(); + _activity.Complete(); _caller.DidNotReceive().OnTransitionCall(1); _caller.DidNotReceive().OnTransitionCall(2); @@ -81,9 +90,15 @@ public async Task SplitActivity_CompleteBoth_Success() } [Test] + // ADMIT: WaitActivity.IsCompleted ANDs the parent's own _completed flag with AreInnerCompleted(), so + // one finished child out of two leaves the chart waiting rather than advancing early. + // RCR: WaitActivity.cs AreInnerCompleted — change `if (!_activity.IsCompleted)` to `if (false)` so + // every child counts as done → RED (the chart advances with one child still outstanding). Dropping + // the inner term from IsCompleted instead does NOT redden this: the parent's own _completed is what + // the surviving term reads, and Complete() has already set it. public async Task SplitActivity_CompleteOnlyOneActivity_OnHold() { - var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => activity = waitActivity)); + var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => _activity = waitActivity)); statechart.Run(); @@ -95,9 +110,9 @@ public async Task SplitActivity_CompleteOnlyOneActivity_OnHold() _caller.DidNotReceive().StateOnExitCall(0); _caller.DidNotReceive().FinalOnEnterCall(0); - await Task.Yield(); // To avoid race conditions with the activity creation - activity.Split(); - activity.Complete(); + await Task.Yield(); // To avoid race conditions with the _activity creation + _activity.Split(); + _activity.Complete(); _caller.DidNotReceive().OnTransitionCall(1); _caller.DidNotReceive().OnTransitionCall(2); @@ -111,6 +126,11 @@ public async Task SplitActivity_CompleteOnlyOneActivity_OnHold() } [Test] + // ADMIT: WaitState.OnTrigger lets a registered event WITH a target pre-empt the pending _activity and + // move the chart on, rather than being ignored while the state waits. + // RCR: WaitState.cs OnTrigger — change the event-path `return transition;` to + // `return transition.TargetState != null ? null : transition;` → RED (the targeted event no longer + // advances). The targetless-event sibling below stays green under this edit. public void WaitState_EventTrigger_ForceCompleted() { var statechart = new Statechart(factory => SetupWaitState(factory, waitActivity => { })); @@ -134,6 +154,11 @@ public void WaitState_EventTrigger_ForceCompleted() } [Test] + // ADMIT: a registered event WITHOUT a target still runs its OnTransition action but leaves the state + // in place — the _activity is not force-completed and no exit/enter fires. + // RCR: WaitState.cs OnTrigger — change the event-path `return transition;` to + // `return transition.TargetState == null ? null : transition;` → RED (OnTransitionCall(2) is never + // evoked). The targeted-event sibling above stays green under this edit. public void WaitState_EventTriggerWithoutTarget_OnlyEvokesOnTransition() { var statechart = new Statechart(factory => @@ -142,7 +167,7 @@ public void WaitState_EventTriggerWithoutTarget_OnlyEvokesOnTransition() var final = SetupSimpleFlow(factory, waiting); waiting.OnEnter(() => _caller.StateOnEnterCall(0)); - waiting.WaitingFor(activity => {}).OnTransition(() => _caller.OnTransitionCall(1)).Target(final); + waiting.WaitingFor(_activity => {}).OnTransition(() => _caller.OnTransitionCall(1)).Target(final); waiting.Event(_event).OnTransition(() => _caller.OnTransitionCall(2)); waiting.OnExit(() => _caller.StateOnExitCall(0)); }); @@ -166,6 +191,11 @@ public void WaitState_EventTriggerWithoutTarget_OnlyEvokesOnTransition() } [Test] + // ADMIT: WaitState.Validate rejects a wait state with no WaitingFor _activity, which would otherwise + // park the chart forever with nothing able to complete it. + // RCR: no single-line mutation exists — this fixture's unconfigured state also has no transition, so + // it trips both the `_waitAction == null` and `_transition?.TargetState == null` guards; disabling + // either leaves the other throwing (verified). Double-covered, not single-line falsifiable. public void WaitState_MissingConfiguration_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -176,6 +206,10 @@ public void WaitState_MissingConfiguration_ThrowsException() } [Test] + // ADMIT: WaitState.Validate rejects a wait state whose completion transition has no Target, so a + // completed _activity always has somewhere to go. + // RCR: WaitState.cs Validate — change `if (_transition?.TargetState == null)` to `if (false)` → RED + // (no InvalidOperationException). public void WaitState_MissingTarget_ThrowsException() { Assert.Throws(() => new Statechart(factory => @@ -188,6 +222,10 @@ public void WaitState_MissingTarget_ThrowsException() } [Test] + // ADMIT: WaitState.Validate rejects a completion transition pointing back at its own state, which + // would restart the _activity forever. + // RCR: WaitState.cs Validate — change `if (_transition.TargetState?.Id == Id)` to `if (false)` → RED + // (no InvalidOperationException). public void WaitState_TransitionsLoop_ThrowsException() { Assert.Throws(() => new Statechart(factory => diff --git a/package.json b/package.json index b8d9650..1217e54 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "com.gamelovers.statechart", "displayName": "Statechart", "author": "Miguel Tomas", - "version": "0.9.4", + "version": "0.9.5", "unity": "2022.3", "license": "MIT", "description": "This package allows the use of Statecharts (Hierarchichal State Machine) within an Unity project.\n\nThe primary feature of Statecharts is that states can be organized in a hierarchy.\nA Statecharts is a state machine where each state in the state machine may define its own subordinate state machines, called substates.\nThose states can again define substates.\n\nFor more information: https://statecharts.github.io/what-is-a-statechart.html",