diff --git a/.cspell.json b/.cspell.json index 23ffdf55..73b0711d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -245,7 +245,9 @@ "unrecognised", "Unrecognised", "unmatch", - "unstubbed" + "unstubbed", + "unparseable", + "entrancy" ], "ignoreRegExpList": [ "/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g", diff --git a/.gitignore b/.gitignore index 9bd2afc8..1e151631 100644 --- a/.gitignore +++ b/.gitignore @@ -355,4 +355,6 @@ pr-description.md* SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta Temp -Temp.meta \ No newline at end of file +Temp.meta + +failed-tests.txt* \ No newline at end of file diff --git a/.llm/skills/index.md b/.llm/skills/index.md index d2107133..3c6b3553 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-04-30. Do not edit manually. +> **Auto-generated** on 2026-05-01. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -9,7 +9,7 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 135 | +| Total Skills | 138 | | Categories | 7 | --- @@ -22,7 +22,7 @@ - [Performance](#performance) (40) - [Scripting](#scripting) (15) - [Solid](#solid) (15) -- [Testing](#testing) (32) +- [Testing](#testing) (35) --- @@ -161,40 +161,43 @@ ## Testing -| Skill | Lines | Complexity | Status | Performance | Tags | -| ----------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | -------------- | ---------------------------- | -| [Comprehensive Test Coverage Requirements](./testing/comprehensive-test-coverage.md) | [ok] 142 | [intermediate] | [stable] | [risk: none] | testing, coverage | -| [Data-Driven Coverage Patterns](./testing/test-coverage-data-driven.md) | [ok] 173 | [intermediate] | [stable] | [risk: none] | testing, data-driven | -| [Data-Driven Test Sources](./testing/data-driven-tests-sources.md) | [ok] 256 | [intermediate] | [stable] | [risk: none] | testing, parameterized | -| [Data-Driven Test Usage Patterns](./testing/data-driven-tests-usage.md) | [draft] 108 | [intermediate] | [stable] | [risk: none] | testing, parameterized | -| [Data-Driven Tests with TestCaseSource](./testing/data-driven-tests.md) | [ok] 198 | [intermediate] | [stable] | [risk: low] | testing, parameterized | -| [Git and Parser Robustness in CI/CD](./testing/git-workflow-robustness.md) | [ok] 214 | [intermediate] | [stable] | [risk: none] | testing, git | -| [Git and Parser Robustness in CI/CD Part 1](./testing/git-workflow-robustness-part-1.md) | [ok] 188 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Inspector Overlay Invariants for MessageAwareComponent](./testing/inspector-overlay-invariants.md) | [ok] 152 | [intermediate] | [stable] | [risk: low] | testing, editor | -| [Script Test Coverage Requirements](./testing/script-test-coverage.md) | [ok] 260 | [intermediate] | [stable] | [risk: none] | testing, scripts | -| [Shared Fixtures: Generic Base](./testing/shared-test-fixtures-generic-base.md) | [ok] 186 | [advanced] | [stable] | [risk: high] | testing, fixtures | -| [Shared Fixtures: Reference Counting](./testing/shared-test-fixtures-reference-counting.md) | [ok] 253 | [advanced] | [stable] | [risk: high] | testing, fixtures | -| [Shared Test Fixtures with Reference Counting](./testing/shared-test-fixtures.md) | [ok] 166 | [advanced] | [stable] | [risk: high] | testing, fixtures | -| [Test Base Class Cleanup Usage](./testing/test-base-class-cleanup-usage.md) | [ok] 219 | [intermediate] | [stable] | [risk: low] | testing, cleanup | -| [Test Base Class with Automatic Resource Cleanup](./testing/test-base-class-cleanup.md) | [ok] 125 | [intermediate] | [stable] | [risk: low] | testing, cleanup | -| [Test Base Class with Automatic Resource Cleanup Part 1](./testing/test-base-class-cleanup-part-1.md) | [ok] 232 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Test Categories for Selective Execution](./testing/test-categories.md) | [ok] 253 | [basic] | [stable] | [risk: high] | testing, organization | -| [Test Categories for Selective Execution Part 1](./testing/test-categories-part-1.md) | [draft] 67 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Test Category Execution](./testing/test-categories-execution.md) | [ok] 143 | [basic] | [stable] | [risk: none] | testing, organization | -| [Test Code Quality and Accuracy](./testing/test-code-quality.md) | [ok] 244 | [intermediate] | [stable] | [risk: medium] | testing, documentation | -| [Test Coverage Scenario Categories](./testing/test-coverage-scenario-categories.md) | [ok] 224 | [intermediate] | [stable] | [risk: none] | testing, coverage | -| [Test Diagnostics and Investigation Patterns](./testing/test-diagnostics.md) | [ok] 248 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | -| [Test Diagnostics Patterns](./testing/test-diagnostics-patterns.md) | [ok] 190 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | -| [Test Diagnostics Usage](./testing/test-diagnostics-usage.md) | [ok] 197 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | -| [Test Failure Investigation and Zero-Flaky Policy](./testing/test-failure-investigation.md) | [ok] 120 | [intermediate] | [stable] | [risk: none] | testing, investigation | -| [Test Failure Investigation Procedure](./testing/test-failure-investigation-procedure.md) | [ok] 217 | [intermediate] | [stable] | [risk: none] | testing, investigation | -| [Test Failure Root Causes and Anti-Patterns](./testing/test-failure-investigation-root-causes.md) | [ok] 187 | [intermediate] | [stable] | [risk: none] | testing, root-cause-analysis | -| [Test Invalid Skill](./testing/test-invalid-skill.md) | [draft] 31 | [expert] | [stable] | [risk: high] | testing, fixtures | -| [Test Organization and Assertions](./testing/test-coverage-organization-assertions.md) | [ok] 174 | [basic] | [stable] | [risk: none] | testing, assertions | -| [Test Production Code Directly](./testing/test-production-code.md) | [ok] 146 | [intermediate] | [stable] | [risk: none] | testing, anti-patterns | -| [Test Production Code Directly Part 1](./testing/test-production-code-part-1.md) | [ok] 205 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Test Production Code Directly Part 2](./testing/test-production-code-part-2.md) | [draft] 66 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | [warn] 270 | [basic] | [stable] | [risk: none] | testing, unity | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ------------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ---------------- | ---------------------------- | +| [Allocation Coverage Required for Dispatch](./testing/allocation-coverage-required-for-dispatch.md) | [ok] 259 | [intermediate] | [stable] | [risk: critical] | testing, allocation | +| [Comprehensive Test Coverage Requirements](./testing/comprehensive-test-coverage.md) | [ok] 142 | [intermediate] | [stable] | [risk: none] | testing, coverage | +| [Data-Driven Coverage Patterns](./testing/test-coverage-data-driven.md) | [ok] 173 | [intermediate] | [stable] | [risk: none] | testing, data-driven | +| [Data-Driven Test Sources](./testing/data-driven-tests-sources.md) | [ok] 256 | [intermediate] | [stable] | [risk: none] | testing, parameterized | +| [Data-Driven Test Usage Patterns](./testing/data-driven-tests-usage.md) | [draft] 108 | [intermediate] | [stable] | [risk: none] | testing, parameterized | +| [Data-Driven Tests with TestCaseSource](./testing/data-driven-tests.md) | [ok] 198 | [intermediate] | [stable] | [risk: low] | testing, parameterized | +| [Git and Parser Robustness in CI/CD](./testing/git-workflow-robustness.md) | [ok] 214 | [intermediate] | [stable] | [risk: none] | testing, git | +| [Git and Parser Robustness in CI/CD Part 1](./testing/git-workflow-robustness-part-1.md) | [ok] 188 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Inspector Overlay Invariants for MessageAwareComponent](./testing/inspector-overlay-invariants.md) | [ok] 152 | [intermediate] | [stable] | [risk: low] | testing, editor | +| [Script Test Coverage Requirements](./testing/script-test-coverage.md) | [ok] 260 | [intermediate] | [stable] | [risk: none] | testing, scripts | +| [Shared Fixtures: Generic Base](./testing/shared-test-fixtures-generic-base.md) | [ok] 186 | [advanced] | [stable] | [risk: high] | testing, fixtures | +| [Shared Fixtures: Reference Counting](./testing/shared-test-fixtures-reference-counting.md) | [ok] 253 | [advanced] | [stable] | [risk: high] | testing, fixtures | +| [Shared Test Fixtures with Reference Counting](./testing/shared-test-fixtures.md) | [ok] 166 | [advanced] | [stable] | [risk: high] | testing, fixtures | +| [Single Thread Contract](./testing/single-thread-contract.md) | [ok] 201 | [intermediate] | [stable] | [risk: high] | testing, concurrency | +| [Test Base Class Cleanup Usage](./testing/test-base-class-cleanup-usage.md) | [ok] 219 | [intermediate] | [stable] | [risk: low] | testing, cleanup | +| [Test Base Class with Automatic Resource Cleanup](./testing/test-base-class-cleanup.md) | [ok] 125 | [intermediate] | [stable] | [risk: low] | testing, cleanup | +| [Test Base Class with Automatic Resource Cleanup Part 1](./testing/test-base-class-cleanup-part-1.md) | [ok] 232 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Test Categories for Selective Execution](./testing/test-categories.md) | [ok] 253 | [basic] | [stable] | [risk: high] | testing, organization | +| [Test Categories for Selective Execution Part 1](./testing/test-categories-part-1.md) | [draft] 67 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Test Category Execution](./testing/test-categories-execution.md) | [ok] 143 | [basic] | [stable] | [risk: none] | testing, organization | +| [Test Code Quality and Accuracy](./testing/test-code-quality.md) | [ok] 244 | [intermediate] | [stable] | [risk: medium] | testing, documentation | +| [Test Coverage Scenario Categories](./testing/test-coverage-scenario-categories.md) | [ok] 224 | [intermediate] | [stable] | [risk: none] | testing, coverage | +| [Test Diagnostics and Investigation Patterns](./testing/test-diagnostics.md) | [ok] 248 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | +| [Test Diagnostics Patterns](./testing/test-diagnostics-patterns.md) | [ok] 190 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | +| [Test Diagnostics Usage](./testing/test-diagnostics-usage.md) | [ok] 197 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | +| [Test Failure Investigation and Zero-Flaky Policy](./testing/test-failure-investigation.md) | [ok] 120 | [intermediate] | [stable] | [risk: none] | testing, investigation | +| [Test Failure Investigation Procedure](./testing/test-failure-investigation-procedure.md) | [ok] 217 | [intermediate] | [stable] | [risk: none] | testing, investigation | +| [Test Failure Root Causes and Anti-Patterns](./testing/test-failure-investigation-root-causes.md) | [ok] 187 | [intermediate] | [stable] | [risk: none] | testing, root-cause-analysis | +| [Test Invalid Skill](./testing/test-invalid-skill.md) | [draft] 31 | [expert] | [stable] | [risk: high] | testing, fixtures | +| [Test Organization and Assertions](./testing/test-coverage-organization-assertions.md) | [ok] 174 | [basic] | [stable] | [risk: none] | testing, assertions | +| [Test Production Code Directly](./testing/test-production-code.md) | [ok] 146 | [intermediate] | [stable] | [risk: none] | testing, anti-patterns | +| [Test Production Code Directly Part 1](./testing/test-production-code-part-1.md) | [ok] 205 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Test Production Code Directly Part 2](./testing/test-production-code-part-2.md) | [draft] 66 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Tests Must Be Parameterized by Message Kind](./testing/tests-must-be-parameterized-by-message-kind.md) | [ok] 240 | [intermediate] | [stable] | [risk: none] | testing, data-driven | +| [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | [warn] 270 | [basic] | [stable] | [risk: none] | testing, unity | --- diff --git a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md new file mode 100644 index 00000000..c8daa396 --- /dev/null +++ b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md @@ -0,0 +1,258 @@ +--- +title: "Allocation Coverage Required for Dispatch" +id: "allocation-coverage-required-for-dispatch" +category: "testing" +version: "1.0.0" +created: "2026-05-01" +updated: "2026-05-01" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Tests/Runtime/Benchmarks/AllocationMatrixTests.cs" + - path: "Tests/Runtime/TestUtilities/AllocationAssertions.cs" + - path: "Tests/Runtime/TestUtilities/MessageScenarios.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "testing" + - "allocation" + - "performance" + - "messaging" + - "zero-gc" + - "benchmark" + - "unity" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of GC measurement, NUnit ValueSource, and the project's allocation harness." + +impact: + performance: + rating: "critical" + details: "Pins the zero-GC contract for every dispatch path." + maintainability: + rating: "high" + details: "Forces new dispatch paths to declare their allocation behaviour up front." + testability: + rating: "critical" + details: "Allocation regressions surface inside the test suite, not in user benchmarks." + +prerequisites: + - "comprehensive-test-coverage" + - "tests-must-be-parameterized-by-message-kind" + +dependencies: + packages: [] + skills: + - "comprehensive-test-coverage" + - "tests-must-be-parameterized-by-message-kind" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - "NUnit" + versions: + unity: ">=2021.3" + +aliases: + - "Zero-GC dispatch contract" + - "Allocation matrix coverage" + +related: + - "tests-must-be-parameterized-by-message-kind" + - "comprehensive-test-coverage" + - "test-categories" + - "single-thread-contract" + +status: "stable" +--- + +# Allocation Coverage Required for Dispatch + +> **One-line summary**: Every new `Emit*` method, every new dispatch path, and +> every new `MessageKind` value must be represented by a row in the allocation +> matrix - otherwise the zero-GC contract is unprotected. + +## Overview + +DxMessaging promises zero managed allocations on the steady-state dispatch +path. A regression there is silent: messages still flow, callers still receive +them, only the GC profile gets worse - and only at scale. The defense is a +matrix of allocation tests pinned in +`Tests/Runtime/Benchmarks/AllocationMatrixTests.cs` that asserts byte budgets +on the bare register / emit / deregister surface across every dispatch axis +(kind, interceptor presence, post-processor presence, diagnostics, priority). + +If a new dispatch path lands and is not covered by the matrix, the contract +silently weakens. This skill is the rule against that. + +## Problem Statement + +Consider the trap: + +```csharp +// New API added to MessageBusExtensions.cs +public static void EmitWithMetadata( + this ref TMessage message, + object metadata, + IMessageBus bus = null) + where TMessage : IUntargetedMessage +{ + // implementation that boxes 'metadata' once per call +} +``` + +Functional tests pass. The library still works. But the steady-state path +through `EmitWithMetadata` allocates ~24 bytes per call. Without a row in the +allocation matrix, nothing fails until a downstream user notices their GC +budget blown in production. + +## Solution + +Two requirements stack: + +1. Every dispatch path with a stable signature must have an + `AllocationMatrixTests` row that exercises it via the parameterized + `MessageScenarios` source. Use `AllocationAssertions.AssertNoAllocations` + for paths that must allocate exactly zero managed bytes per call, and a + hand-rolled `GC.GetTotalAllocatedBytes(precise: true)` delta with an + explicit `Is.LessThanOrEqualTo(byteBudget)` for paths where a small, + documented ceiling is intentional (for example registration and + deregistration). +1. Every `MessageKind` value must appear in `MessageScenarios.AllKinds`. + Anything driven by `[ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))]` + automatically picks up the new kind once it lands there. + +### Adding a Zero-Allocation Row + +Patterned after `EmitIsZeroAlloc` in +`Tests/Runtime/Benchmarks/AllocationMatrixTests.cs`: + +```csharp +[Test] +[Category("Allocation")] +public void EmitWithMetadataIsZeroAlloc( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario +) +{ + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitWithMetadataClosure(scenario, bus); + RegisterHandler(scenario, token); + AllocationAssertions.AssertNoAllocations( + $"EmitWithMetadata-{scenario.Kind}", + emit + ); + } + ); +} +``` + +`AllocationAssertions.AssertNoAllocations` JIT-warms the action and then +asserts via `Is.Not.AllocatingGCMemory()`, so the closure must be built once +outside the assertion zone or the closure's own allocation contaminates the +measurement. + +### Adding a Bounded-Allocation Row + +Some dispatch paths legitimately allocate a small, fixed amount per call. +`RegisterIsZeroAllocSteadyState` and +`DiagnosticsAugmentedHandlerAllocationCostIsBounded` in +`AllocationMatrixTests.cs` budget for the closure plus dictionary entry that +registration unavoidably produces. For those, measure a delta with +`GC.GetTotalAllocatedBytes(precise: true)` after warming the path to steady +state, and assert against an explicit byte budget: + +```csharp +[Test] +[Category("Allocation")] +public void RegisterIsZeroAllocSteadyState( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario +) +{ + RunWithFreshHarness(scenario, (token, bus) => + { + for (int i = 0; i < WarmupRegistrationCycles; ++i) + { + MessageRegistrationHandle warm = RegisterHandler(scenario, token); + token.RemoveRegistration(warm); + } + + long before = GC.GetTotalAllocatedBytes(precise: true); + MessageRegistrationHandle measured = RegisterHandler(scenario, token); + long after = GC.GetTotalAllocatedBytes(precise: true); + long delta = after - before; + token.RemoveRegistration(measured); + + Assert.That( + delta, + Is.LessThanOrEqualTo(PerRegistrationByteBudget), + $"Register-{scenario.Kind} allocated {delta} bytes; " + + $"budget is {PerRegistrationByteBudget} bytes." + ); + }); +} +``` + +Declare `PerRegistrationByteBudget` as a `private const long` at the top of +the fixture and document it with an XML comment explaining what the bytes +pay for, so reviewers can audit relaxations. + +## Enforcement + +`Tests/Runtime/Core/TestAttributeContractTests.cs` contains +`EveryEmitPathHasAllocationCoverage`. The test enumerates every +`MessageKind` value via reflection and asserts that +`MessageScenarios.AllKinds` yields a scenario for each. Adding a new kind +without updating the source - and therefore the allocation matrix that +consumes it - fails the build. + +The contract pin is intentionally narrow (kind enumeration). It cannot prove +that every individual `Emit*` method is covered, but it does guarantee the +matrix's parameterization stays in sync with the kind enum, which is the most +common drift point. + +## Best Practices + +### Do + +- Add an allocation matrix row in the same PR that introduces a new + dispatch path. +- Tag every allocation test with `[Category("Allocation")]` so the + default-suite speed budget skips them. +- Use `MessageScenarios.AllKinds` (or a narrower source) so the row + automatically expands when a new kind is added. +- Build emit closures outside the assertion zone. + +### Don't + +- Don't measure inside `[SetUp]` / `[TearDown]`; the harness state is not + guaranteed stable. +- Don't add a kind to `MessageKind` without adding it to + `MessageScenarios.AllKinds`; the contract test will fail. +- Don't relax a budget without explaining the new ceiling in the test's + XML doc comment. + +## See Also + +- [Tests Must Be Parameterized by Message Kind](tests-must-be-parameterized-by-message-kind.md) +- [Comprehensive Test Coverage Requirements](comprehensive-test-coverage.md) +- [Test Categories for Selective Execution](test-categories.md) +- [Single Thread Contract](single-thread-contract.md) + +## References + +- NUnit `ValueSource` documentation: https://docs.nunit.org/articles/nunit/writing-tests/attributes/valuesource.html + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-01 | Initial version | diff --git a/.llm/skills/testing/single-thread-contract.md b/.llm/skills/testing/single-thread-contract.md new file mode 100644 index 00000000..b660d680 --- /dev/null +++ b/.llm/skills/testing/single-thread-contract.md @@ -0,0 +1,200 @@ +--- +title: "Single Thread Contract" +id: "single-thread-contract" +category: "testing" +version: "1.0.0" +created: "2026-05-01" +updated: "2026-05-01" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Tests/Runtime/Core/SingleThreadContractTests.cs" + - path: "Runtime/Core/MessageBus/MessageBus.cs" + - path: "Runtime/Core/MessageHandler.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "testing" + - "concurrency" + - "threading" + - "messaging" + - "contract" + - "unity" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of the documented threading contract and the cost of changing it." + +impact: + performance: + rating: "high" + details: "Adding locks or interlocked operations on the dispatch path costs measurable throughput." + maintainability: + rating: "high" + details: "Pinning the contract prevents speculative concurrency code from accumulating." + testability: + rating: "high" + details: "Behaviour under cross-thread misuse is documented and tested rather than left implicit." + +prerequisites: + - "comprehensive-test-coverage" + +dependencies: + packages: [] + skills: + - "comprehensive-test-coverage" + - "tests-must-be-parameterized-by-message-kind" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - "NUnit" + versions: + unity: ">=2021.3" + +aliases: + - "Threading contract" + - "DxMessaging is single-threaded" + +related: + - "tests-must-be-parameterized-by-message-kind" + - "allocation-coverage-required-for-dispatch" + - "comprehensive-test-coverage" + +status: "stable" +--- + +# Single Thread Contract + +> **One-line summary**: DxMessaging buses are single-threaded. Do not add +> `lock`, `Interlocked`, or any other concurrency primitive to the dispatch +> path without a deliberate contract change reviewed with the maintainer. + +## Overview + +DxMessaging is built on the assumption that all bus operations - registration, +emission, deregistration, interceptor / post-processor manipulation - happen +on a single thread (typically Unity's main thread). The dispatch hot path +contains no thread-safety primitives because adding them would impose a +throughput cost on every single emission for a guarantee almost no caller +needs. + +The contract is documented and pinned by `SingleThreadContractTests.cs`. The +sentinel does NOT assert correctness under concurrency; it asserts that the +current behaviour - "no exception escapes when used cross-thread, but +correctness is on the caller" - does not change silently. + +## Problem Statement + +Speculative concurrency code is one of the most expensive forms of cargo cult. +Consider a well-meaning PR that adds: + +```csharp +// BAD: speculative locking on the dispatch path. +public void UntargetedBroadcast(ref TMessage message) + where TMessage : IUntargetedMessage +{ + lock (_dispatchLock) + { + // ... existing dispatch ... + } +} +``` + +Every emission now pays a `Monitor.Enter` / `Monitor.Exit` pair. The library's +zero-GC story still holds, but throughput on the hot path drops measurably - +for a guarantee that no real consumer is asking for, on a code path where the +maintainers have explicitly chosen single-threaded semantics. + +The contract test makes this kind of change deliberate. + +## Solution + +Treat the threading contract as a load-bearing invariant. + +### What the Contract Says + +- Bus operations are not guaranteed thread-safe. +- The dispatch path has no thread checks; calling from a non-main thread will + not throw, but correctness (ordering, atomicity, visibility) is on the + caller. +- The current sentinel pins behaviour: "no exception escapes during cross- + thread emission, and the handler runs at least once." + +### What the Sentinel Tests + +`Tests/Runtime/Core/SingleThreadContractTests.cs`: + +- `BusOperationFromNonMainThreadDoesNotCrash` - emits from a background + thread, joins the worker, and asserts no exception was captured AND the + handler ran at least once. If a future change starts throwing on cross- + thread misuse (a deliberate contract tightening), this test fails and + forces the maintainer to update the contract documentation. +- `RepeatedSerialEmitProducesDeterministicCounts` - 50 serial emissions on + the main thread must produce exactly 50 invocations. This is a + determinism smoke check, not a concurrency test; it pins that the + single-thread path remains drift-free. + +### Changing the Contract + +If a future requirement genuinely needs multi-threaded support: + +1. Discuss with the maintainer FIRST. Adding locks costs measurable + throughput. The benefit must be concrete. +1. Update `SingleThreadContractTests.cs` deliberately. The sentinel's + purpose is to fail when the contract changes. +1. Update CHANGELOG.md with a `### Changed` entry under user-impact + guidance, and update the README + docs. +1. Decide on the lock strategy explicitly: per-bus lock, per-kind lock, + reader-writer, lock-free dictionary, etc. Each has different + performance characteristics; pick one and benchmark. + +## Best Practices + +### Do + +- Treat single-threaded as the default. New features should not assume the + bus will be touched concurrently. +- Document any thread-safety guarantees in XML doc comments on public + surface. +- If a caller genuinely needs cross-thread emission, recommend they marshal + the call onto the main thread (e.g. via Unity `MainThreadDispatcher`) and + keep DxMessaging single-threaded. + +### Don't + +- Don't add `lock` / `Interlocked` / `volatile` casually to dispatch code. +- Don't "just to be safe" wrap registrations in locks. The harness is not + designed for it. +- Don't change `SingleThreadContractTests.cs` to make a speculative + concurrency PR pass. The test failing IS the signal. + +## Enforcement + +The sentinel tests in `SingleThreadContractTests.cs` fail when: + +- An exception escapes a background-thread emission (contract change toward + thread-safety enforcement). +- Serial emission counts drift (state corruption). + +There is no static analyzer pin; the cultural pin is this skill plus code +review. + +## See Also + +- [Tests Must Be Parameterized by Message Kind](tests-must-be-parameterized-by-message-kind.md) +- [Allocation Coverage Required for Dispatch](allocation-coverage-required-for-dispatch.md) +- [Comprehensive Test Coverage Requirements](comprehensive-test-coverage.md) + +## References + +- Unity main-thread invariants: https://docs.unity3d.com/Manual/ExecutionOrder.html + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-01 | Initial version | diff --git a/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md b/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md new file mode 100644 index 00000000..32613803 --- /dev/null +++ b/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md @@ -0,0 +1,239 @@ +--- +title: "Tests Must Be Parameterized by Message Kind" +id: "tests-must-be-parameterized-by-message-kind" +category: "testing" +version: "1.0.0" +created: "2026-05-01" +updated: "2026-05-01" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Tests/Runtime/TestUtilities/MessageScenario.cs" + - path: "Tests/Runtime/TestUtilities/MessageScenarios.cs" + - path: "Tests/Runtime/TestUtilities/ScenarioHarness.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "testing" + - "data-driven" + - "parameterization" + - "messaging" + - "scenarios" + - "unity" + - "nunit" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of NUnit ValueSource and the project's MessageScenario harness." + +impact: + performance: + rating: "none" + details: "Pattern affects test maintainability, not runtime performance." + maintainability: + rating: "critical" + details: "Eliminates test duplication; adding a kind requires no triplet-rewrite." + testability: + rating: "critical" + details: "Improves coverage parity across message kinds." + +prerequisites: + - "data-driven-tests" + - "comprehensive-test-coverage" + +dependencies: + packages: [] + skills: + - "data-driven-tests" + - "comprehensive-test-coverage" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - "NUnit" + versions: + unity: ">=2021.3" + +aliases: + - "MessageScenario parameterization" + - "Triplet test consolidation" + +related: + - "data-driven-tests" + - "comprehensive-test-coverage" + - "test-coverage-data-driven" + - "shared-test-fixtures" + +status: "stable" +--- + +# Tests Must Be Parameterized by Message Kind + +> **One-line summary**: Any test that exercises DxMessaging dispatch across more +> than one of `Untargeted`, `Targeted`, or `Broadcast` must be a single +> parameterized method driven by `MessageScenario`, not three near-identical +> triplets. + +## Overview + +DxMessaging exposes three dispatch kinds: untargeted, targeted, and broadcast. +Historically the test suite shipped a triplet of test methods for every +behavior - one per kind - copy-pasted into ~720 lines of duplicated assertions +with subtly different formatting. Adding a new shared behavior meant writing +the same body three times; fixing a bug meant fixing it three times; missing +the third copy was a routine source of coverage drift. + +The project now ships a parameterized scenario harness +(`Tests/Runtime/TestUtilities/`) that lets a single test method cover all +kinds. The contract is enforced by +`TestAttributeContractTests.EveryEmitTestUsesScenarioParameterization` so a +regression cannot land silently. + +## Problem Statement + +Triplet tests are easy to write and easy to drift: + +```csharp +[UnityTest] +public IEnumerator HandlerReceivesEmittedUntargetedMessage() +{ + // ... 40 lines, untargeted ... +} + +[UnityTest] +public IEnumerator HandlerReceivesEmittedTargetedMessage() +{ + // ... 40 lines, targeted (slightly different) ... +} + +[UnityTest] +public IEnumerator HandlerReceivesEmittedBroadcastMessage() +{ + // ... 40 lines, broadcast (slightly different again) ... +} +``` + +Three signatures, three bodies, one behavior. Every fix has to land three +times. Coverage parity is a manual review item. + +## Solution + +Replace the triplet with one method that takes a `MessageScenario` from +`MessageScenarios.AllKinds` via NUnit `[ValueSource]`, and uses +`ScenarioHarness` to pick the right register / emit overload. + +```csharp +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class EmitTests : MessagingTestBase + { + [UnityTest] + public IEnumerator HandlerReceivesEmittedMessage( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerReceivesEmittedMessage) + "_" + scenario, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + + int count = 0; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + _ = ScenarioHarness.RegisterUntargeted( + scenario, token, (ref SimpleUntargetedMessage _) => ++count); + SimpleUntargetedMessage u = new(); + ScenarioHarness.EmitUntargeted(scenario, ref u); + break; + case MessageKind.Targeted: + _ = ScenarioHarness.RegisterTargeted( + scenario, token, component, + (ref SimpleTargetedMessage _) => ++count); + SimpleTargetedMessage t = new(); + ScenarioHarness.EmitTargeted(scenario, ref t, component); + break; + case MessageKind.Broadcast: + _ = ScenarioHarness.RegisterBroadcast( + scenario, token, component, + (ref SimpleBroadcastMessage _) => ++count); + SimpleBroadcastMessage b = new(); + ScenarioHarness.EmitBroadcast(scenario, ref b, component); + break; + } + + Assert.AreEqual(1, count, $"Scenario {scenario} should dispatch exactly once."); + yield return null; + } + } +} +``` + +NUnit produces three discovered tests - `HandlerReceivesEmittedMessage(Untargeted)`, +`HandlerReceivesEmittedMessage(Targeted)`, `HandlerReceivesEmittedMessage(Broadcast)` - +from one source method. Adding a fourth kind means adding one entry to +`MessageScenarios.AllKinds`; the test does not change. + +## Exception: Kind-Specific Fixtures + +Some assertions are intrinsically kind-specific. Untargeted dispatch fans out to +every registered handler regardless of receiver identity; targeted and broadcast +do not. The fan-out shape, the empty-target case, and the broadcast-from-source +routing all have semantics that do not translate cleanly to the other kinds. + +Those tests live in fixtures whose names match `*Specific*Tests`: + +- `EmitUntargetedSpecificTests` +- `EmitTargetedSpecificTests` +- `EmitBroadcastSpecificTests` + +The contract test exempts any fixture matching that pattern. Tests that DO +generalize across kinds belong in `EmitTests` (or another non-`*Specific*` +fixture) and MUST be parameterized. + +## Enforcement + +`Tests/Runtime/Core/TestAttributeContractTests.cs` contains +`EveryEmitTestUsesScenarioParameterization`. The test reflects over every +`[UnityTest]` method in the `DxMessaging.Tests.Runtime` namespace, ignores any +fixture whose name ends with `Tests` and contains `Specific`, and fails the +build for any remaining method whose name mentions `Untargeted`, `Targeted`, +or `Broadcast` but whose parameter list does not include `MessageScenario`. + +If a new test triplet sneaks in, CI fails with a pointer back to this skill. + +## See Also + +- [Data-Driven Tests](data-driven-tests.md) +- [Comprehensive Test Coverage Requirements](comprehensive-test-coverage.md) +- [Data-Driven Coverage Patterns](test-coverage-data-driven.md) +- [Shared Test Fixtures](shared-test-fixtures.md) +- [Allocation Coverage Required for Dispatch](allocation-coverage-required-for-dispatch.md) +- [Single Thread Contract](single-thread-contract.md) + +## References + +- NUnit `ValueSource` documentation: https://docs.nunit.org/articles/nunit/writing-tests/attributes/valuesource.html +- Unity Test Framework: https://docs.unity3d.com/Packages/com.unity.test-framework@latest + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-01 | Initial version | diff --git a/CHANGELOG.md b/CHANGELOG.md index fb209f61..445077dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New DxMessaging project-wide settings asset (`DxMessagingSettings`, stored at `Assets/Editor/DxMessagingSettings.asset`) accessible from Unity's Project Settings. Controls diagnostics targets applied to `IMessageBus.GlobalDiagnosticsTargets`, the editor message buffer size, the domain-reload warning suppression, the base-call analyzer toggle, the project-wide base-call ignore list, and the optional Unity console bridge that feeds the inspector overlay. - New `docs/reference/analyzers.md` reference page documenting every `DXMSG###` diagnostic the package emits, with severity, source generator/analyzer, trigger conditions, message text, and code samples for each. Added to the Reference section of the documentation site navigation. - Added `llms.txt` plus README onboarding guidance so users can connect AI assistants with accurate DxMessaging package context. +- Test-suite hardening: parameterized scenario fixture (`MessageScenario`, `MessageScenarios`, `ScenarioHarness`, `AllocationAssertions`) under `Tests/Runtime/TestUtilities/` enabling kind-parameterized tests. +- Behavioural gap closures: `HandlerExceptionTests`, `ReentrantEmissionTests`, `NullAndInvalidInputTests`, `SingleThreadContractTests` pinning exception-in-handler, re-entrancy, null-input, and threading contracts. +- `AllocationMatrixTests` covering zero-GC dispatch across kinds, interceptors, post-processors, diagnostics, and priority-based dispatch. +- Expanded coverage now pins source-generator and analyzer behaviour that users rely on: generic / record struct / nested partial / nullable annotation cases for `DxMessageIdGenerator`; `[DxOptionalParameter]` permutations and DXMSG005 boundary cases for `DxAutoConstructorGenerator`; positive opt-out cases for `DxIgnoreMissingBaseCallAttribute`. No runtime API change. +- `[Category("Stress")]`, `[Category("Performance")]`, and `[Category("Allocation")]` tagging across the suite to enable filtered runs and a default-suite speed budget under 60 seconds. +- `SuiteSpeedBudgetTest` as a default-suite speed guard rail. +- `TestAttributeContractTests` extensions enforcing kind-parameterization and allocation coverage discipline. + +### Fixed + +- Cross-priority deregistration during in-flight emit no longer drops handlers from the current dispatch. Previously, when a handler at one priority removed a handler at a later priority of the same emission, the later priority's typed-handler stack was rebuilt from the now-mutated registry on first touch and the scheduled-for-removal handler was silently skipped, breaking the documented "frozen handler list per emission" contract. This affected sourced-broadcast, broadcast-without-source, and targeted-without-targeting dispatch (the targeted/untargeted paths already pre-froze every bucket up-front). Two related defects are fixed in the same change: the sourced-broadcast and broadcast-without-source dispatch loops short-circuited on the live `cache.handlers.Count == 0` even when the per-emission snapshot still held the deregistered handler, and post-processor prefreeze took a single-bucket/single-entry fast-path that skipped pre-freezing per-MessageHandler post-processor caches -- a regular handler that registered a new post-processor on the same MessageHandler+priority during its own callback would then see the new post-processor fire on the in-flight emission instead of waiting for the next one. The bus now pre-freezes every priority bucket's typed-handler caches up-front for every dispatch surface (sourced-broadcast, broadcast-without-source, targeted-without-targeting), uses the per-emission snapshot count for the dispatch-loop early-out, and unconditionally pre-freezes post-processor caches; removals and post-processor registrations performed mid-emit now consistently only take effect on the next emission. +- `DxMessagingStaticState.Reset` is now race-safe against deferred deregistrations. Previously, when a message-aware component was destroyed but its disable callback had not yet run (Unity defers Object.Destroy to end of frame) and Reset ran in between, the deferred token teardown would log spurious "Received over-deregistration of {type} for {handler}" errors against the user's Unity console. The bus now stamps each captured deregister closure with a generation counter and silently no-ops closures captured before a Reset. Applied uniformly across every register entry point (untargeted, targeted, broadcast, GlobalAcceptAll, and all three interceptor kinds). The same race-safety guarantee is now propagated to user-installed custom global buses via `MessageBus.BumpResetGeneration()`, which `DxMessagingStaticState.Reset` invokes on the active global bus when it differs from the built-in default; the custom bus's sinks are intentionally left intact to avoid clobbering state the user installed it to preserve. User code is unaffected except that previously-spurious error logs disappear. +- `MessageRegistrationToken.RemoveRegistration(handle)` no longer leaks the staged registration entry, so a `Disable()`/`Enable()` cycle after `RemoveRegistration` no longer silently re-registers the removed handler. The fix also drops the matching metadata and call-count entries so diagnostic mode does not accumulate stale handles. + +### Changed + +- Mutation tests now exercise every messaging kind (Untargeted/Targeted/Broadcast) via a single parameterized fixture (`[ValueSource(MessageScenarios.AllKinds)]`) across `MutationDuringEmissionTests`, `MutationInterceptorTests`, and `MutationDestructionTests`. Users get tighter cross-kind parity guarantees; no runtime API change. (~720 lines of duplication removed; test count preserved.) +- `MessagingTestBase` now reseeds a deterministic `Random` with a logged seed (env var `DXMESSAGING_TEST_SEED` to override), polls a generous 1.5-second timeout for handler cleanup, drains the prior test's deferred destroy queue before resetting `DxMessagingStaticState` in `[UnitySetUp]`, and asserts the bus returns to a fresh state at the end of `[UnityTearDown]`. +- Renamed `UntargetedTests`, `TargetedTests`, `BroadcastTests` to `EmitUntargetedSpecificTests`, `EmitTargetedSpecificTests`, `EmitBroadcastSpecificTests` to clarify that kind-common tests live in `EmitTests` and kind-specific tests live in the renamed files. (Test-suite hardening is test-only; no `Runtime/` behavior was modified.) ## [2.2.0] diff --git a/CLAUDE.md b/CLAUDE.md index fc85196b..1993d2a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,9 @@ See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. -Three project-wide rules: +Four project-wide rules: - Documentation must be pure ASCII (see [ASCII-only documentation guideline](./.llm/skills/documentation/ascii-only-docs.md)). - Code samples must compile (see [Code samples must compile guideline](./.llm/skills/documentation/code-samples-must-compile.md)). - For user-visible code changes (`Runtime/`, `Samples~/`, user-facing `Editor/`, `SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/`, or `SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/`), run `npm run validate:changelog:coverage` and rewrite `W002` entries around user impact before finishing. +- Tests for message dispatch must be parameterized over MessageKind (see [Tests Must Be Parameterized by Message Kind](./.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md)). diff --git a/Editor/Analyzers/BaseCallIlInspector.cs b/Editor/Analyzers/BaseCallIlInspector.cs index 7fbe2515..80320a43 100644 --- a/Editor/Analyzers/BaseCallIlInspector.cs +++ b/Editor/Analyzers/BaseCallIlInspector.cs @@ -47,7 +47,7 @@ namespace DxMessaging.Editor.Analyzers public static class BaseCallIlInspector { // CIL opcode tables, indexed by the low byte of OpCode.Value. Built once by reflecting over - // System.Reflection.Emit.OpCodes — every public static OpCode field there represents a + // System.Reflection.Emit.OpCodes; every public static OpCode field there represents a // canonical CIL instruction. The two-byte form of the table is used when a 0xFE prefix is // observed in the IL stream; otherwise we use the single-byte form. Because CIL specifies // exactly two prefix bytes (single-byte = direct, two-byte = 0xFE prefix), this division @@ -111,7 +111,7 @@ public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName MethodBody body = method.GetMethodBody(); if (body == null) { - // Abstract / extern / runtime-implemented / IL2CPP-stripped — cannot inspect. + // Abstract / extern / runtime-implemented / IL2CPP-stripped; cannot inspect. return true; } @@ -137,7 +137,7 @@ public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName if (il[i] == 0xFE) { // Two-byte (0xFE-prefixed) opcode. Without a following byte we cannot - // decode the instruction — bail out conservatively. Truncated IL is not a + // decode the instruction; bail out conservatively. Truncated IL is not a // shape Roslyn ever emits, so reaching this path means we mis-stepped and // the safest answer is the assume-clean default. if (i + 1 >= il.Length) @@ -153,7 +153,7 @@ public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName i += 1; } - // Unrecognised opcode (zero-initialised slot in the table) — abandon the walk + // Unrecognised opcode (zero-initialised slot in the table); abandon the walk // rather than risk the rest of the stream getting misread. Returning the // assume-clean default keeps the scanner from inventing a phantom warning. if (op.Size == 0) @@ -185,7 +185,7 @@ public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName // Guard against false-positives: the resolved method must live on a // STRICT base type of the declaring class (not the declaring class // itself, not a sibling, not a generic-arg shadow). IsAssignableFrom - // checks "is `declaring` assignable TO `resolved`" — i.e. is + // checks "is `declaring` assignable TO `resolved`"; i.e. is // `resolved` an ancestor of `declaring`. if ( declaring != null @@ -204,7 +204,7 @@ public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName // context (e.g. a MemberRef into a closed generic we can't resolve). // The OpCodes-table walker means we can no longer land on a misaligned // 0x28 inside a wider operand, so this catch only protects against - // legitimate-but-unbindable tokens — we swallow and continue scanning. + // legitimate-but-unbindable tokens; we swallow and continue scanning. } i += 4; continue; @@ -268,7 +268,7 @@ private static int GetOperandSize(OpCode op, byte[] il, int operandStart) } return 4 + caseCount * 4; default: - // Unknown OperandType — bail conservatively by consuming the rest of the + // Unknown OperandType; bail conservatively by consuming the rest of the // stream so the outer loop terminates without misaligning further. return il.Length - operandStart; } diff --git a/Editor/Analyzers/BaseCallLogMessageParser.cs b/Editor/Analyzers/BaseCallLogMessageParser.cs index 12447793..4d95cb76 100644 --- a/Editor/Analyzers/BaseCallLogMessageParser.cs +++ b/Editor/Analyzers/BaseCallLogMessageParser.cs @@ -91,7 +91,7 @@ public sealed class ParsedTypeReport public static class BaseCallLogMessageParser { // Roslyn / Unity-style location prefix: path(line,col): warning DXMSG006: - // We don't anchor to the diagnostic id here beyond the leading "DXMSG" — that lets the + // We don't anchor to the diagnostic id here beyond the leading "DXMSG"; that lets the // same prefix regex serve all five diagnostics (DXMSG006/007/008/009/010). The trailing // `: ` is consumed so the diagnostic-specific regexes only see the message body. private const RegexOptions SharedOptions = @@ -125,7 +125,7 @@ public static class BaseCallLogMessageParser // DXMSG008 format: // '{type}' is excluded from the DxMessaging base-call check ({source}). - // No method name in the message — MethodName is returned as the empty string. + // No method name in the message; MethodName is returned as the empty string. private static readonly Regex Dxmsg008Regex = new( @"^'(?[^']+)'\s+is\s+excluded\s+from\s+the\s+DxMessaging\s+base-call\s+check\s+\([^)]*\)\.", SharedOptions diff --git a/Editor/Analyzers/BaseCallReportAggregator.cs b/Editor/Analyzers/BaseCallReportAggregator.cs index e5cfb104..d96ea0b7 100644 --- a/Editor/Analyzers/BaseCallReportAggregator.cs +++ b/Editor/Analyzers/BaseCallReportAggregator.cs @@ -98,7 +98,7 @@ Dictionary mergedReports } // 1. Replace this assembly's FQN set with the latest batch. Types absent from the new - // batch are dropped from the assembly's row — that's the per-assembly retirement. + // batch are dropped from the assembly's row; that's the per-assembly retirement. if (!typesByAssembly.TryGetValue(assemblyKey, out HashSet typeSet)) { typeSet = new HashSet(StringComparer.Ordinal); @@ -137,7 +137,7 @@ Dictionary mergedReports } // For the assembly we just updated, prefer the freshly-parsed payload. For other - // assemblies, we need the previous merge to still carry their data — but that + // assemblies, we need the previous merge to still carry their data; but that // information is only retrievable from the OUTGOING mergedReports, so we read it // before clearing. IReadOnlyDictionary source = string.Equals( @@ -297,7 +297,7 @@ ParsedTypeReport report } // First seen file/line wins so "Open Script" jumps to a stable location across - // rebuilds — which is what the user's eye lands on first in the console. + // rebuilds; which is what the user's eye lands on first in the console. if (string.IsNullOrEmpty(existing.FilePath) && !string.IsNullOrEmpty(report.FilePath)) { existing.FilePath = report.FilePath; diff --git a/Editor/Analyzers/BaseCallTypeScanner.cs b/Editor/Analyzers/BaseCallTypeScanner.cs index 4c21bfd7..efc33453 100644 --- a/Editor/Analyzers/BaseCallTypeScanner.cs +++ b/Editor/Analyzers/BaseCallTypeScanner.cs @@ -75,12 +75,12 @@ internal static Dictionary Scan(DxMessagingSettings // TypeCache is Unity's domain-reload-cached type lookup. Effectively O(1) after the // first call and survives across reloads via Unity's serialization layer. Using // TypeCache (rather than scanning every loaded assembly via AppDomain) is important - // for performance — a fresh project can have hundreds of assemblies loaded. + // for performance; a fresh project can have hundreds of assemblies loaded. TypeCache.TypeCollection candidates = TypeCache.GetTypesDerivedFrom(); // Defensive: TypeCache.GetTypesDerivedFrom() returns strict subclasses, but - // belt-and-braces in case a future Unity version changes the contract — we feed the + // belt-and-braces in case a future Unity version changes the contract; we feed the // list through Core.Scan which itself skips MessageAwareComponent by FQN match. // The Core handles abstract / generic-definition / null-FQN skipping uniformly. Dictionary coreResult = diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs b/Editor/Analyzers/BaseCallTypeScannerCore.cs index 49ddc60c..8163bcc8 100644 --- a/Editor/Analyzers/BaseCallTypeScannerCore.cs +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs @@ -148,7 +148,7 @@ IEnumerable ignoredTypeNames { // Suppression makes the entry an audit-marker (DXMSG008-equivalent). The // overlay's "ignored" branch handles this via the ignored-types list directly, - // so we don't add it to the snapshot at all — the overlay reads the project + // so we don't add it to the snapshot at all; the overlay reads the project // list to render the "Stop ignoring" HelpBox. This matches the bridge path's // snapshot semantics (DXMSG008 was never in MissingBaseFor either). continue; @@ -256,7 +256,7 @@ HashSet methodLevelIgnore MethodInfo declared = GetDeclaredZeroArgInstance(concrete, methodName); if (declared == null) { - // Type does not declare this method at all — nothing to flag at this level. + // Type does not declare this method at all; nothing to flag at this level. return; } if (declared.ReturnType != typeof(void)) @@ -278,7 +278,7 @@ HashSet methodLevelIgnore // virtual we are hiding. The C# compiler emits the same IL for `new void X()` and // `void X()`-with-CS0114, so we cannot perfectly distinguish DXMSG007 from DXMSG009 // from IL alone. The compile-time analyzer is authoritative for the precise ID; - // here we conservatively classify the case as DXMSG007 — both produce the same + // here we conservatively classify the case as DXMSG007; both produce the same // overlay outcome (method listed in HelpBox). bool isOverride = declared.GetBaseDefinition() != declared; bool hasNewKeyword = @@ -290,7 +290,7 @@ HashSet methodLevelIgnore { AddIfMissing(entry, methodName, "DXMSG007"); } - // else: not an override, no base virtual to hide — not our concern. + // else: not an override, no base virtual to hide; not our concern. return; } @@ -305,13 +305,13 @@ HashSet methodLevelIgnore // Leaf calls base. Walk the inheritance chain to look for a broken intermediate // (DXMSG010). Each link's IL is inspected independently; the first broken link found // produces DXMSG010 on the leaf and we stop. Cross-assembly ancestors with no IL body - // are trusted (assume-clean) — the alternative would be unactionable warnings against + // are trusted (assume-clean); the alternative would be unactionable warnings against // closed-source code. MethodInfo cursorOverridden = GetOverriddenMethod(declared); HashSet visited = new(); while (cursorOverridden != null && visited.Add(cursorOverridden)) { - // Chain reached MessageAwareComponent itself — clean. We compare by full type + // Chain reached MessageAwareComponent itself; clean. We compare by full type // name so the helper does not need a hard reference to the Unity-only type. Type cursorDeclaring = cursorOverridden.DeclaringType; if ( @@ -323,7 +323,7 @@ HashSet methodLevelIgnore } if (cursorOverridden.GetMethodBody() == null) { - // Cross-assembly / abstract — assume clean (cannot inspect). + // Cross-assembly / abstract; assume clean (cannot inspect). return; } bool ancestorCallsBase = BaseCallIlInspector.MethodIlContainsBaseCall( @@ -380,7 +380,7 @@ private static MethodInfo GetOverriddenMethod(MethodInfo derivedOverride) { // For an override, GetBaseDefinition() returns the most-base virtual (the originating // declaration). To walk the chain link-by-link we need the closest ancestor that - // declares the same-named method directly — we look up each BaseType in turn and + // declares the same-named method directly; we look up each BaseType in turn and // return the first match. This skips intermediate types that don't override the slot // (e.g. a generic intermediate that just passes through), which is exactly what the // chain walk needs to detect DXMSG010 at the broken link rather than the pass-through. diff --git a/Editor/Analyzers/DxMessagingConsoleHarvester.cs b/Editor/Analyzers/DxMessagingConsoleHarvester.cs index d63def0f..a5171d5f 100644 --- a/Editor/Analyzers/DxMessagingConsoleHarvester.cs +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs @@ -127,7 +127,7 @@ public static class DxMessagingConsoleHarvester // exclusively to keep the test surface and runtime behaviour identical. // // Note: starting in v2.3, the IL-reflection scanner (BaseCallTypeScanner) is the primary - // source of truth — it runs unconditionally on every rescan, regardless of bridge state. + // source of truth; it runs unconditionally on every rescan, regardless of bridge state. // The bridge only contributes ADDITIONAL data, never overrides the scanner. private static readonly Dictionary> _typesByAssembly = new( StringComparer.OrdinalIgnoreCase @@ -186,14 +186,14 @@ private static readonly Dictionary< // Tracks whether the current snapshot has been refreshed by a scan in THIS Editor session, // or whether it was loaded eagerly from `Library/DxMessaging/baseCallReport.json` in the // static ctor and has not yet been overwritten. The inspector overlay reads this to - // distinguish "fresh-this-session" warnings from cached-from-previous-session warnings — + // distinguish "fresh-this-session" warnings from cached-from-previous-session warnings; // when the cache is showing, we annotate the HelpBox with a small suffix so the user // understands the data may be stale until the first post-reload scan completes. // // Default `false`: the static ctor's `LoadFromDisk` runs first, so by the time anything // observes the snapshot, either (a) the cache populated entries that pre-date this session, // or (b) the cache was empty (truly fresh). In case (b) the overlay renders no warning - // anyway — there are no entries to annotate — so the false default is correct for both. + // anyway; there are no entries to annotate; so the false default is correct for both. // Flipped to `true` after the first successful `RescanNow` post-startup; never flipped // back to `false`. Volatile so the editor-loop reader sees the write without a memory // barrier on Unity's pre-2022 mono runtime. @@ -292,7 +292,7 @@ static DxMessagingConsoleHarvester() { // S8: defensive value-type guard. If a future Unity version makes LogEntry // a struct, Activator.CreateInstance would hand us a boxed copy and the - // GetEntry call would mutate that copy in-place — harvest would silently + // GetEntry call would mutate that copy in-place; harvest would silently // report empty. Disable the LogEntries path rather than silently producing // a wrong result; the CompilerMessage feed still runs. LogOnce( @@ -340,7 +340,7 @@ _startGettingEntries is not null LoadFromDisk(); - // AssetDatabase isn't fully ready inside the static ctor — defer the first scan one + // AssetDatabase isn't fully ready inside the static ctor; defer the first scan one // editor tick so settings load doesn't fight a transitional asset-import state. EditorApplication.delayCall += SafeRescanFromCallback; AssemblyReloadEvents.afterAssemblyReload += SafeRescanFromCallback; @@ -380,7 +380,7 @@ public static void RescanNow() // compile or mid-asset-update. Reading LogEntries during compilation contends with the // compiler's own log-buffer lock and can deadlock the editor. Touching AssetDatabase // (via TryLoadSettings → GetOrCreateSettings → CreateAsset) during compilation - // schedules an import that re-triggers compilation — an infinite-loop trap that + // schedules an import that re-triggers compilation; an infinite-loop trap that // permanently freezes script-compilation startup. Defer to the post-compile state // and let the polled tick (or the explicit afterAssemblyReload hook) pick it up. if (EditorApplication.isCompiling || EditorApplication.isUpdating) @@ -406,7 +406,7 @@ public static void RescanNow() _lastSeenCount = 0; PersistToDisk(); // The "check disabled" path still represents a successful session-time decision - // about the snapshot — flip the freshness flag so the overlay never lingers in + // about the snapshot; flip the freshness flag so the overlay never lingers in // "cached from previous session" mode after the user has explicitly silenced the // check. Doing this BEFORE RaiseReportUpdated mirrors the main path's ordering. _isFreshThisSession = true; @@ -511,7 +511,7 @@ out logEntriesHarvested } // Replace the live snapshot with the new view in one swap. The scanner runs over ALL - // loaded types every time, so this is a full-replace — types the user has fixed since + // loaded types every time, so this is a full-replace; types the user has fixed since // the last scan disappear, types newly broken appear. SnapshotInternal.Clear(); foreach (KeyValuePair kvp in nextSnapshot) @@ -532,7 +532,7 @@ out logEntriesHarvested } // Unions the bridge-produced DTOs into the scanner-produced snapshot. The scanner is the - // authoritative source — the bridge can only contribute methods / diagnostic ids the + // authoritative source; the bridge can only contribute methods / diagnostic ids the // scanner missed for a type, OR a brand-new type entry the scanner did not produce (e.g. // a subclass the scanner couldn't classify because its IL was stripped). The first non- // empty file path / line wins, matching the bridge's pre-existing semantics. @@ -596,7 +596,7 @@ Dictionary scannerSnapshot // Reads the editor console via LogEntries reflection. Returns the aggregated per-type // report, the current console count, and whether the harvest actually ran (false when // the LogEntries reflection layer is unavailable or threw). On Unity 2021 this returns - // an empty aggregate every time — the analyzer warnings flow through the CompilerMessage + // an empty aggregate every time; the analyzer warnings flow through the CompilerMessage // feed instead and arrive via ApplyCompilerMessageDrain. private static Dictionary HarvestFromLogEntries( out int currentCount, @@ -623,7 +623,7 @@ out bool harvested // S4: console-clear handling. We always overwrite _lastSeenCount near the bottom of // RescanNow, so the only point of acting on a shrunken count here is to be explicit // about the semantic. The accumulator is rebuilt from scratch every rescan, so the - // clear case is naturally consistent — even an empty log produces an empty aggregate + // clear case is naturally consistent; even an empty log produces an empty aggregate // and a ReportUpdated fire that drops stale rows. // B2 + S6: enter the get/end pair only AFTER StartGettingEntries actually succeeded. @@ -783,7 +783,7 @@ public static void RequestRescan() private static void Tick() { // Tick is only registered when the LogEntries reflection layer is available, so we - // do NOT need to re-check _logEntriesDisabled here — but the IsAvailable guard + // do NOT need to re-check _logEntriesDisabled here; but the IsAvailable guard // protects against a future failure mode where IsAvailable is flipped to false at // runtime. if (!IsAvailable) @@ -793,7 +793,7 @@ private static void Tick() // Defensive belt: never reflect into LogEntries while a compile or asset-import is // running. Even though RescanNow() itself bails on this state, we don't want to even - // call GetCount() — the lock contention is the source of the freeze, and GetCount + // call GetCount(); the lock contention is the source of the freeze, and GetCount // touches the same buffer. if (EditorApplication.isCompiling || EditorApplication.isUpdating) { @@ -838,12 +838,12 @@ CompilerMessage[] messages { // CRITICAL: this fires for EVERY assembly compiled (10s of times per build). Running // RescanNow synchronously here invokes LogEntries reflection while OTHER assemblies - // are still compiling — the compiler holds its log-buffer lock and our reflection + // are still compiling; the compiler holds its log-buffer lock and our reflection // call blocks waiting for it. Combined with AssetDatabase touches inside RescanNow, // this caused permanent script-compilation freezes on Unity startup. // // S4: when the legacy console-bridge is OFF, we don't need to parse CompilerMessage - // payloads at all — the IL-reflection scanner is the sole data source and it runs + // payloads at all; the IL-reflection scanner is the sole data source and it runs // off the AssemblyReloadEvents.afterAssemblyReload hook that fires once per build, // not per-assembly. Bail out early so a 30-assembly build doesn't burn CPU running // the regex-heavy parser 30 times for output we'll never read. Read the setting once @@ -908,7 +908,7 @@ CompilerMessage[] messages // Fix: schedule a single delayCall. delayCall fires AFTER the current event chain // unwinds and AFTER `EditorApplication.isCompiling` flips back to false. Multiple // delayCall registrations from the same compile burst are debounced by the - // _rescanScheduled latch — only one deferred RescanNow runs per build. + // _rescanScheduled latch; only one deferred RescanNow runs per build. if (_rescanScheduled) { return; @@ -921,7 +921,7 @@ private static void DrainScheduledRescan() { _rescanScheduled = false; // delayCall can fire while still mid-compile if the editor is in a weird state. - // RescanNow has its own isCompiling/isUpdating guard — re-defer if needed. + // RescanNow has its own isCompiling/isUpdating guard; re-defer if needed. if (EditorApplication.isCompiling || EditorApplication.isUpdating) { if (!_rescanScheduled) @@ -948,7 +948,7 @@ private static void SafeRescanFromCallback() private static DxMessagingSettings TryLoadSettings() { - // CRITICAL: passive load only. We must NOT call GetOrCreateSettings here — that path + // CRITICAL: passive load only. We must NOT call GetOrCreateSettings here; that path // can call AssetDatabase.CreateAsset, which during script compilation schedules an // import → re-triggers compilation → permanent freeze. The Project Settings page and // the inspector overlay both call GetOrCreateSettings on demand (outside compilation), diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index 26d34b1b..c9cc83d4 100644 Binary files a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll and b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll differ diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index 0c1fa5f6..ef301848 100644 Binary files a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll and b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll differ diff --git a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs index 52deb0f7..bcf139b3 100644 --- a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs +++ b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs @@ -70,7 +70,7 @@ public override void OnInspectorGUI() // control counts, so we can call it unconditionally here. MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI(target); - // Match Unity's GenericInspector exactly — including the disabled "Script" row that + // Match Unity's GenericInspector exactly; including the disabled "Script" row that // every MonoBehaviour inspector shows. This is intentional: skipping the script row // creates a visible empty gap below the header for subclasses with no // [SerializeField] fields. diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs index 5bd9e5f3..8f5d9a1a 100644 --- a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -52,10 +52,10 @@ public static class MessageAwareComponentInspectorOverlay // // NOTE: cross-path dedupe between the header hook and the OnInspectorGUI hook is // accomplished by an UNCONDITIONAL skip at the top of when the - // target editor is our fallback CustomEditor — see that method's comment. We do NOT use + // target editor is our fallback CustomEditor; see that method's comment. We do NOT use // a per-frame "header drew" set, because such a set would necessarily be populated only // on the Repaint pass of the header hook, while OnInspectorGUI runs on BOTH the Layout - // and Repaint passes — that asymmetry would corrupt the inspector's layout cache. + // and Repaint passes; that asymmetry would corrupt the inspector's layout cache. private static readonly HashSet _renderedThisRepaint = new(); static MessageAwareComponentInspectorOverlay() @@ -89,7 +89,7 @@ private static void DrawHeader(Editor editor) return; } // If our own fallback CustomEditor is the editor instance, skip the header path - // entirely — the editor's OnInspectorGUI will call RenderInsideOnInspectorGUI and we + // entirely; the editor's OnInspectorGUI will call RenderInsideOnInspectorGUI and we // would otherwise render twice. Unconditional skip (not gated on EventType) keeps // control counts balanced on both Layout and Repaint passes. if (editor is MessageAwareComponentFallbackEditor) @@ -122,7 +122,7 @@ private static void RenderForHeaderHook(Object target) } if (currentEvent.type == EventType.Layout) { - // Start of a fresh GUI cycle — wipe the per-Repaint latch. + // Start of a fresh GUI cycle; wipe the per-Repaint latch. _renderedThisRepaint.Clear(); return; } @@ -293,9 +293,9 @@ BaseCallReportEntry entry // fires, the overlay redraws without the suffix. string freshnessSuffix = DxMessagingConsoleHarvester.IsFreshThisSession ? string.Empty - : "\n(cached from previous session — refreshing…)"; + : "\n(cached from previous session; refreshing…)"; string message = - $"{fullName} has lifecycle methods that don't chain to MessageAwareComponent ({missingMethods}) — DxMessaging will not function on this component.\n" + $"{fullName} has lifecycle methods that don't chain to MessageAwareComponent ({missingMethods}); DxMessaging will not function on this component.\n" + "See docs/reference/analyzers.md." + freshnessSuffix; @@ -386,7 +386,7 @@ private static void TryRemoveIgnoredType(DxMessagingSettings settings, string fu { // Same reasoning as TryAddIgnoredType: defer mutation past the current GUI cycle so // the overlay's shape gating remains identical on Layout and Repaint passes of THIS - // frame. The next frame's Layout pass observes the new state — both passes agree. + // frame. The next frame's Layout pass observes the new state; both passes agree. EditorApplication.delayCall += () => { try diff --git a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs index cbf175da..cdff4920 100644 --- a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs +++ b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs @@ -27,7 +27,7 @@ public static class DxMessagingBaseCallIgnoreSync public const string SidecarAssetPath = "Assets/Editor/DxMessaging.BaseCallIgnore.txt"; private const string HeaderComment = - "# Auto-generated from Assets/Editor/DxMessagingSettings.asset — edit there instead."; + "# Auto-generated from Assets/Editor/DxMessagingSettings.asset; edit there instead."; private const string FormatComment = "# One fully-qualified type name per line. Lines starting with # are comments."; diff --git a/Editor/SetupCscRsp.cs b/Editor/SetupCscRsp.cs index 277aab47..af285d7c 100644 --- a/Editor/SetupCscRsp.cs +++ b/Editor/SetupCscRsp.cs @@ -37,7 +37,7 @@ public static class SetupCscRsp // Both DLLs ship side-by-side and both need the RoslynAnalyzer label. private static readonly string AnalyzerDllName = "WallstopStudios.DxMessaging.Analyzer.dll"; - // The analyzer DLLs and shared Roslyn surface ship unconditionally — they're light enough + // The analyzer DLLs and shared Roslyn surface ship unconditionally; they're light enough // and required for DXMSG002–DXMSG009 to function at all. The list intentionally references // a few transitive Roslyn deps that may or may not physically ship with the package; the // copy loop below silently skips any name that isn't on disk. @@ -390,7 +390,7 @@ string line in rspContent.Split( } else { - // Stale entry pointing at a moved/renamed/deleted sidecar — drop it. + // Stale entry pointing at a moved/renamed/deleted sidecar; drop it. foundStale = true; } } diff --git a/Runtime/Core/DxMessagingStaticState.cs b/Runtime/Core/DxMessagingStaticState.cs index 35f1a366..f4a10977 100644 --- a/Runtime/Core/DxMessagingStaticState.cs +++ b/Runtime/Core/DxMessagingStaticState.cs @@ -50,7 +50,25 @@ public static void Reset() MessageRegistrationHandle.SetIdSeed(Baseline.MessageRegistrationHandleSeed); MessageRegistrationBuilder.SetSyntheticOwnerCounter(Baseline.SyntheticOwnerCounter); + // Capture the active global bus before ResetStatics swaps it back to the default + // instance. If a user installed a custom global bus via SetGlobalMessageBus, we + // also bump that bus's reset generation so deregister closures captured against + // it (e.g. a deferred Object.Destroy that lands after Reset) silently no-op + // instead of logging spurious over-deregistration errors. We deliberately do NOT + // call ResetState() on the custom bus -- that would clear its sinks, which the + // user may have intentionally preserved. + IMessageBus activeBus = MessageHandler.MessageBus; + IMessageBus defaultBus = MessageHandler.InitialGlobalMessageBus; + MessageHandler.ResetStatics(); + + if ( + !ReferenceEquals(activeBus, defaultBus) + && activeBus is MessageBus.MessageBus customConcrete + ) + { + customConcrete.BumpResetGeneration(); + } } } diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index e3910b59..dda10818 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -170,6 +170,16 @@ Action RegisterUntargeted(MessageHandler messageHandler, int priority = 0) /// MessageHandler to register the TargetedMessages of the specified type. /// Priority at which to run; lower runs earlier. /// The deregistration action. Invoke when the handler no longer wants to receive the messages. + /// + /// To preserve frozen dispatch snapshots during in-flight emissions, the per-MessageHandler + /// typed-cache for (target, priority) is NOT removed when the last registration at + /// that pair is deregistered. Empty entries persist for the lifetime of the owning + /// MessageHandler. For typical Unity usage with a small fixed set of priorities and a + /// bounded set of long-lived target ids the residual footprint is trivial; code that + /// registers per-ephemeral-target (e.g. a global service that listens to messages targeted + /// at every spawned GameObject) should prefer + /// or recycle MessageHandlers to avoid unbounded inner-dictionary growth. + /// Action RegisterTargeted( InstanceId target, MessageHandler messageHandler, @@ -196,6 +206,16 @@ Action RegisterTargetedWithoutTargeting(MessageHandler messageHandler, int pr /// MessageHandler to register to accept BroadcastMessages. /// /// The deregistration action. Should be invoked when the handler no longer wants to receive messages. + /// + /// To preserve frozen dispatch snapshots during in-flight emissions, the per-MessageHandler + /// typed-cache for (source, priority) is NOT removed when the last registration at + /// that pair is deregistered. Empty entries persist for the lifetime of the owning + /// MessageHandler. For typical Unity usage with a small fixed set of priorities and a + /// bounded set of long-lived source ids the residual footprint is trivial; code that + /// registers per-ephemeral-source (e.g. a global service that listens to broadcasts from + /// every spawned GameObject) should prefer + /// or recycle MessageHandlers to avoid unbounded inner-dictionary growth. + /// Action RegisterSourcedBroadcast( InstanceId source, MessageHandler messageHandler, diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 1a876cf6..43806a0b 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -429,11 +429,43 @@ private readonly Dictionary< private bool _diagnosticsMode = ShouldEnableDiagnostics(); private bool _loggedReflexiveWarning; + // Bumped by ResetState. Deregister closures captured before the bump + // compare their captured generation to this field and silently skip + // when they no longer match, so a deferred Object.Destroy that lands + // after a Reset cannot log spurious over-deregistration errors. + private long _resetGeneration; + + /// + /// Bumps the internal reset generation counter without clearing any registrations or sinks. + /// + /// + /// + /// Deregister closures returned by the registration entry points capture the value of the + /// reset generation at registration time and silently no-op when the captured value differs + /// from the bus's current value. Calling this method invalidates every previously-issued + /// deregister closure for this bus, which is the desired behaviour after a logical "wipe" + /// performed by external state-management code (for example, a custom domain-reload-disabled + /// reset utility) that does not wish to clear registrations via . + /// + /// + /// uses this method to extend the destroy-then-Reset + /// race-safety guarantee to user-installed custom global buses without clobbering their state. + /// + /// + public void BumpResetGeneration() + { + unchecked + { + _resetGeneration++; + } + } + internal void ResetState() { _emissionId = 0; _diagnosticsMode = ShouldEnableDiagnostics(); _loggedReflexiveWarning = false; + BumpResetGeneration(); _sinks.Clear(); _targetedSinks.Clear(); @@ -574,8 +606,16 @@ public Action RegisterGlobalAcceptAll(MessageHandler messageHandler) DispatchCategory.GlobalBroadcast ); + long capturedGeneration = _resetGeneration; return () => { + // Generation guard: see InternalRegisterUntargeted for the + // rationale. Skip silently when the closure outlived a Reset. + if (capturedGeneration != _resetGeneration) + { + return; + } + _globalSinks.version++; _log.Log( new MessagingRegistration( @@ -676,8 +716,16 @@ out List interceptors ) ); + long capturedGeneration = _resetGeneration; return () => { + // Generation guard: see InternalRegisterUntargeted for the + // rationale. Skip silently when the closure outlived a Reset. + if (capturedGeneration != _resetGeneration) + { + return; + } + _log.Log( new MessagingRegistration( InstanceId.EmptyId, @@ -798,8 +846,16 @@ out List interceptors ) ); + long capturedGeneration = _resetGeneration; return () => { + // Generation guard: see InternalRegisterUntargeted for the + // rationale. Skip silently when the closure outlived a Reset. + if (capturedGeneration != _resetGeneration) + { + return; + } + _log.Log( new MessagingRegistration( InstanceId.EmptyId, @@ -920,8 +976,16 @@ out List interceptors ) ); + long capturedGeneration = _resetGeneration; return () => { + // Generation guard: see InternalRegisterUntargeted for the + // rationale. Skip silently when the closure outlived a Reset. + if (capturedGeneration != _resetGeneration) + { + return; + } + _log.Log( new MessagingRegistration( InstanceId.EmptyId, @@ -1554,6 +1618,10 @@ out HandlerCache sortedHandlers DispatchCategory.Targeted, _emissionId ); + // Pre-freeze the typed-handler caches across every priority bucket so + // deregistrations performed by an earlier priority's handler cannot + // empty a later priority's stack mid-emission. + PrefreezeTargetedSnapshot(ref target, snapshot); DispatchBucket[] buckets = snapshot.buckets; int bucketCount = snapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) @@ -1952,13 +2020,13 @@ HandlerCache cache ) where TMessage : ITargetedMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: see comment on RunBroadcast. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); - int messageHandlersCount = messageHandlers.Count; switch (messageHandlersCount) { case 1: @@ -2108,13 +2176,13 @@ HandlerCache cache ) where TMessage : ITargetedMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: see comment on RunBroadcast. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); - int messageHandlersCount = messageHandlers.Count; switch (messageHandlersCount) { case 1: @@ -2184,13 +2252,13 @@ HandlerCache cache ) where TMessage : ITargetedMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: see comment on RunBroadcast. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); - int messageHandlersCount = messageHandlers.Count; switch (messageHandlersCount) { case 1: @@ -2337,7 +2405,10 @@ out HandlerCache broadcastWithoutSourceHandlers BroadcastGlobalSourcedBroadcast(ref source, ref broadcastMessage); } - // Pre-freeze broadcast-without-source handler stacks for this emission + // Pre-freeze broadcast-without-source handler stacks for this emission. + // Skip the prefreeze pass entirely when there is exactly one priority + // bucket with at most one MessageHandler entry; see the rationale on + // the snapshot-level Prefreeze*Snapshot fast-path short-circuit. if ( _sinks.TryGetValue(out HandlerCache bwsHandlers) && bwsHandlers.handlers.Count > 0 @@ -2348,21 +2419,34 @@ out HandlerCache broadcastWithoutSourceHandlers _emissionId ); int frozenCount = frozen.Count; - for (int i = 0; i < frozenCount; ++i) + bool needsBwsPrefreeze = frozenCount > 1; + List singleBucketBwsHandlers = null; + if (!needsBwsPrefreeze && frozenCount == 1) { - KeyValuePair entry = frozen[i]; - List mhList = GetOrAddMessageHandlerStack( - entry.Value, + singleBucketBwsHandlers = GetOrAddMessageHandlerStack( + frozen[0].Value, _emissionId ); - for (int h = 0; h < mhList.Count; ++h) + needsBwsPrefreeze = singleBucketBwsHandlers.Count > 1; + } + if (needsBwsPrefreeze) + { + for (int i = 0; i < frozenCount; ++i) { - mhList[h] - .PrefreezeBroadcastWithoutSourceHandlersForEmission( - entry.Key, - _emissionId, - this - ); + KeyValuePair entry = frozen[i]; + List mhList = + (i == 0 && singleBucketBwsHandlers != null) + ? singleBucketBwsHandlers + : GetOrAddMessageHandlerStack(entry.Value, _emissionId); + for (int h = 0; h < mhList.Count; ++h) + { + mhList[h] + .PrefreezeBroadcastWithoutSourceHandlersForEmission( + entry.Key, + _emissionId, + this + ); + } } } } @@ -2386,6 +2470,56 @@ out HandlerCache sortedHandlers _emissionId ); int handlerListCount = handlerList.Count; + // Pre-freeze the typed-handler caches across every priority bucket so + // deregistrations performed by an earlier priority's handler cannot + // empty a later priority's stack mid-emission. The prefreeze pass is + // only required when at least one later-running handler reads from a + // cache that an earlier-running handler can mutate. That is the case + // when there are multiple priority buckets, OR when the single bucket + // holds more than one MessageHandler (each MessageHandler owns its + // own typed-handler cache, so a removal in one can blank another). + // Single-priority single-MessageHandler dispatch is already protected + // by the lazy GetOrAddNewHandlerStack inside the dispatch path; + // multiple delegate registrations within the same priority on the + // same MessageHandler share a HandlerActionCache that is frozen on + // first read by RunFastHandlersWithContext / RunHandlersWithContext. + bool needsPrefreeze = handlerListCount > 1; + List singleBucketFrozenHandlers = null; + if (!needsPrefreeze && handlerListCount == 1) + { + // For the single-bucket case, count entries in the FROZEN + // MessageHandler stack (not the live dict, which a concurrent + // global/interceptor deregistration could shrink between snapshot + // acquisition and this read). Reusing the frozen list also avoids + // re-acquiring it inside the prefreeze loop below. + singleBucketFrozenHandlers = GetOrAddMessageHandlerStack( + handlerList[0].Value, + _emissionId + ); + needsPrefreeze = singleBucketFrozenHandlers.Count > 1; + } + if (needsPrefreeze) + { + for (int i = 0; i < handlerListCount; ++i) + { + KeyValuePair prefreezeEntry = handlerList[i]; + List prefreezeHandlers = + (i == 0 && singleBucketFrozenHandlers != null) + ? singleBucketFrozenHandlers + : GetOrAddMessageHandlerStack(prefreezeEntry.Value, _emissionId); + int prefreezeHandlerCount = prefreezeHandlers.Count; + for (int h = 0; h < prefreezeHandlerCount; ++h) + { + prefreezeHandlers[h] + .PrefreezeBroadcastHandlersForEmission( + source, + prefreezeEntry.Key, + _emissionId, + this + ); + } + } + } switch (handlerListCount) { case 1: @@ -2776,12 +2910,13 @@ HandlerCache cache ) where TMessage : IBroadcastMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: see comment on RunBroadcast. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); - int messageHandlersCount = messageHandlers.Count; switch (messageHandlersCount) { case 1: @@ -2931,13 +3066,19 @@ HandlerCache cache ) where TMessage : IBroadcastMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: dispatch must respect the per-emission frozen + // MessageHandler list, even if a handler running earlier in the same + // emission has emptied the live cache.handlers dictionary by removing + // its own (or a sibling priority's) registration. Reading the live + // dict here would skip handlers that the snapshot still includes. + // GetOrAddMessageHandlerStack returns the snapshot list; bail only + // when that snapshot is empty. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); - int messageHandlersCount = messageHandlers.Count; switch (messageHandlersCount) { case 1: @@ -3426,7 +3567,7 @@ out List interceptorObjects } private bool InternalUntargetedBroadcast(ref TMessage message) - where TMessage : IMessage + where TMessage : IUntargetedMessage { if ( !_sinks.TryGetValue(out HandlerCache sortedHandlers) @@ -3450,6 +3591,11 @@ private bool InternalUntargetedBroadcast(ref TMessage message) return false; } + // Pre-freeze the typed-handler caches across every priority bucket so + // deregistrations performed by an earlier priority's handler cannot + // empty a later priority's stack mid-emission. + PrefreezeUntargetedSnapshot(snapshot); + bool invoked = false; for (int i = 0; i < bucketCount; ++i) @@ -3559,6 +3705,18 @@ private void PrefreezeUntargetedPostSnapshot(DispatchSnapshot snapshot return; } + // No fast-path short-circuit for post-processor prefreeze. + // + // The single-bucket/single-entry fast-path used by handler prefreeze + // (see PrefreezeUntargetedSnapshot) is unsafe for post-processors: + // post-processors run AFTER regular handlers, and a regular handler + // is allowed to register a NEW post-processor (or a new delegate on + // an existing post-processor cache) during its own execution. Without + // an unconditional prefreeze, the post-processor cache's first read + // happens lazily inside the post-processor dispatch; by which time + // the version has been bumped and the cache will be rebuilt with the + // newly-registered entry visible. Always prefreezing pins the + // emission-start snapshot before any handler can mutate it. DispatchBucket[] buckets = snapshot.buckets; int bucketCount = snapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) @@ -3584,6 +3742,55 @@ private void PrefreezeUntargetedPostSnapshot(DispatchSnapshot snapshot } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PrefreezeUntargetedSnapshot(DispatchSnapshot snapshot) + where TMessage : IUntargetedMessage + { + if (snapshot.IsEmpty) + { + return; + } + + // Prefreeze fast-path short-circuit: if there is exactly one priority + // bucket with at most one MessageHandler entry, no later handler can + // observe a removal performed by an earlier one, so the inline lazy + // freeze inside the dispatch path is sufficient. Note: a single + // MessageHandler may still register multiple delegates at the same + // priority; those share a HandlerActionCache that is frozen on first + // read by the per-priority RunFastHandlers/RunHandlers, so the lazy + // freeze covers same-priority same-MessageHandler removals correctly. + // See the longer rationale on the broadcast inline prefreeze block + // in SourcedBroadcast. + if (snapshot.bucketCount == 1 && snapshot.buckets[0].entryCount <= 1) + { + return; + } + + DispatchBucket[] buckets = snapshot.buckets; + int bucketCount = snapshot.bucketCount; + for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) + { + DispatchBucket bucket = buckets[bucketIndex]; + DispatchEntry[] entries = bucket.entries; + int entryCount = bucket.entryCount; + if (entryCount == 0) + { + continue; + } + + int priority = bucket.priority; + for (int entryIndex = 0; entryIndex < entryCount; ++entryIndex) + { + entries[entryIndex] + .handler.PrefreezeUntargetedHandlersForEmission( + priority, + _emissionId, + this + ); + } + } + } + private bool InternalTargetedWithoutTargetingBroadcast( ref InstanceId target, ref TMessage message @@ -3608,6 +3815,41 @@ ref TMessage message int bucketCount = snapshot.bucketCount; bool invoked = false; + // Hoist per-MessageHandler prefreeze across ALL priority buckets + // when there is more than one bucket. A handler running in an + // earlier bucket can deregister a delegate that lives in a later + // bucket's typed cache; if the later bucket's snapshot is taken + // lazily inside its own dispatch (after the deregistration), the + // rebuild will observe the mutation and the handler will be + // skipped, violating snapshot semantics. The single-bucket case + // is unchanged; no later bucket exists to be polluted, and the + // inline per-bucket prefreeze below covers it. + if (bucketCount > 1) + { + for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) + { + DispatchBucket prefreezeBucket = buckets[bucketIndex]; + DispatchEntry[] prefreezeEntries = prefreezeBucket.entries; + int prefreezeEntryCount = prefreezeBucket.entryCount; + if (prefreezeEntryCount == 0) + { + continue; + } + + if ( + prefreezeEntries[0].prefreeze.kind + == PrefreezeKindTargetedWithoutTargetingHandlers + ) + { + PrefreezeTargetedWithoutTargetingEntries( + prefreezeEntries, + prefreezeEntryCount, + prefreezeBucket.priority + ); + } + } + } + for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) { DispatchBucket bucket = buckets[bucketIndex]; @@ -3620,7 +3862,14 @@ ref TMessage message invoked = true; int priority = bucket.priority; - if (entries[0].prefreeze.kind == PrefreezeKindTargetedWithoutTargetingHandlers) + // Inline per-bucket prefreeze for the single-bucket case only. + // When bucketCount > 1 the hoisted pass above has already + // prefrozen every bucket; running it again here would be + // harmless but redundant. + if ( + bucketCount == 1 + && entries[0].prefreeze.kind == PrefreezeKindTargetedWithoutTargetingHandlers + ) { PrefreezeTargetedWithoutTargetingEntries( entries, @@ -3764,14 +4013,15 @@ HandlerCache cache ) where TMessage : ITargetedMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: see comment on RunBroadcast. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); // Freeze each handler's typed caches for this emission/priority to ensure snapshot semantics - for (int j = 0; j < messageHandlers.Count; ++j) + for (int j = 0; j < messageHandlersCount; ++j) { messageHandlers[j] .PrefreezeTargetedWithoutTargetingHandlersForEmission( @@ -3780,7 +4030,6 @@ HandlerCache cache this ); } - int messageHandlersCount = messageHandlers.Count; switch (messageHandlersCount) { case 1: @@ -3861,6 +4110,36 @@ ref TMessage message _emissionId ); int handlerListCount = handlerList.Count; + // Hoist per-MessageHandler prefreeze across ALL priority buckets + // when there is more than one bucket. A handler running in an + // earlier bucket can deregister a delegate that lives in a later + // bucket's typed cache; if the later bucket's snapshot is taken + // lazily inside RunBroadcastWithoutSource (after the + // deregistration), the rebuild will observe the mutation and + // skip the handler, violating snapshot semantics. The + // single-bucket case is unchanged; RunBroadcastWithoutSource's + // inline prefreeze covers it. + if (handlerListCount > 1) + { + for (int i = 0; i < handlerListCount; ++i) + { + KeyValuePair prefreezeEntry = handlerList[i]; + List mhList = GetOrAddMessageHandlerStack( + prefreezeEntry.Value, + _emissionId + ); + int mhCount = mhList.Count; + for (int h = 0; h < mhCount; ++h) + { + mhList[h] + .PrefreezeBroadcastWithoutSourceHandlersForEmission( + prefreezeEntry.Key, + _emissionId, + this + ); + } + } + } switch (handlerListCount) { case 1: @@ -3932,13 +4211,19 @@ HandlerCache cache ) where TMessage : IBroadcastMessage { - if (cache.handlers.Count == 0) + // Snapshot semantics: dispatch must respect the per-emission frozen + // MessageHandler list, even if a handler running earlier in the same + // emission has emptied the live cache.handlers dictionary by removing + // its own (or a sibling priority's) registration. Reading the live + // dict here would skip handlers that the snapshot still includes. + // GetOrAddMessageHandlerStack returns the snapshot list; bail only + // when that snapshot is empty. + List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); + int messageHandlersCount = messageHandlers.Count; + if (messageHandlersCount == 0) { return; } - - List messageHandlers = GetOrAddMessageHandlerStack(cache, _emissionId); - int messageHandlersCount = messageHandlers.Count; // Ensure each handler's typed no-source caches are frozen for this emission/priority for (int j = 0; j < messageHandlersCount; ++j) { @@ -4139,8 +4424,18 @@ int priority ) ); + long capturedGeneration = _resetGeneration; return () => { + // Generation guard: if ResetState() ran after this closure was + // captured (e.g. a deferred Object.Destroy fires after a + // domain-reload-style reset), silently no-op rather than + // logging a misleading over-deregistration error. + if (capturedGeneration != _resetGeneration) + { + return; + } + cache.version++; _log.Log( new MessagingRegistration( @@ -4273,8 +4568,16 @@ out HandlerCache handlers ); StageDispatchSnapshot(this, handlers, dispatchCategory); + long capturedGeneration = _resetGeneration; return () => { + // Generation guard: see InternalRegisterUntargeted for the + // rationale. Skip silently when the closure outlived a Reset. + if (capturedGeneration != _resetGeneration) + { + return; + } + cache.version++; _log.Log( new MessagingRegistration( @@ -4833,6 +5136,12 @@ DispatchSnapshot snapshot return; } + // No fast-path short-circuit for post-processor prefreeze. See the + // detailed rationale on PrefreezeUntargetedPostSnapshot; a regular + // handler can register a new post-processor (same MessageHandler, + // same priority) during its own execution, and the lazy first-read + // inside post-processor dispatch would otherwise capture that newly + // added entry. Always prefreezing pins the emission-start snapshot. DispatchBucket[] buckets = snapshot.buckets; int bucketCount = snapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) @@ -4859,6 +5168,59 @@ DispatchSnapshot snapshot } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PrefreezeTargetedSnapshot( + ref InstanceId target, + DispatchSnapshot snapshot + ) + where TMessage : ITargetedMessage + { + if (snapshot.IsEmpty) + { + return; + } + + // Prefreeze fast-path short-circuit: if there is exactly one priority + // bucket with at most one MessageHandler entry, no later handler can + // observe a removal performed by an earlier one, so the inline lazy + // freeze inside the dispatch path is sufficient. Note: a single + // MessageHandler may still register multiple delegates at the same + // priority; those share a HandlerActionCache that is frozen on first + // read by the per-priority RunFastHandlers/RunHandlers, so the lazy + // freeze covers same-priority same-MessageHandler removals correctly. + // See the longer rationale on the broadcast inline prefreeze block + // in SourcedBroadcast. + if (snapshot.bucketCount == 1 && snapshot.buckets[0].entryCount <= 1) + { + return; + } + + DispatchBucket[] buckets = snapshot.buckets; + int bucketCount = snapshot.bucketCount; + for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) + { + DispatchBucket bucket = buckets[bucketIndex]; + DispatchEntry[] entries = bucket.entries; + int entryCount = bucket.entryCount; + if (entryCount == 0) + { + continue; + } + + int priority = bucket.priority; + for (int entryIndex = 0; entryIndex < entryCount; ++entryIndex) + { + entries[entryIndex] + .handler.PrefreezeTargetedHandlersForEmission( + target, + priority, + _emissionId, + this + ); + } + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void InvokeGlobalUntargetedEntry( ref TMessage message, @@ -5007,6 +5369,12 @@ DispatchSnapshot snapshot return; } + // No fast-path short-circuit for post-processor prefreeze. See the + // detailed rationale on PrefreezeUntargetedPostSnapshot; a regular + // handler can register a new post-processor (same MessageHandler, + // same priority) during its own execution, and the lazy first-read + // inside post-processor dispatch would otherwise capture that newly + // added entry. Always prefreezing pins the emission-start snapshot. DispatchBucket[] buckets = snapshot.buckets; int bucketCount = snapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) @@ -5084,6 +5452,12 @@ DispatchSnapshot snapshot return; } + // No fast-path short-circuit for post-processor prefreeze. See the + // detailed rationale on PrefreezeUntargetedPostSnapshot; a regular + // handler can register a new post-processor (same MessageHandler, + // same priority) during its own execution, and the lazy first-read + // inside post-processor dispatch would otherwise capture that newly + // added entry. Always prefreezing pins the emission-start snapshot. DispatchBucket[] buckets = snapshot.buckets; int bucketCount = snapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) @@ -5174,6 +5548,13 @@ DispatchSnapshot snapshot return; } + // No fast-path short-circuit for post-processor prefreeze. See the + // detailed rationale on PrefreezeUntargetedPostSnapshot; a regular + // handler can register a new post-processor (same MessageHandler, + // same priority) during its own execution, and the lazy first-read + // inside post-processor dispatch would otherwise capture that newly + // added entry. Always prefreezing pins the emission-start snapshot. + DispatchBucket[] buckets = snapshot.buckets; int bucketCount = snapshot.bucketCount; for (int bucketIndex = 0; bucketIndex < bucketCount; ++bucketIndex) diff --git a/Runtime/Core/MessageHandler.cs b/Runtime/Core/MessageHandler.cs index f282cefe..be0d6a31 100644 --- a/Runtime/Core/MessageHandler.cs +++ b/Runtime/Core/MessageHandler.cs @@ -330,6 +330,149 @@ out HandlerActionCache> cache } } + /// + /// Pre-freezes this handler's untargeted handler caches for the given message type and priority + /// for the specified emission id, so removals during the same emission are not observed. + /// + /// Untargeted message type. + /// Priority bucket to freeze. + /// Current emission id. + /// Bus whose typed handler mapping to use. + internal void PrefreezeUntargetedHandlersForEmission( + int priority, + long emissionId, + IMessageBus messageBus + ) + where T : IUntargetedMessage + { + if (!GetHandlerForType(messageBus, out TypedHandler handler)) + { + return; + } + + if ( + handler._untargetedFastHandlers != null + && handler._untargetedFastHandlers.TryGetValue( + priority, + out HandlerActionCache> fastCache + ) + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); + } + + if ( + handler._untargetedHandlers != null + && handler._untargetedHandlers.TryGetValue( + priority, + out HandlerActionCache> cache + ) + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); + } + } + + /// + /// Pre-freezes this handler's targeted handler caches for the given message type, target, and priority + /// for the specified emission id, so removals during the same emission are not observed. + /// + /// Targeted message type. + /// Target instance id. + /// Priority bucket to freeze. + /// Current emission id. + /// Bus whose typed handler mapping to use. + internal void PrefreezeTargetedHandlersForEmission( + InstanceId target, + int priority, + long emissionId, + IMessageBus messageBus + ) + where T : ITargetedMessage + { + if (!GetHandlerForType(messageBus, out TypedHandler handler)) + { + return; + } + + if ( + handler._targetedFastHandlers != null + && handler._targetedFastHandlers.TryGetValue( + target, + out Dictionary>> fastByPriority + ) + && fastByPriority.TryGetValue( + priority, + out HandlerActionCache> fastCache + ) + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); + } + + if ( + handler._targetedHandlers != null + && handler._targetedHandlers.TryGetValue( + target, + out Dictionary>> byPriority + ) + && byPriority.TryGetValue(priority, out HandlerActionCache> cache) + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); + } + } + + /// + /// Pre-freezes this handler's broadcast handler caches for the given message type, source, and priority + /// for the specified emission id, so removals during the same emission are not observed. + /// + /// Broadcast message type. + /// Source instance id. + /// Priority bucket to freeze. + /// Current emission id. + /// Bus whose typed handler mapping to use. + internal void PrefreezeBroadcastHandlersForEmission( + InstanceId source, + int priority, + long emissionId, + IMessageBus messageBus + ) + where T : IBroadcastMessage + { + if (!GetHandlerForType(messageBus, out TypedHandler handler)) + { + return; + } + + if ( + handler._broadcastFastHandlers != null + && handler._broadcastFastHandlers.TryGetValue( + source, + out Dictionary>> fastByPriority + ) + && fastByPriority.TryGetValue( + priority, + out HandlerActionCache> fastCache + ) + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(fastCache, emissionId); + } + + if ( + handler._broadcastHandlers != null + && handler._broadcastHandlers.TryGetValue( + source, + out Dictionary>> byPriority + ) + && byPriority.TryGetValue(priority, out HandlerActionCache> cache) + ) + { + _ = TypedHandler.GetOrAddNewHandlerStack(cache, emissionId); + } + } + /// /// High-performance handler that receives the message by reference (no boxing/copies). /// @@ -3079,7 +3222,7 @@ public Action AddTargetedHandler( long emissionId ) { - return AddHandler( + return AddHandlerPreservingPriorityKey( target, ref _targetedHandlers, originalHandler, @@ -3107,7 +3250,7 @@ public Action AddTargetedHandler( long emissionId ) { - return AddHandler( + return AddHandlerPreservingPriorityKey( target, ref _targetedFastHandlers, originalHandler, @@ -3183,7 +3326,7 @@ public Action AddUntargetedHandler( long emissionId ) { - return AddHandler( + return AddHandlerPreservingPriorityKey( ref _untargetedHandlers, originalHandler, handler, @@ -3208,7 +3351,7 @@ public Action AddUntargetedHandler( long emissionId ) { - return AddHandler( + return AddHandlerPreservingPriorityKey( ref _untargetedFastHandlers, originalHandler, handler, @@ -3235,7 +3378,7 @@ public Action AddSourcedBroadcastHandler( long emissionId ) { - return AddHandler( + return AddHandlerPreservingPriorityKey( source, ref _broadcastHandlers, originalHandler, @@ -3263,7 +3406,7 @@ public Action AddSourcedBroadcastHandler( long emissionId ) { - return AddHandler( + return AddHandlerPreservingPriorityKey( source, ref _broadcastFastHandlers, originalHandler, @@ -3708,7 +3851,21 @@ long emissionId ); } - // Context-aware variant that preserves the priority key mapping on deregistration for the current emission. + // Context-aware variant that preserves the priority and context key + // mappings on deregistration so frozen dispatch snapshots remain valid + // for any in-flight emission. Trade-off: empty HandlerActionCache + // entries (and their enclosing per-priority Dictionary) are not + // reclaimed until either (a) a future registration at the same + // (context, priority) pair reuses the cache, or (b) the owning + // MessageHandler is destroyed. For typical Unity gameplay (a small + // fixed set of priorities and a bounded set of long-lived target / + // source InstanceIds) the residual footprint is on the order of + // hundreds of bytes per MessageHandler. Code that interacts with + // many transient InstanceIds (e.g. a global service that registers + // handlers per ephemeral GameObject) should prefer recycling + // MessageHandlers or routing through AddSourcedBroadcastWithoutSourceHandler / + // AddTargetedWithoutTargetingHandler to avoid the per-(context,priority) + // outer-dictionary growth. private static Action AddHandlerPreservingPriorityKey( InstanceId context, ref Dictionary< diff --git a/Runtime/Core/MessageRegistrationToken.cs b/Runtime/Core/MessageRegistrationToken.cs index 0db30f4e..d5248165 100644 --- a/Runtime/Core/MessageRegistrationToken.cs +++ b/Runtime/Core/MessageRegistrationToken.cs @@ -1977,6 +1977,13 @@ public void RemoveRegistration(MessageRegistrationHandle handle) { deregistrationAction?.Invoke(); } + + // Drop the matching staged registration and metadata so a later + // Disable()/Enable() cycle does not silently re-register the + // handler we were just asked to remove. + _ = _registrations?.Remove(handle); + _ = _metadata?.Remove(handle); + _ = _callCounts?.Remove(handle); } /// diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs index e17bac65..ecc541c7 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs @@ -26,14 +26,14 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers /// duplicate descriptor registrations for the same id. /// /// - // Diagnostic catalog (DxMessaging) — see docs/reference/analyzers.md for full details. + // Diagnostic catalog (DxMessaging); see docs/reference/analyzers.md for full details. // ---------------------------------------------------------------------------------- // DXMSG002 Error Multiple message attributes ([DxBroadcast/Targeted/Untargeted]) // on a single type. Source: DxMessageIdGenerator. // DXMSG003 Warning Type that needs source generation is nested inside non-partial // container(s). Source: both DxMessageIdGenerator and // DxAutoConstructorGenerator. - // DXMSG004 Info Companion suggestion to DXMSG003 — add 'partial' to the named + // DXMSG004 Info Companion suggestion to DXMSG003; add 'partial' to the named // container. Source: both generators. // DXMSG005 Error [DxOptionalParameter] default expression is not a legal C# constant // for the field's type. Source: DxAutoConstructorGenerator. @@ -49,7 +49,7 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers // C# emits CS0114 for the same scenario; DXMSG009 is the project- // specific equivalent. Source: MessageAwareComponentBaseCallAnalyzer. // DXMSG010 Warning This override correctly calls base.{method}(), but an intermediate - // ancestor's override of the same method does not — the chain is broken + // ancestor's override of the same method does not; the chain is broken // at the parent, so MessageAwareComponent's lifecycle work never runs // on this component. Source: MessageAwareComponentBaseCallAnalyzer. [DiagnosticAnalyzer(LanguageNames.CSharp)] @@ -139,7 +139,7 @@ public sealed class MessageAwareComponentBaseCallAnalyzer : DiagnosticAnalyzer category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "An override on this class correctly invokes base.X(), but the parent class's override of the same method does not itself call base — the chain is broken at the parent, so MessageAwareComponent's lifecycle work never runs. Fix the parent override to call base, OR override directly from MessageAwareComponent here, OR suppress with [DxIgnoreMissingBaseCall] if the broken chain is intentional.", + description: "An override on this class correctly invokes base.X(), but the parent class's override of the same method does not itself call base; the chain is broken at the parent, so MessageAwareComponent's lifecycle work never runs. Fix the parent override to call base, OR override directly from MessageAwareComponent here, OR suppress with [DxIgnoreMissingBaseCall] if the broken chain is intentional.", helpLinkUri: HelpLinkBase + "dxmsg010" ); @@ -164,7 +164,7 @@ public override void Initialize(AnalysisContext context) private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) { - // K. Defensive cast — protects against future syntax-kind reuse. + // K. Defensive cast; protects against future syntax-kind reuse. if (context.Node is not MethodDeclarationSyntax methodDecl) { return; @@ -208,8 +208,8 @@ is not IMethodSymbol methodSymbol // DXMSG009: when neither 'override' nor 'new' is present, C# treats the method as // implicit hiding of the base lifecycle method (compiler emits CS0114). Fire only when - // the signature shape actually matches a Unity lifecycle method — parameter-less, void, - // non-static, non-generic — so unrelated overloads like `void OnEnable(int)`, + // the signature shape actually matches a Unity lifecycle method; parameter-less, void, + // non-static, non-generic; so unrelated overloads like `void OnEnable(int)`, // unrelated static helpers, and `void Awake()` (which coexists with the base method // because of differing generic arity and does not trigger CS0114) all stay silent. bool wouldFireMissingModifier = @@ -222,7 +222,7 @@ is not IMethodSymbol methodSymbol // Bail when this method does not match any of our diagnostic shapes. This protects // unrelated methods on subclasses (e.g., a private helper named `Awake` that takes a - // parameter, or a static factory) from producing noise — including DXMSG008 on + // parameter, or a static factory) from producing noise; including DXMSG008 on // opted-out classes. if (!hasNewModifier && !hasOverrideModifier && !wouldFireMissingModifier) { @@ -230,7 +230,7 @@ is not IMethodSymbol methodSymbol } // Pre-compute would-have-fired flags so the opt-out branches can avoid emitting - // DXMSG008 on clean overrides — pure noise per the adversarial review (B5). The + // DXMSG008 on clean overrides; pure noise per the adversarial review (B5). The // override / new / missing-modifier branches are mutually exclusive at the C# language // level (a method cannot have both `override` and `new`, and `wouldFireMissingModifier` // requires neither). @@ -239,7 +239,7 @@ is not IMethodSymbol methodSymbol hasOverrideModifier && !ContainsBaseInvocation(methodDecl, methodName); // Pre-compute the DXMSG010 (broken transitive chain) check. Only relevant when this - // method IS an override AND base.X() IS present syntactically — otherwise DXMSG006 + // method IS an override AND base.X() IS present syntactically; otherwise DXMSG006 // already fires on this method and DXMSG010 would be redundant noise on the same // location. We compute it here so the opt-out branches can lower it to DXMSG008 too. IMethodSymbol brokenChainAncestor = null; @@ -253,7 +253,7 @@ out brokenChainAncestor ); // Opt-out via attribute on the method or the class. We still want the user to see that - // the suppression is active during build, so we emit DXMSG008 (Info) when bailing — + // the suppression is active during build, so we emit DXMSG008 (Info) when bailing; // BUT only when there is something we would have actually reported. if (HasIgnoreAttribute(methodSymbol) || HasIgnoreAttribute(containingType)) { @@ -309,7 +309,7 @@ out brokenChainAncestor if (wouldFireMissingModifier) { - // Implicit hiding — C# would emit CS0114 alongside this. We surface a project- + // Implicit hiding; C# would emit CS0114 alongside this. We surface a project- // specific diagnostic so the inspector overlay (which scopes to DXMSG006/007/009) // also shows the warning above the user's component. context.ReportDiagnostic( @@ -340,7 +340,7 @@ out brokenChainAncestor // From here on we know hasOverrideModifier is true. // I. base.X() inside a lambda or local function still counts as compliant per the - // good-faith policy — covered by `BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith`. + // good-faith policy; covered by `BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith`. if (!wouldFireMissingBase) { // DXMSG010: base.X() IS present syntactically, but the inherited override on an @@ -577,7 +577,7 @@ out IMethodSymbol firstBrokenLink ImmutableArray refs = cursor.DeclaringSyntaxReferences; if (refs.IsDefaultOrEmpty) { - return true; // cross-assembly / compiler-only symbol — assume clean + return true; // cross-assembly / compiler-only symbol; assume clean } bool ancestorCallsBase = false; @@ -603,7 +603,7 @@ out IMethodSymbol firstBrokenLink cursor = cursor.OverriddenMethod; } - // Walked off the top without hitting MessageAwareComponent — chain doesn't terminate + // Walked off the top without hitting MessageAwareComponent; chain doesn't terminate // at MessageAwareComponent. This shouldn't normally happen (the // StrictlyInheritsFromMessageAwareComponent gate at function entry guarantees the // containing type does inherit from MAC), but if it does, treat as clean to avoid @@ -626,7 +626,7 @@ INamedTypeSymbol containingType INamedTypeSymbol current = containingType; while (current is not null) { - // Stop walking once we've reached MessageAwareComponent itself — its virtual + // Stop walking once we've reached MessageAwareComponent itself; its virtual // declaration is not an override and shouldn't count. if ( string.Equals( @@ -723,7 +723,7 @@ private static bool PropertyReturnsLiteralFalse(SyntaxNode propertySyntax) return IsFalseLiteral(getterArrow.Expression); } - // Case 3: block-bodied getter — accept ONLY a single statement that is `return false;` + // Case 3: block-bodied getter; accept ONLY a single statement that is `return false;` // (no conditionals, no other statements). This avoids the false positive where any // branch happens to return false (e.g., `if (x) return false; return true;`). if (getter.Body is BlockSyntax block) @@ -752,7 +752,7 @@ private static bool IsFalseLiteral(ExpressionSyntax expression) // I. Sentinel comment: see HelperIndirectionFalsePositiveStillFires plus // BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith for the documented "good faith" - // policy — any textual `base.X()` anywhere inside the override body (including local + // policy; any textual `base.X()` anywhere inside the override body (including local // functions / lambdas) counts as compliant; helper-indirection through a separate // method does not. } diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs index 31410b98..49c9d942 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs @@ -55,7 +55,7 @@ public void IlInspectorOnEmptyMethodNameReturnsTrueAssumeClean() [Test] public void IlInspectorOnAbstractMethodReturnsTrueAssumeClean() { - // Abstract methods have no IL body — GetMethodBody() returns null. The inspector must + // Abstract methods have no IL body; GetMethodBody() returns null. The inspector must // treat this as assume-clean (cross-assembly third-party code paths exhibit the same // shape and emitting an unactionable warning would be hostile). MethodInfo abstractMethod = typeof(AbstractFixture).GetMethod( @@ -132,7 +132,7 @@ protected override void OnEnable() [Test] public void E2ELeafCallsUnrelatedSiblingMethodNotMistakenForBaseCall() { - // The leaf calls SOMETHING — but it's a method on a sibling class, not the parent's + // The leaf calls SOMETHING; but it's a method on a sibling class, not the parent's // OnEnable. The IsAssignableFrom check inside the inspector ensures we only count calls // to ancestors of the declaring type. Assembly fixture = CompileFixture( @@ -170,7 +170,7 @@ protected override void OnEnable() public void E2ELeafCallsBaseAwakeButCheckingForOnEnableDoesNotMatch() { // The leaf overrides Awake correctly but does not declare OnEnable. We're asking about - // "does this Awake body call base.OnEnable()" — which is a meaningless question, but the + // "does this Awake body call base.OnEnable()"; which is a meaningless question, but the // inspector shouldn't false-positive on the base.Awake() call. Assembly fixture = CompileFixture( """ @@ -249,7 +249,7 @@ string name in new[] [Test] public void E2EBrokenIntermediateChainDescendantBaseCallStillDetectedAtLeaf() { - // The leaf calls base.OnEnable() correctly — IL inspection of the leaf must report TRUE. + // The leaf calls base.OnEnable() correctly; IL inspection of the leaf must report TRUE. // The DXMSG010 detection (the intermediate's broken chain) is the SCANNER's job, not the // raw IL inspector's; here we confirm the inspector primitive faithfully reports each // method's IL in isolation regardless of what its ancestors do. @@ -261,7 +261,7 @@ public class BrokenMiddle : MessageAwareComponent { protected override void OnEnable() { - // No base call — chain dies here. + // No base call; chain dies here. } } @@ -298,7 +298,7 @@ protected override void OnEnable() BaseCallIlInspector.MethodIlContainsBaseCall(middleOnEnable, "OnEnable"), Is.False ); - // Leaf calls middle.OnEnable() correctly via base — the inspector reports true. + // Leaf calls middle.OnEnable() correctly via base; the inspector reports true. Assert.That( BaseCallIlInspector.MethodIlContainsBaseCall(leafOnEnable, "OnEnable"), Is.True @@ -309,7 +309,7 @@ protected override void OnEnable() public void E2ECallvirtStillDetectedAsBaseCall() { // C# emits `call` for non-virtual base method invocation, and `callvirt` for virtual ones - // in some configurations. We accept both opcodes — covered by Roslyn's standard emission + // in some configurations. We accept both opcodes; covered by Roslyn's standard emission // for `base.X()` overrides. Assembly fixture = CompileFixture( """ @@ -341,7 +341,7 @@ protected override void OnDestroy() public void E2EDeepChainLeafBaseCallDetected() { // Three-deep chain, each link calls base. The IL inspector at the leaf only inspects the - // leaf's body — it must report TRUE because the leaf's IL contains a base.OnEnable() call. + // leaf's body; it must report TRUE because the leaf's IL contains a base.OnEnable() call. Assembly fixture = CompileFixture( """ using DxMessaging.Unity; @@ -383,7 +383,7 @@ public class C : B public void E2ELeafCallsBaseConditionallyStillDetected() { // base.X() inside an `if` is still visible to the IL walker. The walker doesn't check - // reachability — even an unreachable base call counts as "calls base". This matches the + // reachability; even an unreachable base call counts as "calls base". This matches the // analyzer's conservative semantic check. Assembly fixture = CompileFixture( """ @@ -419,7 +419,7 @@ protected override void OnEnable() public void E2EMultipleSeparateBaseCallsStillDetectedAsCallsBase() { // Multiple invocations of base methods (e.g. base.OnEnable() called twice for some - // reason) — the inspector returns true on the first match and short-circuits. + // reason); the inspector returns true on the first match and short-circuits. Assembly fixture = CompileFixture( """ using DxMessaging.Unity; @@ -526,7 +526,7 @@ protected virtual void RegisterMessageHandlers() { } { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), }; - // Ensure System.Runtime is loaded — required for MetadataReference resolution on net9.0. + // Ensure System.Runtime is loaded; required for MetadataReference resolution on net9.0. Assembly runtime = Assembly.Load("System.Runtime"); if (!string.IsNullOrEmpty(runtime.Location)) { @@ -570,7 +570,7 @@ private abstract class AbstractFixture public void E2ELdstrBeforeBaseCallStillDetectsBaseCall() { // Spec 4b: an `ldstr` opcode (0x72) carries a 4-byte metadata-token operand. If the - // walker stepped 1 byte instead of 4, it would land inside the operand bytes — and one + // walker stepped 1 byte instead of 4, it would land inside the operand bytes; and one // of those bytes could happen to be 0x28 (call). The OpCodes-table walker steps the // operand bytes per the opcode's declared OperandType, so the base call AFTER the ldstr // must still be detected correctly. This pins the misalignment-proofness of the walker. @@ -657,7 +657,7 @@ public void E2EUnrelatedClassCallingSameNamedStaticMethodRejectedByIsAssignableF { // Spec 4e: the leaf calls a same-named method on a CONCRETE UNRELATED class (not via a // static-helper alias, but via the class type directly). The IsAssignableFrom guard inside - // MethodIlContainsBaseCall must reject this — the unrelated class is not an ancestor of + // MethodIlContainsBaseCall must reject this; the unrelated class is not an ancestor of // the leaf, so even though the method name matches, the call is not a base call. Assembly fixture = CompileFixture( """ @@ -698,7 +698,7 @@ protected override void OnEnable() public void E2ESecondInstanceMethodNamedSameAsBaseOnUnrelatedInstanceAlsoRejected() { // Spec 4e (reinforced): the leaf calls `OnEnable` on a field of an unrelated REFERENCE - // type — IsAssignableFrom must still reject. The reference type is not an ancestor of the + // type; IsAssignableFrom must still reject. The reference type is not an ancestor of the // leaf's declaring type, so the same-named call must not be misclassified. Assembly fixture = CompileFixture( """ @@ -742,7 +742,7 @@ public void E2EVolatilePrefixTwoByteOpcodeWalkerHandled() // Spec 4a: a method body containing the two-byte 0xFE 0x13 (volatile.) prefix BEFORE // an instruction. The OpCodes-table walker has a separate two-byte branch that must // step over volatile. correctly so the subsequent instructions are walked correctly. - // We exercise the branch by building a method via Reflection.Emit — the resulting IL + // We exercise the branch by building a method via Reflection.Emit; the resulting IL // contains the two-byte prefix shape and the inspector must terminate without throwing. // We assert the method correctly does NOT report a base call (the synthesized method // doesn't call any same-named method). diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs index e1bbf7bf..8f04167a 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs @@ -8,7 +8,7 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; public sealed class BaseCallLogMessageParserTests { // The exact format strings the analyzer uses today. If these drift, both this test and the - // parser regexes must be updated in lockstep — the parser is downstream of the analyzer. + // parser regexes must be updated in lockstep; the parser is downstream of the analyzer. private const string Dxmsg006Bare = "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + "the messaging system may not function correctly on this component."; @@ -142,7 +142,7 @@ public void ParseLineDebugLogStyleTextReturnsNull() { Assert.That( BaseCallLogMessageParser.ParseLine( - "Hello from Debug.Log — nothing analyzer-related here." + "Hello from Debug.Log; nothing analyzer-related here." ), Is.Null ); @@ -157,7 +157,7 @@ public void ParseLineDiagnosticIdInIsolationOrCommentFormReturnsNull() // A line that says DXMSG006 but is not the analyzer's wording. Assert.That( BaseCallLogMessageParser.ParseLine( - "DXMSG006 fired earlier today on this assembly — investigate." + "DXMSG006 fired earlier today on this assembly; investigate." ), Is.Null ); @@ -466,7 +466,7 @@ public void ParseLineDxmsg010BrokenAncestorIsNotSurfacedOnParsedEntry() // limitation: future readers of the parsed entry have no way to surface the broken-ancestor // FQN to the inspector overlay's "broken chain via {broken}" message. If the struct gains // a BrokenAncestor field in a future change, this test should be updated to assert the - // captured value rather than the absence — but until then, this test keeps the limitation + // captured value rather than the absence; but until then, this test keeps the limitation // visible to drive a future enhancement and prevent silent regressions of the regex itself. ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg010Bare); diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs index 6a45a642..b1a97f79 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs @@ -106,7 +106,7 @@ public void ScanNoModifierOnGuardedNameReportsHidingDiagnostic() // (C# CS0114) compiles to the same IL shape as `new void X()`. The scanner's IL-only // probe cannot distinguish DXMSG007 from DXMSG009; it conservatively classifies as // DXMSG007. The compile-time analyzer is authoritative for the precise ID. This test - // pins the conservative-classification contract — if a future scanner gains semantic + // pins the conservative-classification contract; if a future scanner gains semantic // insight, the assertion below should be updated alongside the doc note. Assembly fixture = CompileFixture( """ @@ -117,7 +117,7 @@ public class ImplicitHider : MessageAwareComponent { protected void OnEnable() { - // No `override`, no `new`. C# emits CS0114 — the analyzer would emit DXMSG009. + // No `override`, no `new`. C# emits CS0114; the analyzer would emit DXMSG009. // The scanner classifies as DXMSG007 because the IL is indistinguishable. } } @@ -169,7 +169,7 @@ public void ScanBrokenIntermediateReportsDxmsg010OnLeaf() { // The user's canonical `BrokenThing : ddd : MessageAwareComponent` case: ddd's override // does not call base, BrokenThing's override does. The leaf is the type the user is - // actively editing, so DXMSG010 should land on BrokenThing — not on ddd, which gets its + // actively editing, so DXMSG010 should land on BrokenThing; not on ddd, which gets its // own DXMSG006 row. Assembly fixture = CompileFixture( """ @@ -179,7 +179,7 @@ public class ddd : MessageAwareComponent { protected override void OnEnable() { - // No base call — chain dies here. + // No base call; chain dies here. } } @@ -215,7 +215,7 @@ public void ScanChainSkippingMiddleTypeReportsDxmsg010OnLeaf() // declare OnEnable, but ddd's override is broken. BrokenThing calls base correctly. The // chain walker's GetOverriddenMethod must walk PAST Middle (which doesn't declare the // slot directly) to find ddd's broken override. If the walker stopped at Middle without - // finding the method on it, we would report nothing on BrokenThing — a missed DXMSG010. + // finding the method on it, we would report nothing on BrokenThing; a missed DXMSG010. Assembly fixture = CompileFixture( """ using DxMessaging.Unity; @@ -224,7 +224,7 @@ public class ddd : MessageAwareComponent { protected override void OnEnable() { - // Broken — no base call. + // Broken; no base call. } } @@ -326,7 +326,7 @@ public abstract class AbstractBroken : MessageAwareComponent { protected override void OnEnable() { - // Broken — but abstract types are skipped. + // Broken; but abstract types are skipped. } } """ @@ -342,7 +342,7 @@ protected override void OnEnable() public void ScanGenericTypeDefinitionIsSkipped() { // Open generic-type definitions cannot be instantiated as MonoBehaviour components. - // Closed generic instantiations would be classified separately — but the open definition + // Closed generic instantiations would be classified separately; but the open definition // itself is a TypeCache artifact we should not surface. Assembly fixture = CompileFixture( """ @@ -352,7 +352,7 @@ public class GenericBroken : MessageAwareComponent { protected override void OnEnable() { - // Broken — but the generic-type-definition is skipped. + // Broken; but the generic-type-definition is skipped. } } """ @@ -616,7 +616,7 @@ public void ScanOnExternMethodTreatedAsCleanCrossAssembly() // be treated as assume-clean. GetMethodBody() returns null for extern methods just like // it does for cross-assembly closed-source code; the scanner's defensive bias means no // diagnostic is emitted. - // Suppress CS0626 for the missing DllImport — we never actually call the method, we just + // Suppress CS0626 for the missing DllImport; we never actually call the method, we just // need a MethodInfo whose IL body is null. Assembly fixture = CompileFixture( """ @@ -651,7 +651,7 @@ public class ExternLeaf : MessageAwareComponent BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); // The IL inspector returns true (assume clean) when the body is null. The scanner records - // an entry only when MissingBaseFor is non-empty — so an assume-clean override produces + // an entry only when MissingBaseFor is non-empty; so an assume-clean override produces // no entry. Assert.That( snapshot, @@ -709,7 +709,7 @@ private static Assembly CompileFixture(string userSource) { // Build a self-contained assembly that defines a MessageAwareComponent stub plus the // user's classes on top. The stub's chain terminator FQN ("DxMessaging.Unity.MessageAware - // Component") is the literal string the Core checks for to terminate the chain walk — + // Component") is the literal string the Core checks for to terminate the chain walk; // keep them in lock-step. const string Stubs = """ namespace UnityEngine @@ -746,7 +746,7 @@ protected virtual void RegisterMessageHandlers() { } { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), }; - // Ensure System.Runtime is loaded — required for MetadataReference resolution on net9.0. + // Ensure System.Runtime is loaded; required for MetadataReference resolution on net9.0. Assembly runtime = Assembly.Load("System.Runtime"); if (!string.IsNullOrEmpty(runtime.Location)) { diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs index 0a793146..412dda81 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs @@ -72,7 +72,7 @@ public void AggregateOnMixedDiagnosticsForSameTypeDedupesMethodsAndUnionsIds() Assert.That(report.MissingBaseFor, Is.EquivalentTo(new[] { "Awake", "OnEnable" })); Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006", "DXMSG007" })); // First-occurrence file path is stable so "Open Script" jumps to the first reported - // location — which is what the user's eye lands on first in the console. + // location; which is what the user's eye lands on first in the console. Assert.That(report.FilePath, Is.EqualTo("Assets/Sample/Player.cs")); Assert.That(report.Line, Is.EqualTo(8)); } @@ -99,8 +99,8 @@ public void AggregateDropsLinesWithoutAnyDxmsgPrefix() [Test] public void AggregateOnEmptyInputReturnsEmptyDictionary() { - // The harvester calls Aggregate even when an assembly produced zero matching messages - // — the empty result is then used by ApplyCompilerMessageDrain to RETIRE the previous + // The harvester calls Aggregate even when an assembly produced zero matching messages; + // the empty result is then used by ApplyCompilerMessageDrain to RETIRE the previous // attribution for that assembly. Stable empty handling is load-bearing for that flow. Dictionary aggregated = BaseCallLogMessageParser.Aggregate( Array.Empty() @@ -155,7 +155,7 @@ public void ApplyAssemblyReportsNewTypeAddedToBoth() [Test] public void ApplyAssemblyReportsRecompileSameAssemblyDropsRetiredTypes() { - // Assembly A reports type X with method Awake. The user fixes the issue and recompiles — + // Assembly A reports type X with method Awake. The user fixes the issue and recompiles; // A's next batch is empty. X must be removed from BOTH mergedReports AND // typesByAssembly[A] (otherwise the inspector shows a phantom HelpBox for a fixed type). Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); @@ -245,7 +245,7 @@ public void ApplyAssemblyReportsTwoAssembliesReportSameTypeRetainAfterOneDrops() public void ApplyAssemblyReportsDifferentMethodsOnSameTypeAcrossAssemblies() { // A reports X.Awake; B reports X.OnEnable. The merged view must carry both methods on a - // single X entry — this is the partial-class / split-assembly case. + // single X entry; this is the partial-class / split-assembly case. Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); Dictionary mergedReports = new(StringComparer.Ordinal); @@ -277,7 +277,7 @@ public void ApplyAssemblyReportsUnknownAssemblyKeyDoesNotDisturbExistingState() { // Sanity check: applying an empty batch for an assembly we've never seen leaves the // merged map untouched. A common refresh path on Unity 2021 is "every assembly fires - // assemblyCompilationFinished, even ones with no warnings" — those calls must not + // assemblyCompilationFinished, even ones with no warnings"; those calls must not // accidentally zero out the snapshot. Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); Dictionary mergedReports = new(StringComparer.Ordinal); @@ -381,7 +381,7 @@ public void BuildSnapshotLogEntriesAndCompilerMessageAgree() public void BuildSnapshotLogEntriesOnlyVsCompilerMessageOnly() { // Each source independently produces a non-empty snapshot. Both halves of the dual-source - // contract must work in isolation — Unity 2021 only feeds the CompilerMessage path, + // contract must work in isolation; Unity 2021 only feeds the CompilerMessage path, // Unity 2022+ predominantly feeds LogEntries. Dictionary logOnly = MakeReports( ("Sample.A", new[] { "Awake" }, "DXMSG006", "A.cs", 1) @@ -518,7 +518,7 @@ public void ApplyAssemblyReportsThreeAssembliesDisjointSetsRetireOneAndOverlap() public void AggregateSameAssemblyReportsSameFqnMultipleTimesInOneDrainDedupesMethods() { // Spec 3b: a single drain that contains the SAME line three times for the same FQN must - // dedupe — Aggregate-then-merge always produces a single MissingBaseFor entry per method. + // dedupe; Aggregate-then-merge always produces a single MissingBaseFor entry per method. // This pins the parser-level dedup contract that the harvester depends on. Dictionary aggregated = BaseCallLogMessageParser.Aggregate( new[] { Unity2021Dxmsg009, Unity2021Dxmsg009, Unity2021Dxmsg009 } @@ -537,7 +537,7 @@ public void AggregateSameAssemblyReportsSameFqnMultipleTimesInOneDrainDedupesMet [Test] public void BuildSnapshotDictionaryWithNullValueDoesNotCrash() { - // Spec 3c: defensive — if either source dictionary contains a null ParsedTypeReport value + // Spec 3c: defensive; if either source dictionary contains a null ParsedTypeReport value // (a defensive shape we may see if the harvester's internal state ever decays), the // snapshot builder must not crash. The null entry is silently skipped. Dictionary logEntries = new(StringComparer.Ordinal) @@ -563,7 +563,7 @@ public void ApplyAssemblyReportsFilePathStickinessFirstSeenWinsAcrossAssemblies( { // Spec 3d: A reports type X with path=A.cs line=10. B then ALSO reports X with path=B.cs // line=20. The merged snapshot must keep A.cs/10 (first-assembly-seen wins). This pins - // the cross-assembly first-seen contract — same-assembly recompile uses latest payload + // the cross-assembly first-seen contract; same-assembly recompile uses latest payload // (different code path; pinned implicitly by ApplyAssemblyReports_RecompileSameAssembly...). Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); Dictionary mergedReports = new(StringComparer.Ordinal); diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs index 1f1e4811..c1d86519 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxAutoConstructorGeneratorDiagnosticsTests.cs @@ -66,4 +66,460 @@ public readonly partial struct InvalidOptional "DXMSG005 should be reported for optional defaults that cannot be parsed." ); } + + // ------------------------------------------------------------------------------------------ + // Phase D: [DxOptionalParameter] permutations on primitive types (constructor-constant path). + // ------------------------------------------------------------------------------------------ + + [Test] + public void OptionalParameterIntDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "int", + attributeArg: "42", + expectedDefaultLiteral: "42" + ); + } + + [Test] + public void OptionalParameterLongDefaultEmitsConstructorWithLiteral() + { + // Long literals must be suffixed with 'L' to disambiguate from int. + AssertOptionalProducesDefault( + fieldType: "long", + attributeArg: "9000000000L", + expectedDefaultLiteral: "9000000000L" + ); + } + + [Test] + public void OptionalParameterFloatDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "float", + attributeArg: "3.14f", + expectedDefaultLiteral: "3.14f" + ); + } + + [Test] + public void OptionalParameterDoubleDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "double", + attributeArg: "2.718", + expectedDefaultLiteral: "2.718" + ); + } + + [Test] + public void OptionalParameterBoolDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "bool", + attributeArg: "true", + expectedDefaultLiteral: "true" + ); + } + + [Test] + public void OptionalParameterStringDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "string", + attributeArg: "\"hello\"", + expectedDefaultLiteral: "\"hello\"" + ); + } + + [Test] + public void OptionalParameterCharDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "char", + attributeArg: "'x'", + expectedDefaultLiteral: "'x'" + ); + } + + [Test] + public void OptionalParameterByteDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "byte", + attributeArg: "(byte)7", + expectedDefaultLiteral: "7" + ); + } + + [Test] + public void OptionalParameterShortDefaultEmitsConstructorWithLiteral() + { + AssertOptionalProducesDefault( + fieldType: "short", + attributeArg: "(short)11", + expectedDefaultLiteral: "11" + ); + } + + // ------------------------------------------------------------------------------------------ + // Phase D: [DxOptionalParameter(Expression = "...")] permutations. + // ------------------------------------------------------------------------------------------ + + [Test] + public void OptionalParameterStringNullableDefaultViaExpressionEmits() + { + // string? with `Expression = "null"` should bind cleanly because the field is a reference + // type, satisfying IsReferenceOrNullable. + string source = """ +#nullable enable +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "null")] + public readonly string? maybe; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "string? with Expression=\"null\" must not emit DXMSG005." + ); + AssertGeneratedSourceContains( + result, + "= null", + "Generated constructor should default the parameter to `null`." + ); + } + + [Test] + public void OptionalParameterEnumLiteralViaExpressionEmits() + { + // Enum literal via Expression; the named-argument path runs IsValidDefaultExpression, + // which speculatively binds the expression at the type's syntax position. The enum is + // declared in the same compilation, so the binding should succeed. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +public enum MyEnum +{ + Default, + Other, +} + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "MyEnum.Other")] + public readonly MyEnum value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Enum literal via Expression must not emit DXMSG005." + ); + AssertGeneratedSourceContains( + result, + "= MyEnum.Other", + "Generated constructor should default to MyEnum.Other." + ); + } + + [Test] + public void OptionalParameterDefaultStructViaExpressionEmits() + { + // `default(MyStruct)` is currently the canonical way to express a struct's default value + // through DxOptionalParameter. The bare `default` literal is also accepted (the generator + // short-circuits on the literal `default` in IsValidDefaultExpression). + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +public struct MyStruct +{ + public int value; +} + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "default(MyStruct)")] + public readonly MyStruct s; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "default(MyStruct) via Expression must not emit DXMSG005." + ); + AssertGeneratedSourceContains( + result, + "= default(MyStruct)", + "Generated constructor should default the parameter to `default(MyStruct)`." + ); + } + + [Test] + public void OptionalParameterBareDefaultLiteralEmits() + { + // Special case: the generator short-circuits on the literal token `default`; it is valid + // for any type because the parameter slot supplies the type context. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "default")] + public readonly int value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Bare `default` Expression must not emit DXMSG005." + ); + AssertGeneratedSourceContains( + result, + "= default", + "Generated constructor should default the parameter to `default`." + ); + } + + // ------------------------------------------------------------------------------------------ + // Phase D: DXMSG005 emission boundary cases. + // ------------------------------------------------------------------------------------------ + + [Test] + public void DXMSG005DoesNotFireForRuntimeExpressionWhoseTypeMatches() + { + // PINS CURRENT CONTRACT (slightly weaker than the brief assumed): the generator's + // IsValidDefaultExpression speculatively binds the expression and checks only that the + // bound type is implicitly convertible to the field type. It does NOT verify that the + // expression is a compile-time constant. Therefore a non-constant expression like + // `System.DateTime.Now.Ticks` (whose type is `long`, convertible to a `long` field) passes + // DXMSG005's check. The C# compiler then surfaces the real problem as CS1736 on the + // generated constructor signature. This test pins THAT behavior so a future tightening of + // IsValidDefaultExpression to require constants is a deliberate, visible change. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "System.DateTime.Now.Ticks")] + public readonly long value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Id == "DXMSG005"), + Is.Empty, + "Pin: DXMSG005 currently does not fire for non-constant expressions whose bound type " + + "matches the field type. Constant-ness is enforced downstream by CS1736." + ); + } + + [Test] + public void DXMSG005FiresForUnparseableExpression() + { + // Pin: a syntactically invalid expression must surface as DXMSG005 (the generator catches + // the parse exception and reports the diagnostic rather than crashing). + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "(((")] + public readonly int value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG005"), + "DXMSG005 should fire for unparseable Expression strings." + ); + } + + [Test] + public void DXMSG005FiresForTypeMismatchedExpression() + { + // Pin: `Expression = "\"hello\""` on an `int` field must be rejected; there is no + // implicit conversion from string -> int. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(Expression = "\"hello\"")] + public readonly int value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG005"), + "DXMSG005 should fire when the Expression's type cannot implicitly convert to the field type." + ); + } + + [Test] + public void DXMSG005FiresForNullOnValueTypeField() + { + // Pin: `[DxOptionalParameter(null)]` on an `int` field is invalid because int is neither + // a reference type nor a Nullable. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(null)] + public readonly int value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG005"), + "DXMSG005 should fire when null is passed as the default for a non-nullable value type." + ); + } + + [Test] + public void DXMSG005DoesNotFireForCompatiblePrimitiveConstant() + { + // Boundary: a literal `int` constant on an `int` field must NOT trip DXMSG005. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter(7)] + public readonly int value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Id == "DXMSG005"), + Is.Empty, + "DXMSG005 should not fire for compatible primitive constants." + ); + AssertGeneratedSourceContains( + result, + "= 7", + "Generated constructor should default the parameter to `7`." + ); + } + + // ------------------------------------------------------------------------------------------ + // Helpers. + // ------------------------------------------------------------------------------------------ + + /// + /// Drives the auto-constructor generator with a single optional field of the given type and + /// default attribute argument, then asserts no DXMSG005 fires AND the expected literal lands + /// in the generated constructor signature. + /// + private static void AssertOptionalProducesDefault( + string fieldType, + string attributeArg, + string expectedDefaultLiteral + ) + { + string source = $$""" +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxAutoConstructor] +public readonly partial struct M +{ + [DxOptionalParameter({{attributeArg}})] + public readonly {{fieldType}} value; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxAutoConstructor(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics.Where(d => d.Id == "DXMSG005"), + Is.Empty, + $"DXMSG005 must not fire for {fieldType} with attribute arg {attributeArg}." + ); + AssertGeneratedSourceContains( + result, + $"= {expectedDefaultLiteral}", + $"Generated constructor should default the {fieldType} parameter to {expectedDefaultLiteral}." + ); + } + + private static void AssertGeneratedSourceContains( + GeneratorDriverRunResult result, + string fragment, + string failureMessage + ) + { + string joined = string.Join( + "\n", + result.Results.SelectMany(r => r.GeneratedSources).Select(g => g.SourceText.ToString()) + ); + Assert.That(joined, Does.Contain(fragment), failureMessage); + } } diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs index 39155182..c15740bb 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DxMessageIdGeneratorDiagnosticsTests.cs @@ -1,5 +1,7 @@ using System.Linq; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; using NUnit.Framework; namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; @@ -63,4 +65,385 @@ public class Container "DXMSG004 should suggest adding the partial keyword for the containing type." ); } + + // ------------------------------------------------------------------------------------------ + // Phase D: generic message structs. + // ------------------------------------------------------------------------------------------ + + [Test] + public void GenericMessageStructEmitsId() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +public readonly partial struct MyMessage { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + + Assert.That( + result.Results[0].Diagnostics, + Is.Empty, + "A generic message struct must not produce diagnostics." + ); + AssertGeneratedSourceContains( + result, + "MyMessage", + "Generic message struct should generate a partial declaration with its type parameter." + ); + AssertGeneratedSourceParses(result); + } + + [Test] + public void GenericMessageWithMultipleTypeParametersEmitsId() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxBroadcastMessage] +public readonly partial struct MyMessage { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + + Assert.That( + result.Results[0].Diagnostics, + Is.Empty, + "A multi-parameter generic message struct must not produce diagnostics." + ); + AssertGeneratedSourceContains( + result, + "MyMessage", + "Generic message struct should generate a partial declaration with all of its type parameters." + ); + AssertGeneratedSourceParses(result); + } + + [Test] + public void GenericMessageWithConstraintEmitsId() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +public readonly partial struct MyMessage where T : struct { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + + Assert.That( + result.Results[0].Diagnostics, + Is.Empty, + "A generic message struct with a constraint must not produce diagnostics." + ); + AssertGeneratedSourceContains( + result, + "MyMessage", + "Generic message struct should generate the partial declaration with its type parameter." + ); + AssertGeneratedSourceParses(result); + } + + // ------------------------------------------------------------------------------------------ + // Phase D: record struct messages. + // ------------------------------------------------------------------------------------------ + + [Test] + public void RecordStructMessageEmitsId() + { + // Pins current contract: `[DxUntargetedMessage] partial record struct` is supported and + // produces a partial declaration whose kind is rendered as `record struct`. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +public readonly partial record struct MyRecordMessage(int Value); +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + + Assert.That( + result.Results[0].Diagnostics, + Is.Empty, + "A record struct message must not produce diagnostics." + ); + AssertGeneratedSourceContains( + result, + "record struct MyRecordMessage", + "The generator should emit a partial record struct declaration." + ); + AssertGeneratedSourceParses(result); + } + + // ------------------------------------------------------------------------------------------ + // Phase D: deep partial nesting. + // ------------------------------------------------------------------------------------------ + + [Test] + public void MessageInThreeLevelNestedPartialContainerEmitsId() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +public partial class A +{ + public partial class B + { + public partial class C + { + [DxUntargetedMessage] + public readonly partial struct M { } + } + } +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + + Assert.That( + result.Results[0].Diagnostics, + Is.Empty, + "A message nested in three partial containers must not produce diagnostics." + ); + AssertGeneratedSourceContains( + result, + "partial struct M", + "The generator should emit a partial declaration for the inner message type." + ); + AssertGeneratedSourceContains( + result, + "partial class A", + "The generator should re-open the outermost container as partial." + ); + AssertGeneratedSourceParses(result); + } + + [Test] + public void MessageInNonPartialContainerEmitsDiagnostic() + { + // Pins current contract: ANY non-partial link in the container chain triggers DXMSG003 + + // DXMSG004. + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +public partial class A +{ + public class B + { + public partial class C + { + [DxUntargetedMessage] + public readonly partial struct M { } + } + } +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG003"), + "DXMSG003 should fire when ANY container in the chain is non-partial." + ); + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG004"), + "DXMSG004 should suggest making the non-partial container partial." + ); + } + + // ------------------------------------------------------------------------------------------ + // Phase D: multiple message attributes; permutations. + // ------------------------------------------------------------------------------------------ + + [Test] + public void MultipleMessageAttributesUntargetedAndTargetedEmitsDxmsg002() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +[DxTargetedMessage] +public readonly partial struct Conflicting { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG002"), + "DXMSG002 should fire for [DxUntargetedMessage] + [DxTargetedMessage]." + ); + } + + [Test] + public void MultipleMessageAttributesUntargetedAndBroadcastEmitsDxmsg002() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +[DxBroadcastMessage] +public readonly partial struct Conflicting { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG002"), + "DXMSG002 should fire for [DxUntargetedMessage] + [DxBroadcastMessage]." + ); + } + + [Test] + public void MultipleMessageAttributesTargetedAndBroadcastEmitsDxmsg002() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxTargetedMessage] +[DxBroadcastMessage] +public readonly partial struct Conflicting { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG002"), + "DXMSG002 should fire for [DxTargetedMessage] + [DxBroadcastMessage]." + ); + } + + [Test] + public void MultipleMessageAttributesAllThreeEmitsDxmsg002() + { + string source = """ +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +[DxTargetedMessage] +[DxBroadcastMessage] +public readonly partial struct Conflicting { } +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + Diagnostic[] diagnostics = result.Results[0].Diagnostics.ToArray(); + + Assert.That( + diagnostics, + Has.Some.Matches(d => d.Id == "DXMSG002"), + "DXMSG002 should fire when all three Dx message attributes are present." + ); + } + + // ------------------------------------------------------------------------------------------ + // Phase D: nullable annotations. + // ------------------------------------------------------------------------------------------ + + [Test] + public void MessageWithNullableReferenceFieldsCompiles() + { + // The generated partial emits `#nullable enable annotations` so the consumer's nullable + // reference fields must round-trip cleanly when the user source itself opts in via + // `#nullable enable`. + string source = """ +#nullable enable +using DxMessaging.Core.Attributes; + +namespace Sample; + +[DxUntargetedMessage] +public partial struct M +{ + public string? Optional; + public int Required; +} +"""; + + GeneratorDriverRunResult result = GeneratorTestUtilities.RunDxMessageId(source); + + Assert.That( + result.Results[0].Diagnostics, + Is.Empty, + "A message struct with nullable reference fields must not produce diagnostics." + ); + AssertGeneratedSourceContains( + result, + "partial struct M", + "The generator should emit a partial declaration even when the user struct has nullable fields." + ); + AssertGeneratedSourceParses(result); + } + + // ------------------------------------------------------------------------------------------ + // Helpers. + // ------------------------------------------------------------------------------------------ + + private static void AssertGeneratedSourceContains( + GeneratorDriverRunResult result, + string fragment, + string failureMessage + ) + { + // Walk every generated tree, concatenate text, then assert. Roslyn's GeneratedSources is + // an ImmutableArray; we don't care about the partition here. + string joined = string.Join( + "\n", + result.Results.SelectMany(r => r.GeneratedSources).Select(g => g.SourceText.ToString()) + ); + Assert.That(joined, Does.Contain(fragment), failureMessage); + } + + private static void AssertGeneratedSourceParses(GeneratorDriverRunResult result) + { + // Parse the generator's output as standalone trees and assert they have no syntax errors. + // This catches indentation, accessibility, and type-param mismatch regressions that surface + // as parse-level diagnostics, but does NOT perform a full compile against referenced types. + Assert.That( + result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Generator must not surface compile-error diagnostics." + ); + + foreach (GeneratorRunResult r in result.Results) + { + foreach (GeneratedSourceResult g in r.GeneratedSources) + { + SyntaxTree tree = CSharpSyntaxTree.ParseText(g.SourceText); + Assert.That( + tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + $"Generated file '{g.HintName}' has parse errors." + ); + } + } + } } diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs index 3106cde1..3695f8d2 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs @@ -136,7 +136,7 @@ internal static ImmutableArray RunBaseCallAnalyzer( options: effectiveOptions ); - // B6. Refuse to return when the underlying compilation has errors — otherwise tests can + // B6. Refuse to return when the underlying compilation has errors; otherwise tests can // silently bind nothing and pass. We exclude analyzer diagnostics here (we only want raw // compile errors) by calling Compilation.GetDiagnostics rather than the analyzer pipeline. ImmutableArray compileDiags = compilation.GetDiagnostics(); diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs index b7f24786..dfbd8a76 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs @@ -12,7 +12,7 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; [TestFixture] public sealed class MessageAwareComponentBaseCallAnalyzerTests { - // S2. Reference the analyzer's source-of-truth constant directly via InternalsVisibleTo — + // S2. Reference the analyzer's source-of-truth constant directly via InternalsVisibleTo; // no more duplicated literal in the tests. Drift risk eliminated. private static readonly string IgnoreFileName = IgnoreListReader.IgnoreFileName; @@ -329,7 +329,7 @@ protected override void Awake() [Test] public void IgnoreAttributeOnClassEmitsDxmsg008Only() { - // B5. Two overrides: one clean, one dirty. DXMSG008 must fire ONCE — on the dirty one. + // B5. Two overrides: one clean, one dirty. DXMSG008 must fire ONCE; on the dirty one. // Clean overrides on opted-out classes must produce zero diagnostics (no noise). string source = """ namespace Sample @@ -392,7 +392,7 @@ protected override void Awake() [Test] public void ClassFullNameInIgnoreListEmitsDxmsg008Only() { - // B5. Two overrides — clean and dirty. DXMSG008 fires ONCE on the dirty one. + // B5. Two overrides; clean and dirty. DXMSG008 fires ONCE on the dirty one. string source = """ namespace Sample { @@ -531,7 +531,7 @@ private void CallBaseAwake() } """; - // Documented false positive — analyzer is good-faith and only inspects the override body. + // Documented false positive; analyzer is good-faith and only inspects the override body. ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); @@ -624,7 +624,7 @@ protected override void RegisterMessageHandlers() [Test] public void MethodWithoutOverrideOrNewIsIgnored() { - // A new declaration named Awake that neither overrides nor hides — should not be flagged. + // A new declaration named Awake that neither overrides nor hides; should not be flagged. string source = """ namespace Sample { @@ -712,7 +712,7 @@ protected override void RegisterMessageHandlers() public void MoreDerivedRegisterForStringMessagesOverrideWinsAndPreventsSmartCase() { // B2. Most-derived override wins. Even though the grandparent returns literal false, the - // intermediate overrides it back to literal true — so the smart-case must NOT apply. + // intermediate overrides it back to literal true; so the smart-case must NOT apply. string source = """ namespace Sample { @@ -747,7 +747,7 @@ protected override void RegisterMessageHandlers() public void ConditionalReturnFalseInRegisterForStringMessagesGetterKeepsWarning() { // B3. Block-bodied getter with `if (...) return false; return true;` is NOT - // unconditional — smart-case must NOT apply. + // unconditional; smart-case must NOT apply. string source = """ namespace Sample { @@ -816,7 +816,7 @@ protected override void RegisterMessageHandlers() public void NonOverrideAwakeOnOptedOutClassProducesZeroDiagnostics() { // B (strong). A method named `Awake` with neither `override` nor `new` on an opted-out - // class must produce zero diagnostics — including DXMSG008. Before the reorder fix, + // class must produce zero diagnostics; including DXMSG008. Before the reorder fix, // DXMSG008 could fire here. string source = """ namespace Sample @@ -842,7 +842,7 @@ public void Awake(int discriminator) [Test] public void UsingAliasForMessageAwareComponentResolvesViaFullyQualifiedName() { - // G. Confirms FQN resolution survives `using` aliases — the analyzer walks BaseType + // G. Confirms FQN resolution survives `using` aliases; the analyzer walks BaseType // and compares against the symbol's display name, so aliases are transparent. string source = """ using MAC = DxMessaging.Unity.MessageAwareComponent; @@ -869,7 +869,7 @@ protected override void Awake() [Test] public void EditorConfigSuppressOnDxmsg006SilencesCanonicalCase() { - // H.1 — descriptor-based DXMSG006 path: WithSpecificDiagnosticOptions(Suppress) yields zero. + // H.1; descriptor-based DXMSG006 path: WithSpecificDiagnosticOptions(Suppress) yields zero. string source = """ namespace Sample { @@ -908,9 +908,9 @@ protected override void Awake() [Test] public void EditorConfigSuppressOnDxmsg006AlsoSilencesSmartCaseInfoPath() { - // H.2 — the runtime-built `Diagnostic.Create(string id, ...)` path used for the + // H.2; the runtime-built `Diagnostic.Create(string id, ...)` path used for the // smart-case Info lowering must also honour editorconfig severity overrides. This is the - // rubric-flagged "real gotcha" — without proper threading the Info diagnostic would slip + // rubric-flagged "real gotcha"; without proper threading the Info diagnostic would slip // through `Suppress`. string source = """ namespace Sample @@ -952,7 +952,7 @@ protected override void RegisterMessageHandlers() [Test] public void EditorConfigPromoteOnDxmsg006ProducesError() { - // H.3 — promotion (`Error`) must thread through the descriptor path. + // H.3; promotion (`Error`) must thread through the descriptor path. string source = """ namespace Sample { @@ -994,7 +994,7 @@ protected override void Awake() public void BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith() { // I. base.X() inside a nested local function still satisfies the good-faith textual - // search — DescendantNodes() walks lambdas and local functions. Documents the policy. + // search; DescendantNodes() walks lambdas and local functions. Documents the policy. string source = """ namespace Sample { @@ -1077,7 +1077,7 @@ public void IgnoreListReaderRepeatLoadOnSameOptionsReturnsSameInstanceAndIsToken // S1. Verify the Lazy cache contract: two calls to Load(...) with the same // AnalyzerOptions must return the same hashset instance (single-shot memoization), // and passing a different (or even already-cancelled) CancellationToken on the - // second call must NOT crash — the factory deliberately drops the outer token via + // second call must NOT crash; the factory deliberately drops the outer token via // CancellationToken.None to avoid the cached-cancellation-exception footgun. AnalyzerOptions options = GeneratorTestUtilities.BuildAnalyzerOptions( (IgnoreFileName, "Sample.Player\nSample.Other\n") @@ -1089,7 +1089,7 @@ public void IgnoreListReaderRepeatLoadOnSameOptionsReturnsSameInstanceAndIsToken Assert.That(first, Does.Contain("Sample.Other")); // Second call uses an already-cancelled token. If the factory closure baked the - // first call's token, this would still return the cached value — fine. The footgun + // first call's token, this would still return the cached value; fine. The footgun // (which the fix prevents) is the inverse: a cancelled FIRST call caches an // OperationCanceledException and re-throws it forever. We can't easily simulate // that here without racing, so we settle for asserting the cache returns the same @@ -1109,7 +1109,7 @@ public void OptOutAttributePlusFalseStringMessagesSettingProducesSingleDxmsg008( // S4. When a class has BOTH the [DxIgnoreMissingBaseCall] opt-out AND a literal-false // RegisterForStringMessages override AND a missing-base RegisterMessageHandlers, the // opt-out path wins: exactly ONE DXMSG008 fires (because the underlying check would - // have produced a DXMSG006 diagnostic at *some* severity — would-have-fired counts as + // have produced a DXMSG006 diagnostic at *some* severity; would-have-fired counts as // needing suppression), and the smart-case Info-lowering path is bypassed entirely. string source = """ namespace Sample @@ -1257,7 +1257,7 @@ private void OnEnable() { } public void GenericMethodWithGuardedNameDoesNotFireDxmsg009() { // C# does NOT emit CS0114 for `void Awake()` because the type-parameter arity differs - // from the base; both methods coexist. DXMSG009 must not fire either — flagging it would + // from the base; both methods coexist. DXMSG009 must not fire either; flagging it would // be a false positive misleading the user toward an incorrect "fix". string source = """ namespace Sample @@ -1278,7 +1278,7 @@ private void Awake() { } public void ExpressionBodiedNonOverrideOnEnableEmitsDxmsg009() { // Expression-bodied form of the implicit-hide pattern. The method is parameter-less, - // returns void, non-static, and has no override/new modifier — so DXMSG009 fires. + // returns void, non-static, and has no override/new modifier; so DXMSG009 fires. string source = """ namespace Sample { @@ -1620,7 +1620,7 @@ protected override void Awake() AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); - // The emitted message must contain the dot-form of the nested FQN — that is the form + // The emitted message must contain the dot-form of the nested FQN; that is the form // the harvester ingests and keys the snapshot by. Assert.That(dxmsg006.GetMessage(), Does.Contain("Sample.Outer.Nested")); Assert.That(dxmsg006.GetMessage(), Does.Not.Contain("Outer+Nested")); @@ -1632,7 +1632,7 @@ protected override void Awake() public void BrokenIntermediateAncestorEmitsDxmsg010OnDescendant() { // The exact user-reported case. `BrokenThing.OnEnable` correctly calls base.OnEnable(), - // but the inherited override on `ddd` has an empty body — so the chain stops at `ddd` + // but the inherited override on `ddd` has an empty body; so the chain stops at `ddd` // and never reaches `MessageAwareComponent.OnEnable`. DXMSG006 fires on `ddd`; DXMSG010 // fires on `BrokenThing` so the user editing `BrokenThing` is told the chain is broken. string source = """ @@ -1640,7 +1640,7 @@ namespace Sample { public class ddd : DxMessaging.Unity.MessageAwareComponent { - // Field included in the user's literal report shape — exercised here for fidelity. + // Field included in the user's literal report shape; exercised here for fidelity. public int a; protected override void OnEnable() { } } @@ -1730,7 +1730,7 @@ protected override void OnEnable() [Test] public void HealthyChainEmitsNoDiagnostics() { - // Sanity: when every override correctly calls base, no diagnostics fire — the chain + // Sanity: when every override correctly calls base, no diagnostics fire; the chain // walk must not produce false positives on a clean inheritance graph. string source = """ namespace Sample @@ -1906,7 +1906,7 @@ protected override void OnEnable() public void Dxmsg010StillFiresAtWarningEvenWhenSmartCaseLowersDxmsg006OnAncestor() { // The smart-case lowering (literal `RegisterForStringMessages => false`) takes DXMSG006 - // on `ddd.RegisterMessageHandlers` from Warning to Info — but the chain is GENUINELY + // on `ddd.RegisterMessageHandlers` from Warning to Info; but the chain is GENUINELY // broken from BrokenThing's perspective. DXMSG010 must still fire at Warning on // BrokenThing: smart-case is a per-method per-class courtesy, descendants still need // the chain to be unbroken. @@ -1955,8 +1955,8 @@ protected override void RegisterMessageHandlers() [Test] public void Dxmsg010MessageMentionsBrokenAncestorTypeName() { - // The DXMSG010 message must include the FQN of the broken ancestor — not a generic - // "an ancestor" placeholder — so the user knows exactly where the chain is broken. + // The DXMSG010 message must include the FQN of the broken ancestor; not a generic + // "an ancestor" placeholder; so the user knows exactly where the chain is broken. string source = """ namespace Sample { @@ -2015,7 +2015,7 @@ protected override void OnEnable() .GetSubText(dxmsg010.Location.SourceSpan) .ToString(); Assert.That(spanText, Is.EqualTo("OnEnable")); - // Sanity: the source span for DXMSG010 must NOT point inside `ddd` — confirm by + // Sanity: the source span for DXMSG010 must NOT point inside `ddd`; confirm by // verifying the surrounding source contains "BrokenThing" (the descendant) within a // small window around the span. string fullText = dxmsg010.Location.SourceTree.GetText().ToString(); @@ -2031,7 +2031,7 @@ protected override void OnEnable() public void Dxmsg008AttributeAndIgnoreListBothPresentFiresOnce() { // Adversarial: BOTH the class-level [DxIgnoreMissingBaseCall] attribute AND the project - // ignore list claim Sample.Player. The opt-out path must coalesce — exactly ONE DXMSG008 + // ignore list claim Sample.Player. The opt-out path must coalesce; exactly ONE DXMSG008 // is emitted for the offending method, not two competing entries (one per opt-out source). string source = """ namespace Sample @@ -2061,7 +2061,7 @@ protected override void Awake() public void Dxmsg008MethodAndClassAttributeBothPresentFiresOnce() { // Adversarial: BOTH the class-level AND method-level [DxIgnoreMissingBaseCall] are set. - // Exactly ONE DXMSG008 should fire — the opt-out is binary, so duplicate sources do not + // Exactly ONE DXMSG008 should fire; the opt-out is binary, so duplicate sources do not // duplicate the diagnostic. string source = """ namespace Sample @@ -2089,8 +2089,8 @@ protected override void Awake() public void Dxmsg008ClassAttributeWithMixedCleanAndDirtyMethodsOnlyFiresForDirty() { // The class is opted out via [DxIgnoreMissingBaseCall]; one method is broken (would emit - // DXMSG006), another method is clean (calls base). DXMSG008 must fire EXACTLY ONCE — on - // the would-have-fired method only — and the clean method must not produce noise. + // DXMSG006), another method is clean (calls base). DXMSG008 must fire EXACTLY ONCE; on + // the would-have-fired method only; and the clean method must not produce noise. string source = """ namespace Sample { @@ -2143,7 +2143,7 @@ public void Dxmsg010DoesNotFireWhenAncestorHasSmartCaseAndCallsBaseCorrectly() { // Spec 1b (clean variant): ancestor has literal `RegisterForStringMessages => false` AND // its RegisterMessageHandlers correctly calls base. Descendant overrides and calls base. - // The chain is genuinely clean — DXMSG010 must NOT fire (and DXMSG006 must NOT fire). + // The chain is genuinely clean; DXMSG010 must NOT fire (and DXMSG006 must NOT fire). string source = """ namespace Sample { @@ -2216,7 +2216,7 @@ protected override void OnEnable() ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); - // ddd has DXMSG006. Inner and Leaf both have DXMSG010 — chain dies at ddd. + // ddd has DXMSG006. Inner and Leaf both have DXMSG010; chain dies at ddd. AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); Assert.That( diagnostics.Single(d => d.Id == "DXMSG006").GetMessage(), @@ -2237,8 +2237,8 @@ protected override void OnEnable() [Test] public void TernaryReturningFalseOnRegisterForStringMessagesDoesNotApplySmartCase() { - // Spec 1d: smart-case lowering applies ONLY for a literal `false`. A ternary expression — - // even one that always evaluates to false at runtime — must NOT lower DXMSG006 to Info. + // Spec 1d: smart-case lowering applies ONLY for a literal `false`. A ternary expression; + // even one that always evaluates to false at runtime; must NOT lower DXMSG006 to Info. // The analyzer's literal-shape check is syntactic; runtime evaluation is irrelevant. string source = """ namespace Sample @@ -2264,7 +2264,7 @@ protected override void RegisterMessageHandlers() [Test] public void IsFalsePatternOnRegisterForStringMessagesDoesNotApplySmartCase() { - // Spec 1d: an `is false` pattern is not a literal-false return — smart-case must not apply. + // Spec 1d: an `is false` pattern is not a literal-false return; smart-case must not apply. string source = """ namespace Sample { @@ -2290,7 +2290,7 @@ protected override void RegisterMessageHandlers() public void SwitchExpressionReturningFalseOnRegisterForStringMessagesDoesNotApplySmartCase() { // Spec 1d: a switch expression whose only arm returns literal false is still NOT a literal - // false return — smart-case must not apply. + // false return; smart-case must not apply. string source = """ namespace Sample { @@ -2310,4 +2310,172 @@ protected override void RegisterMessageHandlers() AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); } + + // ------------------------------------------------------------------------------------------ + // Phase D: positive (no-warning-noise) coverage of [DxIgnoreMissingBaseCall]. + // + // Contract pinned (NOT brief's "ZERO diagnostics"): opt-out via [DxIgnoreMissingBaseCall] + // suppresses DXMSG006/007/009/010 entirely; the analyzer instead emits DXMSG008 (Info) on + // each method that WOULD otherwise have fired, so the user can still see the suppression is + // active during build. Clean methods on opted-out classes produce zero diagnostics. + // ------------------------------------------------------------------------------------------ + + [Test] + public void IgnoreMissingBaseCallAttributeAtClassScopeSuppressesAllGuardedMethods() + { + // Class-level attribute on a class that overrides EVERY guarded method without calling + // base. Expect: no DXMSG006/007/009/010, and one DXMSG008 (Info) per dirty override. + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() { int a = 0; } + protected override void OnEnable() { int b = 0; } + protected override void OnDisable() { int c = 0; } + protected override void OnDestroy() { int d = 0; } + protected override void RegisterMessageHandlers() { int e = 0; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG009"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG010"), Is.Empty); + + Diagnostic[] dxmsg008 = diagnostics.Where(d => d.Id == "DXMSG008").ToArray(); + Assert.That( + dxmsg008, + Has.Length.EqualTo(5), + "Class-level [DxIgnoreMissingBaseCall] should produce one DXMSG008 per dirty override." + ); + string[] spans = dxmsg008 + .Select(d => + d.Location.SourceTree!.GetText().GetSubText(d.Location.SourceSpan).ToString() + ) + .ToArray(); + Assert.That( + spans, + Is.EquivalentTo( + new[] { "Awake", "OnEnable", "OnDisable", "OnDestroy", "RegisterMessageHandlers" } + ) + ); + foreach (Diagnostic d in dxmsg008) + { + Assert.That(d.Severity, Is.EqualTo(DiagnosticSeverity.Info)); + Assert.That(d.GetMessage(), Does.Contain("[DxIgnoreMissingBaseCall]")); + } + } + + [Test] + public void IgnoreMissingBaseCallAttributeAtMethodScopeSuppressesOnlyAnnotatedMethod() + { + // Class is NOT opted out; ONE method has the attribute. The annotated method must be + // exempt (DXMSG008 Info), the others must still fire DXMSG006. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + protected override void Awake() { int a = 0; } + + protected override void OnEnable() { int b = 0; } + protected override void OnDisable() { int c = 0; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + // The annotated `Awake` becomes DXMSG008 (Info). The two unannotated methods stay as + // DXMSG006 (Warning). + Diagnostic[] dxmsg008 = diagnostics.Where(d => d.Id == "DXMSG008").ToArray(); + Assert.That(dxmsg008, Has.Length.EqualTo(1)); + Assert.That( + dxmsg008[0] + .Location.SourceTree!.GetText() + .GetSubText(dxmsg008[0].Location.SourceSpan) + .ToString(), + Is.EqualTo("Awake") + ); + + Diagnostic[] dxmsg006 = diagnostics.Where(d => d.Id == "DXMSG006").ToArray(); + Assert.That( + dxmsg006, + Has.Length.EqualTo(2), + "Method-level opt-out must NOT suppress DXMSG006 on its sibling overrides." + ); + string[] dxmsg006Spans = dxmsg006 + .Select(d => + d.Location.SourceTree!.GetText().GetSubText(d.Location.SourceSpan).ToString() + ) + .ToArray(); + Assert.That(dxmsg006Spans, Is.EquivalentTo(new[] { "OnEnable", "OnDisable" })); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG009"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG010"), Is.Empty); + } + + [Test] + public void IgnoreMissingBaseCallAttributeAtMethodScopeOnHiddenWithNewSuppressesDxmsg007() + { + // Method-level [DxIgnoreMissingBaseCall] on a `new`-hidden guarded method must downgrade + // the would-be DXMSG007 to a single DXMSG008 Info, with no other diagnostics. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + protected new void Awake() { int x = 0; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + Diagnostic dxmsg008 = diagnostics.Single(d => d.Id == "DXMSG008"); + Assert.That( + dxmsg008 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg008.Location.SourceSpan) + .ToString(), + Is.EqualTo("Awake") + ); + Assert.That(dxmsg008.GetMessage(), Does.Contain("[DxIgnoreMissingBaseCall]")); + } + + [Test] + public void IgnoreMissingBaseCallAttributeAtClassScopeOnCleanOverridesProducesZeroDiagnostics() + { + // Belt-and-braces: class-level opt-out plus EVERY override calls base. No would-be + // DXMSG006/007/009/010 means no DXMSG008 either; clean overrides on opted-out classes + // must produce zero noise (matches the IgnoreAttributeOnClassEmitsDxmsg008Only contract). + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() { base.Awake(); } + protected override void OnEnable() { base.OnEnable(); } + protected override void OnDisable() { base.OnDisable(); } + protected override void OnDestroy() { base.OnDestroy(); } + protected override void RegisterMessageHandlers() { base.RegisterMessageHandlers(); } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } } diff --git a/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs b/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs index 80f9e2a3..c8a01246 100644 --- a/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs +++ b/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs @@ -81,7 +81,7 @@ public void FallbackEditorMustRegisterAsPrimaryNonFallbackEditorForChildClasses( // The [CustomEditor] attribute MUST register this editor as a PRIMARY (non-fallback) // editor for every MessageAwareComponent subclass. Earlier attempts to use // isFallback = true caused Unity to skip our editor entirely and pick GenericInspector - // instead — which dropped the missing-base-call HelpBox warnings on every component + // instead; which dropped the missing-base-call HelpBox warnings on every component // because Unity 2021's Editor.finishedDefaultHeaderGUI hook does not reliably fire for // MonoBehaviour subclasses that have no registered [CustomEditor]. // @@ -91,7 +91,7 @@ public void FallbackEditorMustRegisterAsPrimaryNonFallbackEditorForChildClasses( // every MonoBehaviour shows). There is no missing row to leave a gap. // // CustomEditor.isFallback has been a public field on UnityEditor.CustomEditor since - // at least Unity 2017.2 — we read it directly without reflection. The contract: + // at least Unity 2017.2; we read it directly without reflection. The contract: // isFallback MUST be false (default), editorForChildClasses MUST be true. Type fallbackType = typeof(MessageAwareComponentFallbackEditor); object[] attributes = fallbackType.GetCustomAttributes( @@ -108,7 +108,7 @@ public void FallbackEditorMustRegisterAsPrimaryNonFallbackEditorForChildClasses( Assert.That( customEditor.isFallback, Is.False, - "MessageAwareComponentFallbackEditor must register with isFallback = false (the default). Setting isFallback = true causes Unity to prefer GenericInspector for every MessageAwareComponent subclass, which silently drops the missing-base-call HelpBox warnings — the regression this test was added to prevent." + "MessageAwareComponentFallbackEditor must register with isFallback = false (the default). Setting isFallback = true causes Unity to prefer GenericInspector for every MessageAwareComponent subclass, which silently drops the missing-base-call HelpBox warnings; the regression this test was added to prevent." ); FieldInfo editorForChildClassesField = typeof(CustomEditor).GetField( @@ -118,7 +118,7 @@ public void FallbackEditorMustRegisterAsPrimaryNonFallbackEditorForChildClasses( Assert.That( editorForChildClassesField, Is.Not.Null, - "Unity's CustomEditor.m_EditorForChildClasses field is missing — Unity may have renamed the field; update this test." + "Unity's CustomEditor.m_EditorForChildClasses field is missing; Unity may have renamed the field; update this test." ); bool editorForChildClasses = (bool)editorForChildClassesField.GetValue(customEditor); Assert.That( @@ -146,7 +146,7 @@ public void FallbackEditorIsSelectedForSubclassWithoutCustomEditor() Assert.That( editor, Is.Not.Null, - "Editor.CreateEditor returned null for the empty subclass — Unity could not resolve any editor." + "Editor.CreateEditor returned null for the empty subclass; Unity could not resolve any editor." ); Assert.That( editor, diff --git a/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs b/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs new file mode 100644 index 00000000..060a34f4 --- /dev/null +++ b/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs @@ -0,0 +1,749 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Benchmarks +{ + using System; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + + /// + /// Locks in the zero-GC dispatch contract across the full register / emit / + /// deregister surface. Each test below is a row in the allocation matrix + /// that the upcoming GC and performance work depends on; a regression in any + /// row will surface here before it lands in user-visible benchmarks. + /// + /// + /// + /// All tests in this fixture are tagged [Category("Allocation")] so + /// they can be filtered out of the <1-min default suite. The fixture + /// intentionally builds emit closures once outside the assertion zone so + /// the closure-creation cost itself is not measured. Each test owns a + /// dedicated instance to keep registrations from + /// leaking across rows; the global static bus is left untouched. + /// + /// + /// Cross-product reduction. The matrix exercises EACH axis (kind, + /// interceptor presence, post-processor presence, diagnostics on/off, + /// multi-priority) independently. The full Cartesian product is intentionally + /// not tested because: (a) the test count would explode (3 kinds x 2 + /// interceptor x 2 post-processor x 2 diagnostics x 3 priority = 72 + /// permutations); (b) interaction effects are covered by + /// , a single combinatorial test + /// that exercises the realistic production setup (interceptor + + /// post-processor + multi-priority handler chain); and (c) any specific + /// interaction surfaced by Phase D / E adversarial work can be added later + /// as a focused row without re-running the full Cartesian sweep. + /// + /// + [Category("Allocation")] + public sealed class AllocationMatrixTests : BenchmarkTestBase + { + private const int WarmupRegistrationCycles = 100; + + /// + /// Number of warm emit cycles run before measurement begins on the + /// diagnostic emission path. The diagnostic pipeline records each + /// emission in a fixed-capacity + /// whose underlying + /// allocates only while growing toward + /// . Pre-emitting + /// twice the buffer size guarantees we are well past the growth phase + /// and that subsequent Add calls overwrite in place. + /// + private const int DiagnosticsEmitWarmupMultiplier = 2; + + /// + /// Cumulative allocation budget for the diagnostics-enabled emit path + /// measured by GC.GetAllocatedBytesForCurrentThread (which is + /// thread-cumulative and unaffected by interim collections) across + /// (32) + /// consecutive emissions after the cyclic buffer reaches steady state. + /// The diagnostics path captures a stack trace per emit (see + /// MessageEmissionData.GetAccurateStackTrace), which is + /// fundamentally allocating in the current design - Unity's + /// StackTraceUtility.ExtractStackTrace returns a fresh string + /// (typically 1-4 KB), String.Split produces a new array plus + /// per-line substrings, and the LINQ filter plus String.Join + /// each materialize additional managed objects. Empirically the steady + /// state runs ~4-10 KB per emit, so 32 emits land in the 128-320 KB + /// range. The budget below sets a per-iteration ceiling + /// ( bytes) and multiplies by + /// the iteration count; a real regression (e.g. an unbounded list + /// growth or per-frame buffer churn) will breach the ceiling. + /// + private const long MaxBytesPerDiagnosticsEmit = 32 * 1024L; + private const long PerEmitDiagnosticsByteBudget = + MaxBytesPerDiagnosticsEmit * AllocationAssertions.DefaultMeasuredIterations; + + /// + /// Per-call allocation budget for a single registration after warm-up. + /// Estimated cost: a closure object capturing the local function (24-48 + /// bytes including object header and captured fields), the produced + /// delegate (~64 bytes), and a dictionary entry in the registration + /// table (~32 bytes). Total expected cost is roughly 120-200 bytes per + /// registration; we set the budget to a 2-3x ceiling so incidental + /// runtime behaviour does not flake the test while a genuine + /// regression (e.g. an extra array allocation) still trips it. + /// + private const long PerRegistrationByteBudget = 512L; + + /// + /// Per-call allocation budget for a single deregistration after + /// warm-up. Deregistration is dictionary-remove plus delegate cleanup + /// and is expected to be cheaper than registration (no closure + /// creation). The budget here is half of + /// with the same 2-3x slack + /// philosophy applied. + /// + private const long PerDeregistrationByteBudget = 256L; + + /// + /// Per-call allocation budget for a single registration on the + /// diagnostics-augmented path. The closure inside + /// (lines ~106-114) wraps the + /// user handler with diagnostics bookkeeping regardless of the + /// diagnostics flag, so this budget is the regular registration cost + /// () plus an extra 50% slack + /// to cover the augmented closure's captured state. + /// + private const long PerAugmentedRegistrationByteBudget = 768L; + + // The InstanceId values below are arbitrary 32-bit integers that + // distinguish the targeted/source/owner participants from each other + // and from any production-style ids. Tests run on isolated + // MessageBus instances so collisions with other tests are not + // possible. + private static readonly InstanceId StableTarget = new InstanceId(0x5757_5757); + private static readonly InstanceId StableSource = new InstanceId(0x4242_4242); + private static readonly InstanceId HandlerOwner = new InstanceId(0x6363_6363); + + private DiagnosticsTarget _savedGlobalDiagnostics; + private Action _savedLogFunction; + + protected override bool MessagingDebugEnabled => false; + + [SetUp] + public void CaptureDiagnosticsState() + { + _savedGlobalDiagnostics = IMessageBus.GlobalDiagnosticsTargets; + _savedLogFunction = MessagingDebug.LogFunction; + // Stray Debug.Log calls would allocate strings and contaminate the + // assertion. Mute the messaging logger for the duration of the + // fixture and restore it in TearDown. + MessagingDebug.LogFunction = null; + } + + [TearDown] + public void RestoreDiagnosticsState() + { + IMessageBus.GlobalDiagnosticsTargets = _savedGlobalDiagnostics; + MessagingDebug.LogFunction = _savedLogFunction; + } + + /// + /// Pins zero-allocation emission for the bare register-one-handler-then-emit + /// path across all three message kinds. Closure under measurement is built + /// once with stable captures so its allocation does not pollute the result. + /// + [Test] + [Category("Allocation")] + public void EmitIsZeroAlloc( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterHandler(scenario, token); + AllocationAssertions.AssertNoAllocations($"Emit-{scenario.Kind}", emit); + } + ); + } + + /// + /// Pins zero-allocation emission across both interceptor-present and + /// interceptor-absent rows. The scenario flag drives whether an + /// allowing interceptor is registered, so this single test covers both + /// halves of the interceptor axis (doubling coverage relative to a + /// dedicated interceptor-on test) without paying the cost of the full + /// Cartesian product. + /// + [Test] + [Category("Allocation")] + public void EmitIsZeroAllocAcrossInterceptorPresence( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.WithAndWithoutInterceptor) + )] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterHandler(scenario, token); + if (scenario.UseInterceptor) + { + RegisterAllowingInterceptor(scenario, token); + } + string suffix = scenario.UseInterceptor ? "On" : "Off"; + AllocationAssertions.AssertNoAllocations( + $"Emit+Interceptor{suffix}-{scenario.Kind}", + emit + ); + } + ); + } + + /// + /// Pins zero-allocation emission across both post-processor-present + /// and post-processor-absent rows. The scenario flag drives whether a + /// post-processor is registered, so this single test covers both + /// halves of the post-processor axis. + /// + [Test] + [Category("Allocation")] + public void EmitIsZeroAllocAcrossPostProcessorPresence( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.WithAndWithoutPostProcessor) + )] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterHandler(scenario, token); + if (scenario.UsePostProcessor) + { + RegisterPostProcessor(scenario, token); + } + string suffix = scenario.UsePostProcessor ? "On" : "Off"; + AllocationAssertions.AssertNoAllocations( + $"Emit+PostProcessor{suffix}-{scenario.Kind}", + emit + ); + } + ); + } + + /// + /// Pins a bounded-allocation steady state on the diagnostics-enabled + /// emit path. The cyclic emission buffer's + /// backing grows + /// only while filling toward + /// , so the per-slot + /// list churn is one-shot. The unavoidable allocator is the + /// per-emission stack-trace capture inside + /// MessageEmissionData.GetAccurateStackTrace: Unity's + /// StackTraceUtility.ExtractStackTrace returns a fresh string, + /// String.Split produces a new array, the LINQ filter + /// materializes another array, and String.Join rebuilds the + /// string. The contract is therefore "bounded", not "zero": after + /// the prewarm loop we measure 32 emits as one batch and assert the + /// observed allocation falls within + /// . + /// + [Test] + [Category("Allocation")] + public void EmitWithDiagnosticsEnabledIsBoundedAlloc( + [ValueSource(typeof(AllocationMatrixTests), nameof(DiagnosticsOnScenarios))] + MessageScenario scenario + ) + { + IMessageBus.GlobalDiagnosticsTargets = DiagnosticsTarget.All; + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterHandler(scenario, token); + + // Pre-warm the cyclic emission buffer to its capacity so + // the underlying List stops growing. After this loop + // every subsequent Add overwrites a slot in place. The + // 2x multiplier is defensive in case capacity changes in + // future or another path also needs to flush. + int prewarmCycles = + IMessageBus.GlobalMessageBufferSize * DiagnosticsEmitWarmupMultiplier; + if (prewarmCycles < 1) + { + prewarmCycles = 1; + } + for (int i = 0; i < prewarmCycles; ++i) + { + emit(); + } + + // GC.GetTotalMemory measures live heap, not cumulative + // allocation, so a Gen-0 collection mid-loop would silently + // hide allocation pressure (and the test would flake-pass). + // GC.GetAllocatedBytesForCurrentThread is monotonic and + // unaffected by collections, so it accurately captures + // cumulative allocation across the measured window. + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < AllocationAssertions.DefaultMeasuredIterations; ++i) + { + emit(); + } + long after = GC.GetAllocatedBytesForCurrentThread(); + long delta = after - before; + long perEmit = delta / AllocationAssertions.DefaultMeasuredIterations; + Assert.That( + delta, + Is.LessThanOrEqualTo(PerEmitDiagnosticsByteBudget), + $"EmitDiagnostics-{scenario.Kind} allocated {delta} bytes " + + $"({perEmit} avg/emit) across " + + $"{AllocationAssertions.DefaultMeasuredIterations} emissions, " + + $"exceeding the {PerEmitDiagnosticsByteBudget}-byte " + + $"diagnostics budget (" + + $"{PerEmitDiagnosticsByteBudget / AllocationAssertions.DefaultMeasuredIterations}" + + " avg/emit)." + ); + } + ); + } + + /// + /// Stresses the priority-bucket dispatch path with three handlers at + /// distinct priorities and pins that emission remains zero-allocation. + /// + [Test] + [Category("Allocation")] + public void EmitWithMultiplePrioritiesIsZeroAlloc( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterHandler(scenario, token, priority: 0); + RegisterHandler(scenario, token, priority: 5); + RegisterHandler(scenario, token, priority: 10); + AllocationAssertions.AssertNoAllocations( + $"Emit+Priorities-{scenario.Kind}", + emit + ); + } + ); + } + + /// + /// Single combinatorial row that pins zero-allocation emission for the + /// realistic "production" stack: an allowing interceptor, multiple + /// handlers at distinct priorities, and multiple post-processors at + /// distinct priorities. Covers interaction effects between axes that + /// the per-axis tests above do not exercise. Diagnostics is + /// intentionally left off here because + /// already pins + /// that axis. + /// + [Test] + [Category("Allocation")] + public void EmitWithFullStackIsZeroAlloc() + { + // Untargeted is the cheapest dispatch and the most common in + // production code; using a single kind keeps the combinatorial + // surface small while still exercising the full handler chain. + MessageScenario scenario = MessageScenario.Untargeted(); + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterHandler(scenario, token, priority: 0); + RegisterHandler(scenario, token, priority: 5); + RegisterHandler(scenario, token, priority: 10); + RegisterAllowingInterceptor(scenario, token); + RegisterPostProcessor(scenario, token); + RegisterPostProcessor(scenario, token); + AllocationAssertions.AssertNoAllocations( + $"EmitFullStack-{scenario.Kind}", + emit + ); + } + ); + } + + /// + /// Pins the per-registration allocation cost when diagnostics are enabled. + /// The diagnostic closure that wraps user handlers is created at + /// registration time inside + /// regardless of the diagnostics + /// flag (the closure body branches on _diagnosticMode), so this + /// test treats the cost as a budget rather than a hard zero. Expected + /// cost: a small constant (delegate + closure-state object + dictionary + /// entry) per registration. Threshold: + /// bytes; a regression + /// past that bound indicates a new per-registration allocation. + /// + [Test] + [Category("Allocation")] + public void DiagnosticsAugmentedHandlerAllocationCostIsBounded() + { + IMessageBus.GlobalDiagnosticsTargets = DiagnosticsTarget.All; + + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + try + { + token.Enable(); + + // Warm: create and tear down a few registrations so the dictionaries + // and pools used by the registration path are sized for steady state. + for (int i = 0; i < WarmupRegistrationCycles; ++i) + { + MessageRegistrationHandle warm = + token.RegisterUntargeted(NoOpUntargeted); + token.RemoveRegistration(warm); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetTotalMemory(forceFullCollection: false); + MessageRegistrationHandle measured = + token.RegisterUntargeted(NoOpUntargeted); + long after = GC.GetTotalMemory(forceFullCollection: false); + long delta = after - before; + token.RemoveRegistration(measured); + + Assert.That( + delta, + Is.LessThanOrEqualTo(PerAugmentedRegistrationByteBudget), + $"Diagnostic registration allocated {delta} bytes; " + + $"budget is {PerAugmentedRegistrationByteBudget} bytes. " + + "If this assertion regresses, inspect MessageRegistrationToken " + + "lines ~106-114 (augmented handler closure) before relaxing the bound." + ); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + /// + /// Pins the per-registration allocation cost in steady state across all + /// kinds. The registration path uses dictionaries that grow on first + /// fill; the warm-up cycles below pre-grow them, so the measured + /// registration only pays for the new entries. The budget is set + /// generously ( bytes) to + /// absorb the expected delegate + closure + dictionary-entry + /// allocations without flaking on incidental runtime behaviour. + /// + [Test] + [Category("Allocation")] + public void RegisterIsZeroAllocSteadyState( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + for (int i = 0; i < WarmupRegistrationCycles; ++i) + { + MessageRegistrationHandle warm = RegisterHandler(scenario, token); + token.RemoveRegistration(warm); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetTotalMemory(forceFullCollection: false); + MessageRegistrationHandle measured = RegisterHandler(scenario, token); + long after = GC.GetTotalMemory(forceFullCollection: false); + long delta = after - before; + token.RemoveRegistration(measured); + + Assert.That( + delta, + Is.LessThanOrEqualTo(PerRegistrationByteBudget), + $"Register-{scenario.Kind} allocated {delta} bytes after warm-up; " + + $"budget is {PerRegistrationByteBudget} bytes." + ); + } + ); + } + + /// + /// Pins the per-deregistration allocation cost in steady state. After + /// warm-up the deregistration path should not allocate anything beyond + /// dictionary-remove churn; the budget + /// ( bytes) is half the + /// registration budget because there is no closure construction on + /// this path. + /// + [Test] + [Category("Allocation")] + public void DeregisterIsZeroAllocSteadyState( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + for (int i = 0; i < WarmupRegistrationCycles; ++i) + { + MessageRegistrationHandle warm = RegisterHandler(scenario, token); + token.RemoveRegistration(warm); + } + + MessageRegistrationHandle measured = RegisterHandler(scenario, token); + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetTotalMemory(forceFullCollection: false); + token.RemoveRegistration(measured); + long after = GC.GetTotalMemory(forceFullCollection: false); + long delta = after - before; + + Assert.That( + delta, + Is.LessThanOrEqualTo(PerDeregistrationByteBudget), + $"Deregister-{scenario.Kind} allocated {delta} bytes after warm-up; " + + $"budget is {PerDeregistrationByteBudget} bytes." + ); + } + ); + } + + public static IEnumerable DiagnosticsOnScenarios + { + get + { + foreach (MessageScenario scenario in MessageScenarios.WithDiagnosticsToggle) + { + if (scenario.DiagnosticsEnabled) + { + yield return scenario; + } + } + } + } + + private static void NoOpUntargeted(ref SimpleUntargetedMessage message) { } + + private static void NoOpTargeted(ref SimpleTargetedMessage message) { } + + private static void NoOpBroadcast(ref SimpleBroadcastMessage message) { } + + private static bool AllowUntargeted(ref SimpleUntargetedMessage message) + { + return true; + } + + private static bool AllowTargeted(ref InstanceId target, ref SimpleTargetedMessage message) + { + return true; + } + + private static bool AllowBroadcast( + ref InstanceId source, + ref SimpleBroadcastMessage message + ) + { + return true; + } + + private void RunWithFreshHarness( + MessageScenario scenario, + Action body + ) + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (body == null) + { + throw new ArgumentNullException(nameof(body)); + } + + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + try + { + token.Enable(); + body(token, bus); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + private static MessageRegistrationHandle RegisterHandler( + MessageScenario scenario, + MessageRegistrationToken token, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + NoOpUntargeted, + priority: priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + StableTarget, + NoOpTargeted, + priority: priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + StableSource, + NoOpBroadcast, + priority: priority + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static MessageRegistrationHandle RegisterAllowingInterceptor( + MessageScenario scenario, + MessageRegistrationToken token + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + AllowUntargeted + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + AllowTargeted + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + AllowBroadcast + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static MessageRegistrationHandle RegisterPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedPostProcessor( + scenario, + token, + NoOpUntargeted + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedPostProcessor( + scenario, + token, + StableTarget, + NoOpTargeted + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastPostProcessor( + scenario, + token, + StableSource, + NoOpBroadcast + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static Action BuildEmitClosure(MessageScenario scenario, IMessageBus bus) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage untargeted = new SimpleUntargetedMessage(); + return () => untargeted.EmitUntargeted(bus); + } + case MessageKind.Targeted: + { + SimpleTargetedMessage targeted = new SimpleTargetedMessage(); + InstanceId target = StableTarget; + return () => targeted.EmitTargeted(target, bus); + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage broadcast = new SimpleBroadcastMessage(); + InstanceId source = StableSource; + return () => broadcast.EmitBroadcast(source, bus); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/UntargetedTests.cs.meta b/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs.meta similarity index 83% rename from Tests/Runtime/Core/UntargetedTests.cs.meta rename to Tests/Runtime/Benchmarks/AllocationMatrixTests.cs.meta index d83c3d1a..9c4e3e63 100644 --- a/Tests/Runtime/Core/UntargetedTests.cs.meta +++ b/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0eba64d6fac2b74499ec5f8fb487c4c6 +guid: 79706c4ba5c54e9587755b93da4aa07e MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs b/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs index 4a0c52ce..277b7c6d 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs +++ b/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs @@ -13,6 +13,7 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using UnityEngine; using UnityEngine.TestTools; + [Category("Performance")] public sealed class BenchmarkHarnessRobustnessTests : BenchmarkTestBase { [TestCase("untargeted")] diff --git a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs b/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs index d46affab..26daa464 100644 --- a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs +++ b/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs @@ -20,6 +20,7 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using Zenject; #endif + [Category("Performance")] public sealed class ComparisonPerformanceTests : BenchmarkTestBase { protected override bool MessagingDebugEnabled => false; diff --git a/Tests/Runtime/Benchmarks/PerformanceTests.cs b/Tests/Runtime/Benchmarks/PerformanceTests.cs index 0475048e..c77c9450 100644 --- a/Tests/Runtime/Benchmarks/PerformanceTests.cs +++ b/Tests/Runtime/Benchmarks/PerformanceTests.cs @@ -13,6 +13,7 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using UnityEngine.TestTools.Constraints; using Is = NUnit.Framework.Is; + [Category("Performance")] public sealed class PerformanceTests : BenchmarkTestBase { protected override bool MessagingDebugEnabled => false; diff --git a/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs b/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs index 29869beb..b08df9db 100644 --- a/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs +++ b/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs @@ -8,6 +8,7 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using DxMessaging.Core.Messages; using NUnit.Framework; + [Category("Performance")] public sealed class ProviderResolutionBenchmarks : BenchmarkTestBase { private IMessageBus _originalBus; diff --git a/Tests/Runtime/Core/BroadcastTests.cs b/Tests/Runtime/Core/EmitBroadcastSpecificTests.cs similarity index 98% rename from Tests/Runtime/Core/BroadcastTests.cs rename to Tests/Runtime/Core/EmitBroadcastSpecificTests.cs index b6cb1a73..558de4ba 100644 --- a/Tests/Runtime/Core/BroadcastTests.cs +++ b/Tests/Runtime/Core/EmitBroadcastSpecificTests.cs @@ -13,7 +13,16 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine; using UnityEngine.TestTools; - public sealed class BroadcastTests : MessagingTestBase + /// + /// Broadcast-only emission tests preserved verbatim from the original + /// BroadcastTests.cs. Logic that is provably common across all three + /// message kinds is consolidated in ; the tests in + /// this file remain kind-specific because their assertion semantics + /// (per-source routing, GameObject vs Component source, broadcast-without- + /// source fan-out, dual-mode counts) do not translate cleanly to untargeted + /// dispatch. + /// + public sealed class EmitBroadcastSpecificTests : MessagingTestBase { [UnityTest] public IEnumerator SimpleGameObjectBroadcastNormal() diff --git a/Tests/Runtime/Core/BroadcastTests.cs.meta b/Tests/Runtime/Core/EmitBroadcastSpecificTests.cs.meta similarity index 83% rename from Tests/Runtime/Core/BroadcastTests.cs.meta rename to Tests/Runtime/Core/EmitBroadcastSpecificTests.cs.meta index 0885f883..f44bcb27 100644 --- a/Tests/Runtime/Core/BroadcastTests.cs.meta +++ b/Tests/Runtime/Core/EmitBroadcastSpecificTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 180aacfaf36946e4d900a22bbe8a6d80 +guid: 3e0e8aa2c0c801737285491bba307fda MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Core/TargetedTests.cs b/Tests/Runtime/Core/EmitTargetedSpecificTests.cs similarity index 98% rename from Tests/Runtime/Core/TargetedTests.cs rename to Tests/Runtime/Core/EmitTargetedSpecificTests.cs index 637b2d0d..c09044f0 100644 --- a/Tests/Runtime/Core/TargetedTests.cs +++ b/Tests/Runtime/Core/EmitTargetedSpecificTests.cs @@ -12,7 +12,16 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine; using UnityEngine.TestTools; - public sealed class TargetedTests : MessagingTestBase + /// + /// Targeted-only emission tests preserved verbatim from the original + /// TargetedTests.cs. Logic that is provably common across all three + /// message kinds is consolidated in ; the tests in + /// this file remain kind-specific because their assertion semantics + /// (per-target routing, GameObject vs Component target, targeted-without- + /// targeting fan-out, dual-mode counts) do not translate cleanly to + /// untargeted dispatch. + /// + public sealed class EmitTargetedSpecificTests : MessagingTestBase { [UnityTest] public IEnumerator SimpleGameObjectTargetedNormal() diff --git a/Tests/Runtime/Core/TargetedTests.cs.meta b/Tests/Runtime/Core/EmitTargetedSpecificTests.cs.meta similarity index 83% rename from Tests/Runtime/Core/TargetedTests.cs.meta rename to Tests/Runtime/Core/EmitTargetedSpecificTests.cs.meta index 543f9519..723fcf7e 100644 --- a/Tests/Runtime/Core/TargetedTests.cs.meta +++ b/Tests/Runtime/Core/EmitTargetedSpecificTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 86adfbd0cbd985e4c9ff6cbae3f375b1 +guid: da70b28d80a127ba7d472d23ecd0afe9 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Core/EmitTests.cs b/Tests/Runtime/Core/EmitTests.cs new file mode 100644 index 00000000..cc1db72c --- /dev/null +++ b/Tests/Runtime/Core/EmitTests.cs @@ -0,0 +1,109 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// Parameterized emit smoke tests that exercise the basic register-emit-receive + /// loop across all three message kinds via . The + /// per-kind variants of these tests live in the kind-specific files + /// EmitUntargetedSpecificTests.cs, EmitTargetedSpecificTests.cs, + /// and EmitBroadcastSpecificTests.cs; only logic that is provably + /// identical across all three kinds (after factoring out kind-specific + /// register/emit calls) is consolidated here. Tests with kind-specific + /// assertion semantics (different expected counts, asymmetric routing rules, + /// without-targeting variants, etc.) intentionally remain in the per-kind + /// files to preserve assertion fidelity. + /// + public sealed class EmitTests : MessagingTestBase + { + [UnityTest] + public IEnumerator HandlerReceivesEmittedMessage( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerReceivesEmittedMessage) + "_" + scenario, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + + int count = 0; + const int numEmissions = 100; + + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + _ = ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => ++count + ); + SimpleUntargetedMessage message = new(); + for (int i = 0; i < numEmissions; ++i) + { + Assert.AreEqual(i, count); + ScenarioHarness.EmitUntargeted(scenario, ref message); + } + + break; + } + case MessageKind.Targeted: + { + _ = ScenarioHarness.RegisterTargeted( + scenario, + token, + component, + (ref SimpleTargetedMessage _) => ++count + ); + SimpleTargetedMessage message = new(); + for (int i = 0; i < numEmissions; ++i) + { + Assert.AreEqual(i, count); + ScenarioHarness.EmitTargeted(scenario, ref message, component); + } + + break; + } + case MessageKind.Broadcast: + { + _ = ScenarioHarness.RegisterBroadcast( + scenario, + token, + component, + (ref SimpleBroadcastMessage _) => ++count + ); + SimpleBroadcastMessage message = new(); + for (int i = 0; i < numEmissions; ++i) + { + Assert.AreEqual(i, count); + ScenarioHarness.EmitBroadcast(scenario, ref message, component); + } + + break; + } + default: + { + Assert.Fail("Unhandled MessageKind: {0}.", scenario.Kind); + break; + } + } + + Assert.AreEqual(numEmissions, count); + yield break; + } + } +} + +#endif diff --git a/Tests/Runtime/Core/EmitTests.cs.meta b/Tests/Runtime/Core/EmitTests.cs.meta new file mode 100644 index 00000000..320e7ecd --- /dev/null +++ b/Tests/Runtime/Core/EmitTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11267e297a400f091c7651c5a9321872 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/UntargetedTests.cs b/Tests/Runtime/Core/EmitUntargetedSpecificTests.cs similarity index 94% rename from Tests/Runtime/Core/UntargetedTests.cs rename to Tests/Runtime/Core/EmitUntargetedSpecificTests.cs index 5e140c63..2ed749ec 100644 --- a/Tests/Runtime/Core/UntargetedTests.cs +++ b/Tests/Runtime/Core/EmitUntargetedSpecificTests.cs @@ -12,7 +12,15 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine; using UnityEngine.TestTools; - public sealed class UntargetedTests : MessagingTestBase + /// + /// Untargeted-only emission tests preserved verbatim from the original + /// UntargetedTests.cs. Logic that is provably common across all three + /// message kinds is consolidated in ; the tests in + /// this file remain kind-specific because their assertion semantics do not + /// translate cleanly to targeted or broadcast dispatch (untargeted fan-out + /// to every registered handler differs from targeted/broadcast routing). + /// + public sealed class EmitUntargetedSpecificTests : MessagingTestBase { [UnityTest] public IEnumerator SimpleNormal() diff --git a/Tests/Runtime/Core/EmitUntargetedSpecificTests.cs.meta b/Tests/Runtime/Core/EmitUntargetedSpecificTests.cs.meta new file mode 100644 index 00000000..64952286 --- /dev/null +++ b/Tests/Runtime/Core/EmitUntargetedSpecificTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06f5f0552188757a85014fe2dd288b36 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/HandlerExceptionTests.cs b/Tests/Runtime/Core/HandlerExceptionTests.cs new file mode 100644 index 00000000..acac622b --- /dev/null +++ b/Tests/Runtime/Core/HandlerExceptionTests.cs @@ -0,0 +1,615 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + // Bus does not emit framework-level logs on handler/interceptor/post-processor throws; LogAssert.Expect intentionally not used. + + /// + /// Pins the current behavior of the message bus when handlers, interceptors, and + /// post-processors throw. The bus does not wrap dispatched delegates in try/catch, + /// so exceptions propagate out of the emit call and any siblings scheduled to run + /// after the throwing delegate are skipped for the current dispatch. These tests + /// capture that contract so any future change to swallow-and-log behavior fails + /// loudly and forces a deliberate review. + /// + public sealed class HandlerExceptionTests : MessagingTestBase + { + private const string ThrowingHandlerMessage = "DxMessaging-test-handler-throw"; + private const string ThrowingInterceptorMessage = "DxMessaging-test-interceptor-throw"; + private const string ThrowingPostProcessorMessage = "DxMessaging-test-post-processor-throw"; + + /// + /// Pins that a throwing handler aborts the rest of the current dispatch: + /// previously ordered handlers run, the throwing handler runs, and any + /// subsequent handler scheduled after it is skipped for that emission. + /// + [UnityTest] + public IEnumerator HandlerThrowPreventsSubsequentHandlers( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerThrowPreventsSubsequentHandlers) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int firstCount = 0; + int secondCount = 0; + int thirdCount = 0; + + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++firstCount + ); + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++secondCount; + throw new InvalidOperationException(ThrowingHandlerMessage); + } + ); + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++thirdCount + ); + + InvalidOperationException captured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + + Assert.AreEqual(ThrowingHandlerMessage, captured.Message); + Assert.AreEqual(1, firstCount, "First handler must run before the throwing handler."); + Assert.AreEqual(1, secondCount, "Throwing handler must execute before propagating."); + // Pinning current behavior: the bus does not wrap handlers in try/catch, so + // siblings scheduled after the throwing one are skipped during this dispatch. + // If that ever changes (e.g. the bus starts swallow-and-log) update this assertion. + Assert.AreEqual( + 0, + thirdCount, + "Subsequent handler must not run once propagation begins." + ); + yield break; + } + + [UnityTest] + public IEnumerator HandlerThrowDoesNotCorruptDispatchPool( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerThrowDoesNotCorruptDispatchPool) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int safeCount = 0; + int throwingCount = 0; + + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++safeCount + ); + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 1, + onInvoked: () => + { + ++throwingCount; + throw new InvalidOperationException(ThrowingHandlerMessage); + } + ); + + const int Iterations = 10; + for (int i = 0; i < Iterations; ++i) + { + InvalidOperationException captured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual(ThrowingHandlerMessage, captured.Message); + } + + Assert.AreEqual( + Iterations, + safeCount, + "Safe handler must run on every emission even when later handler throws." + ); + Assert.AreEqual( + Iterations, + throwingCount, + "Throwing handler must execute on every emission with no double-fire or skip." + ); + yield break; + } + + /// + /// Pins that a throwing handler aborts the dispatch before post-processors + /// run. Handler exceptions propagate out of the emit call without invoking + /// any post-processors registered for the same message. If post-processors + /// are later moved into a finally block this contract must be revisited. + /// + [UnityTest] + public IEnumerator HandlerThrowPreventsPostProcessorsFromRunning( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerThrowPreventsPostProcessorsFromRunning) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + int postProcessorCount = 0; + + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++handlerCount; + throw new InvalidOperationException(ThrowingHandlerMessage); + } + ); + RegisterCountingPostProcessor( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++postProcessorCount + ); + + InvalidOperationException captured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + + Assert.AreEqual(ThrowingHandlerMessage, captured.Message); + Assert.AreEqual(1, handlerCount, "Throwing handler must execute exactly once."); + // Pinning current behavior: a handler exception aborts the dispatch before + // post-processors run. If post-processors are later moved into a finally + // block the assertion below will need to be inverted. + Assert.AreEqual( + 0, + postProcessorCount, + "Post-processor must not run when an earlier handler throws." + ); + yield break; + } + + [UnityTest] + public IEnumerator HandlerThrowDoesNotPreventDeregistration( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerThrowDoesNotPreventDeregistration) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int throwingCount = 0; + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++throwingCount; + throw new InvalidOperationException(ThrowingHandlerMessage); + } + ); + + InvalidOperationException firstCaptured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual(ThrowingHandlerMessage, firstCaptured.Message); + Assert.AreEqual(1, throwingCount); + + token.RemoveRegistration(handle); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + throwingCount, + "Handler must not fire after RemoveRegistration even if a previous emit threw." + ); + + // After deregistering the throwing handler, registering a fresh + // non-throwing handler must produce a clean dispatch with no residue + // from the previous failure. + int replacementCount = 0; + MessageRegistrationHandle replacementHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++replacementCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + replacementCount, + "Replacement handler registered after the throw must run on the next emission." + ); + Assert.AreEqual( + 1, + throwingCount, + "Removed throwing handler must remain inert after replacement is registered." + ); + + token.RemoveRegistration(replacementHandle); + yield break; + } + + [UnityTest] + public IEnumerator InterceptorThrowFallsBackGracefully( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(InterceptorThrowFallsBackGracefully) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + int interceptorCount = 0; + + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++handlerCount + ); + RegisterThrowingInterceptor(scenario, token, onInvoked: () => ++interceptorCount); + + InvalidOperationException captured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + + Assert.AreEqual(ThrowingInterceptorMessage, captured.Message); + Assert.AreEqual( + 1, + interceptorCount, + "Interceptor must execute and throw exactly once." + ); + // Behavior pinned to current implementation: interceptor exceptions + // propagate before handlers run, so handlers do not see the message. + Assert.AreEqual( + 0, + handlerCount, + "Handler must not run when an interceptor throws during the same emission." + ); + + // Sanity: a follow-up emission after the throwing interceptor still raises again, + // proving no infinite loop or NullReferenceException is masked behind the throw. + InvalidOperationException secondCaptured = Assert.Throws( + () => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual(ThrowingInterceptorMessage, secondCaptured.Message); + Assert.AreEqual(2, interceptorCount); + Assert.AreEqual(0, handlerCount); + yield break; + } + + [UnityTest] + public IEnumerator PostProcessorThrowDoesNotAffectNextEmission( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(PostProcessorThrowDoesNotAffectNextEmission) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + int throwingPostProcessorCount = 0; + int trailingPostProcessorCount = 0; + + RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++handlerCount + ); + // Throwing post-processor at priority 1 (runs after the trailing one + // at priority 2 if priority is purely lower-first, OR before depending + // on order). To force a deterministic order where the throwing PP runs + // first and skips the trailing one, register the throwing PP at the + // earlier priority and the trailing PP at a later priority. + RegisterCountingPostProcessor( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++throwingPostProcessorCount; + throw new InvalidOperationException(ThrowingPostProcessorMessage); + } + ); + RegisterCountingPostProcessor( + scenario, + token, + hostId, + priority: 1, + onInvoked: () => ++trailingPostProcessorCount + ); + + InvalidOperationException firstCaptured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual(ThrowingPostProcessorMessage, firstCaptured.Message); + Assert.AreEqual(1, handlerCount, "Handler must run before throwing post-processor."); + Assert.AreEqual(1, throwingPostProcessorCount); + Assert.AreEqual( + 0, + trailingPostProcessorCount, + "Trailing post-processor must not run when an earlier post-processor throws." + ); + + InvalidOperationException secondCaptured = Assert.Throws( + () => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual(ThrowingPostProcessorMessage, secondCaptured.Message); + Assert.AreEqual( + 2, + handlerCount, + "Handler must continue to run on subsequent emissions." + ); + Assert.AreEqual(2, throwingPostProcessorCount); + Assert.AreEqual( + 0, + trailingPostProcessorCount, + "Trailing post-processor must remain skipped on every emission while the earlier one throws." + ); + yield break; + } + + private static MessageRegistrationHandle RegisterCountingHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + int priority, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static MessageRegistrationHandle RegisterCountingPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + int priority, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedPostProcessor( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedPostProcessor( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastPostProcessor( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static MessageRegistrationHandle RegisterThrowingInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => + { + onInvoked(); + throw new InvalidOperationException(ThrowingInterceptorMessage); + } + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage _) => + { + onInvoked(); + throw new InvalidOperationException(ThrowingInterceptorMessage); + } + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage _) => + { + onInvoked(); + throw new InvalidOperationException(ThrowingInterceptorMessage); + } + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/HandlerExceptionTests.cs.meta b/Tests/Runtime/Core/HandlerExceptionTests.cs.meta new file mode 100644 index 00000000..8635d5de --- /dev/null +++ b/Tests/Runtime/Core/HandlerExceptionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dec4be9a19b44352a8bb5ec4d01b3c3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/InterceptorCancellationTests.cs b/Tests/Runtime/Core/InterceptorCancellationTests.cs index 9de0eff9..1485d3dd 100644 --- a/Tests/Runtime/Core/InterceptorCancellationTests.cs +++ b/Tests/Runtime/Core/InterceptorCancellationTests.cs @@ -1,9 +1,10 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using DxMessaging.Core; - using DxMessaging.Core.Extensions; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; @@ -13,31 +14,35 @@ namespace DxMessaging.Tests.Runtime.Core public sealed class InterceptorCancellationTests : MessagingTestBase { [UnityTest] - public IEnumerator UntargetedInterceptorCancelsHandlersAndPostProcessors() + public IEnumerator InterceptorCancelsHandlersAndPostProcessors( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedInterceptorCancelsHandlersAndPostProcessors), + nameof(InterceptorCancelsHandlersAndPostProcessors) + "_" + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; int handled = 0; int postProcessed = 0; + int laterRan = 0; - _ = token.RegisterUntargeted(_ => handled++); - _ = token.RegisterUntargetedPostProcessor( - (ref SimpleUntargetedMessage _) => postProcessed++ - ); + _ = RegisterHandler(scenario, token, hostId, () => handled++); + _ = RegisterPostProcessor(scenario, token, hostId, () => postProcessed++); // Register a canceling interceptor (always false) - _ = token.RegisterUntargetedInterceptor((ref SimpleUntargetedMessage _) => false); + _ = RegisterInterceptor(scenario, token, () => false); // Also register a later interceptor that would be skipped if earlier cancels - int laterRan = 0; - _ = token.RegisterUntargetedInterceptor( - (ref SimpleUntargetedMessage _) => + _ = RegisterInterceptor( + scenario, + token, + () => { laterRan++; return true; @@ -45,8 +50,7 @@ public IEnumerator UntargetedInterceptorCancelsHandlersAndPostProcessors() priority: 10 ); - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual(0, handled, "Handlers must not run when interceptor cancels."); Assert.AreEqual( @@ -58,114 +62,184 @@ public IEnumerator UntargetedInterceptorCancelsHandlersAndPostProcessors() yield break; } - [UnityTest] - public IEnumerator TargetedInterceptorCancelsHandlersAndPostProcessors() + private static MessageRegistrationHandle RegisterHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) { - GameObject host = new( - nameof(TargetedInterceptorCancelsHandlersAndPostProcessors), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int handled = 0; - int postProcessed = 0; - - _ = token.RegisterGameObjectTargeted(host, _ => handled++); - _ = token.RegisterGameObjectTargetedPostProcessor( - host, - (ref SimpleTargetedMessage _) => postProcessed++ - ); - - // Cancel targeted messages - _ = token.RegisterTargetedInterceptor( - (ref InstanceId _, ref SimpleTargetedMessage _) => false - ); - - // A later interceptor that would not execute after cancellation - int laterRan = 0; - _ = token.RegisterTargetedInterceptor( - (ref InstanceId _, ref SimpleTargetedMessage _) => + switch (scenario.Kind) + { + case MessageKind.Untargeted: { - laterRan++; - return true; - }, - priority: 10 - ); - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - - Assert.AreEqual(0, handled, "Targeted handlers must not run when interceptor cancels."); - Assert.AreEqual( - 0, - postProcessed, - "Targeted post-processors must not run when interceptor cancels." - ); - Assert.AreEqual( - 0, - laterRan, - "Later targeted interceptors must not run after cancellation." - ); - yield break; + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } } - [UnityTest] - public IEnumerator BroadcastInterceptorCancelsHandlersAndPostProcessors() + private static MessageRegistrationHandle RegisterPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) { - GameObject host = new( - nameof(BroadcastInterceptorCancelsHandlersAndPostProcessors), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int handled = 0; - int postProcessed = 0; - - _ = token.RegisterGameObjectBroadcast(host, _ => handled++); - _ = token.RegisterGameObjectBroadcastPostProcessor( - host, - _ => postProcessed++ - ); - - // Cancel broadcast messages - _ = token.RegisterBroadcastInterceptor( - (ref InstanceId _, ref SimpleBroadcastMessage _) => false - ); - - // A later interceptor that would not execute after cancellation - int laterRan = 0; - _ = token.RegisterBroadcastInterceptor( - (ref InstanceId _, ref SimpleBroadcastMessage _) => + switch (scenario.Kind) + { + case MessageKind.Untargeted: { - laterRan++; - return true; - }, - priority: 10 - ); + return ScenarioHarness.RegisterUntargetedPostProcessor( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedPostProcessor( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastPostProcessor( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); + private static MessageRegistrationHandle RegisterInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Func body, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => body(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage __) => body(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage __) => body(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } - Assert.AreEqual( - 0, - handled, - "Broadcast handlers must not run when interceptor cancels." - ); - Assert.AreEqual( - 0, - postProcessed, - "Broadcast post-processors must not run when interceptor cancels." - ); - Assert.AreEqual( - 0, - laterRan, - "Later broadcast interceptors must not run after cancellation." - ); - yield break; + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } } } } diff --git a/Tests/Runtime/Core/MessagingTestBase.cs b/Tests/Runtime/Core/MessagingTestBase.cs index cd459f99..56535054 100644 --- a/Tests/Runtime/Core/MessagingTestBase.cs +++ b/Tests/Runtime/Core/MessagingTestBase.cs @@ -5,6 +5,7 @@ namespace DxMessaging.Tests.Runtime.Core using System.Collections; using System.Collections.Generic; using System.Diagnostics; + using System.Globalization; using System.Linq; using DxMessaging.Core; using DxMessaging.Core.MessageBus; @@ -18,15 +19,82 @@ namespace DxMessaging.Tests.Runtime.Core public abstract class MessagingTestBase { + private const string TestSeedEnvVar = "DXMESSAGING_TEST_SEED"; + private const int DefaultTestSeed = unchecked((int)0xDB1ABCED); + + private static int? _cachedSeed; + protected int _numRegistrations; protected readonly List _spawned = new(); - protected readonly Random _random = new(); + protected Random _random = new(DefaultTestSeed); protected virtual bool MessagingDebugEnabled => true; + protected virtual int StressRegistrations => 150; + + /// + /// Maximum time the polling loop in + /// waits for the message + /// bus to drain before failing. Override in derived fixtures that need + /// a tighter or looser bound. + /// + protected virtual TimeSpan FreshHandlerWaitTimeout => TimeSpan.FromSeconds(1.5); + + /// + /// Resolved test seed cached for the lifetime of the process. The + /// environment variable is parsed once and reused across every + /// fixture/test to avoid repeated lookups during Setup. + /// + protected static int TestSeed + { + get + { + _cachedSeed ??= ResolveTestSeed(); + return _cachedSeed.Value; + } + } + + [OneTimeSetUp] + public virtual void LogTestSeedOnce() + { + Debug.Log($"DxMessaging test seed = {TestSeed} (env {TestSeedEnvVar})."); + } + + /// + /// Reseeds the per-test random source from the resolved seed and + /// applies the default messaging-debug configuration. + /// + /// + /// + /// The call has moved to + /// so the prior test's deferred + /// Object.Destroy queue can drain (yielded for one frame) before + /// the bus sinks are wiped. Resetting synchronously here would race the + /// destroy queue: the next test's [SetUp] would clear sinks, then + /// Unity would flush the previous test's destroys, firing + /// against an emptied bus + /// and surfacing spurious over-deregistration errors attributed to the + /// current test. If a fixture needs per-test global state, set it + /// inside the test body or in a derived [SetUp] that runs after + /// base.Setup(); do not rely on configuration that survives the + /// reset performed in UnitySetup. The seed log line is emitted + /// once per fixture from rather than per + /// test. + /// + /// + /// _numRegistrations defaults to 25, the smoke-check + /// depth used by most fixtures. Fixtures whose Run(...) helper + /// calls rely on stress fan-out (for example legacy registration + /// stress) should override Setup and assign + /// _numRegistrations = StressRegistrations after invoking + /// base.Setup(). + /// + /// [SetUp] public virtual void Setup() { + _random = new Random(TestSeed); + MessagingDebug.enabled = MessagingDebugEnabled; MessagingDebug.LogFunction = (level, message) => { @@ -47,11 +115,50 @@ public virtual void Setup() IMessageBus messageBus = MessageHandler.MessageBus; Assert.IsNotNull(messageBus); messageBus.Log.Enabled = true; - _numRegistrations = 150; + _numRegistrations = 25; LogMessageBusStatus(); } + private static int ResolveTestSeed() + { + string raw = Environment.GetEnvironmentVariable(TestSeedEnvVar); + if (string.IsNullOrEmpty(raw)) + { + return DefaultTestSeed; + } + + if (int.TryParse(raw, out int parsed)) + { + return parsed; + } + + if ( + raw.StartsWith("0x", StringComparison.Ordinal) + || raw.StartsWith("0X", StringComparison.Ordinal) + ) + { + string stripped = raw.Substring(2); + if ( + int.TryParse( + stripped, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out int hexParsed + ) + ) + { + return hexParsed; + } + } + + Debug.LogWarning( + $"DXMESSAGING_TEST_SEED='{raw}' is not a valid integer or hex value. " + + $"Falling back to default seed 0x{DefaultTestSeed:X8}." + ); + return DefaultTestSeed; + } + protected void LogMessageBusStatus() { IMessageBus messageBus = MessageHandler.MessageBus; @@ -74,6 +181,19 @@ public virtual void Cleanup() _spawned.Clear(); } + /// + /// Resets DxMessaging static state once per fixture after every test + /// has run. Cleanup intentionally leaves static state intact so + /// the cleanup-robustness test can observe it mid-test; this hook + /// makes sure no debug flags, log functions, or custom buses leak into + /// fixtures that do not derive from . + /// + [OneTimeTearDown] + public virtual void OneTimeCleanup() + { + DxMessagingStaticState.Reset(); + } + [UnityTearDown] public IEnumerator UnityCleanup() { @@ -92,12 +212,34 @@ public IEnumerator UnityCleanup() } _spawned.Clear(); + + // Assert the bus drained fully inside this test, instead of + // letting a stuck handler bleed into the next test's logs. + IEnumerator freshHandler = WaitUntilMessageHandlerIsFresh(); + while (freshHandler.MoveNext()) + { + yield return freshHandler.Current; + } } [UnitySetUp] public virtual IEnumerator UnitySetup() { - return WaitUntilMessageHandlerIsFresh(); + // Drain the prior test's deferred Object.Destroy queue before + // wiping bus state. Otherwise queued OnDisable callbacks would + // fire against an emptied bus and log over-deregistration errors + // against the next test (see ResetState's _resetGeneration guard + // for the production-side hardening). + if (Application.isPlaying) + { + yield return null; + } + DxMessagingStaticState.Reset(); + IEnumerator freshHandler = WaitUntilMessageHandlerIsFresh(); + while (freshHandler.MoveNext()) + { + yield return freshHandler.Current; + } } protected void Run( @@ -175,14 +317,19 @@ protected static MessageRegistrationToken GetToken(MessageAwareComponent compone return component.Token; } - protected static IEnumerator WaitUntilMessageHandlerIsFresh() + // NOTE: This polling loop should eventually be replaced by a bus-side + // version-counter check (Issue 14). Until that lands, callers fall back + // to the per-frame yield below to detect when the bus has drained. + protected IEnumerator WaitUntilMessageHandlerIsFresh() { IMessageBus messageBus = MessageHandler.MessageBus; Assert.IsNotNull(messageBus); Stopwatch timer = Stopwatch.StartNew(); + // Generous safety margin; the loop exits as soon as state clears, so this only bites under extreme load. + TimeSpan timeout = FreshHandlerWaitTimeout; - while (IsStale() && timer.Elapsed < TimeSpan.FromSeconds(1.25)) + while (IsStale() && timer.Elapsed < timeout) { yield return null; } diff --git a/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs b/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs index d17a6880..63f7f257 100644 --- a/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs +++ b/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs @@ -1,11 +1,14 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using System.Collections.Generic; using System.Linq; using DxMessaging.Core; using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; using Scripts.Components; using UnityEngine; @@ -201,6 +204,264 @@ public IEnumerator CleanupVariantsDestroyTrackedObjectsAndClearRegistrations( ); } + /// + /// Regression test for the destroy-then-Reset race that previously + /// flooded TearDown with spurious over-deregistration errors. + /// + /// Spawns a , calls + /// synchronously (Unity defers the + /// destruction to end-of-frame), wipes bus state via + /// , then yields a frame to + /// let Unity flush the destroy queue. The deferred + /// OnDisable/OnDestroy callbacks must not log any + /// over-deregistration errors -- the production hardening installs a + /// reset-generation guard on every cached deregister closure to make + /// this safe by design. + /// + /// + [UnityTest] + public IEnumerator DestroyThenResetDoesNotLogOverDeregistrationErrors( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Force a clean baseline so any pre-existing logs from earlier + // setup do not contaminate the assertion below. + DxMessagingStaticState.Reset(); + yield return WaitUntilMessageHandlerIsFresh(); + + GameObject host = new( + $"{nameof(DestroyThenResetDoesNotLogOverDeregistrationErrors)}_{scenario.Kind}", + typeof(SimpleMessageAwareComponent) + ); + + // Layer a kind-specific registration on top of the auto-registered + // StringMessage handlers so the regression is exercised across all + // dispatch shapes. + SimpleMessageAwareComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + _ = ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => { } + ); + break; + case MessageKind.Targeted: + _ = ScenarioHarness.RegisterTargeted( + scenario, + token, + hostId, + (ref SimpleTargetedMessage _) => { } + ); + break; + case MessageKind.Broadcast: + _ = ScenarioHarness.RegisterBroadcast( + scenario, + token, + hostId, + (ref SimpleBroadcastMessage _) => { } + ); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported scenario kind." + ); + } + + // Capture every log emitted by MessagingDebug across the destroy/ + // reset window. We swap the function (instead of using LogAssert) + // so this test does not rely on Unity console plumbing or the + // test runner's expected-log matching, both of which interact + // badly with deferred destroys. + // + // The log function is installed *after* the Reset() below because + // DxMessagingStaticState.Reset restores MessagingDebug.LogFunction + // to the captured baseline; setting it before Reset would lose + // the override exactly when we need it. + Action previousLogFunction = MessagingDebug.LogFunction; + bool previousEnabled = MessagingDebug.enabled; + List capturedErrors = new(); + try + { + Object.Destroy(host); + DxMessagingStaticState.Reset(); + + MessagingDebug.enabled = true; + MessagingDebug.LogFunction = (level, message) => + { + if (level == LogLevel.Error) + { + capturedErrors.Add(message); + } + }; + + // Yield a frame so Unity flushes the deferred destroy queue. + // Pre-fix this is when OnDisable/OnDestroy would fire against + // the wiped bus and log over-deregistration errors. + yield return null; + yield return null; + + Assert.IsTrue( + host == null, + $"Destroy should have completed by now. scenario={scenario.Kind}." + ); + + Assert.IsEmpty( + capturedErrors, + "Expected no MessagingDebug error logs after destroy+reset, got: " + + string.Join(" | ", capturedErrors) + ); + } + finally + { + MessagingDebug.LogFunction = previousLogFunction; + MessagingDebug.enabled = previousEnabled; + } + } + + /// + /// Companion to + /// that pins the same race-safety guarantee for user-installed custom global buses. + /// + /// The component's deregister closures live on the custom bus (because + /// returned the custom bus when the + /// component awoke). wipes the default + /// bus and propagates the reset-generation bump to the active custom bus so the + /// deferred destroy callbacks silently no-op against the custom bus instead of + /// either logging spurious over-deregistration errors or undoing registrations + /// the user wished to preserve. + /// + /// + [UnityTest] + public IEnumerator DestroyThenResetWithCustomBusDoesNotLogOverDeregistrationErrors( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Force a clean baseline so any pre-existing logs from earlier setup do not + // contaminate the assertion below. + DxMessagingStaticState.Reset(); + yield return WaitUntilMessageHandlerIsFresh(); + + IMessageBus previousBus = MessageHandler.MessageBus; + MessageBus customBus = new(); + MessageHandler.SetGlobalMessageBus(customBus); + + try + { + GameObject host = new( + $"{nameof(DestroyThenResetWithCustomBusDoesNotLogOverDeregistrationErrors)}_{scenario.Kind}", + typeof(SimpleMessageAwareComponent) + ); + + SimpleMessageAwareComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + switch (scenario.Kind) + { + case MessageKind.Untargeted: + _ = ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => { } + ); + break; + case MessageKind.Targeted: + _ = ScenarioHarness.RegisterTargeted( + scenario, + token, + hostId, + (ref SimpleTargetedMessage _) => { } + ); + break; + case MessageKind.Broadcast: + _ = ScenarioHarness.RegisterBroadcast( + scenario, + token, + hostId, + (ref SimpleBroadcastMessage _) => { } + ); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported scenario kind." + ); + } + + Assert.AreSame( + customBus, + MessageHandler.MessageBus, + "Component must be registered against the custom bus for this regression to be meaningful." + ); + + Action previousLogFunction = MessagingDebug.LogFunction; + bool previousEnabled = MessagingDebug.enabled; + List capturedErrors = new(); + try + { + Object.Destroy(host); + DxMessagingStaticState.Reset(); + + MessagingDebug.enabled = true; + MessagingDebug.LogFunction = (level, message) => + { + if (level == LogLevel.Error) + { + capturedErrors.Add(message); + } + }; + + yield return null; + yield return null; + + Assert.IsTrue( + host == null, + $"Destroy should have completed by now. scenario={scenario.Kind}." + ); + + Assert.IsEmpty( + capturedErrors, + "Expected no MessagingDebug error logs after destroy+reset against a custom bus, got: " + + string.Join(" | ", capturedErrors) + ); + } + finally + { + MessagingDebug.LogFunction = previousLogFunction; + MessagingDebug.enabled = previousEnabled; + } + } + finally + { + // Wipe the custom bus before restoring the previous global so the + // generation guard cannot leak entries into the next test's bus + // observation. + customBus.ResetState(); + if (previousBus is MessageBus previousConcrete) + { + MessageHandler.SetGlobalMessageBus(previousConcrete); + } + else if (previousBus != null) + { + MessageHandler.SetGlobalMessageBus(previousBus); + } + else + { + MessageHandler.ResetGlobalMessageBus(); + } + } + } + private static void DestroyForCleanupScenario(GameObject spawned) { if (Application.isPlaying) diff --git a/Tests/Runtime/Core/MutationDestructionTests.cs b/Tests/Runtime/Core/MutationDestructionTests.cs index cb3cb874..e7df5254 100644 --- a/Tests/Runtime/Core/MutationDestructionTests.cs +++ b/Tests/Runtime/Core/MutationDestructionTests.cs @@ -1,14 +1,17 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using DxMessaging.Core; using DxMessaging.Core.Extensions; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; + using Object = UnityEngine.Object; /// /// Validates mutation semantics when a listener is explicitly destroyed during dispatch. @@ -30,19 +33,32 @@ public sealed class MutationDestructionTests : MessagingTestBase private const int DestroyerPriority = -10; // ensure it runs before default priority 0 [UnityTest] - public IEnumerator UntargetedDestroyOtherListenerDoesNotRun() + public IEnumerator DestroyOtherListenerDoesNotRun( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject a = new( - nameof(UntargetedDestroyOtherListenerDoesNotRun) + "_A", + nameof(DestroyOtherListenerDoesNotRun) + "_" + scenario + "_A", typeof(EmptyMessageAwareComponent) ); GameObject b = new( - nameof(UntargetedDestroyOtherListenerDoesNotRun) + "_B", + nameof(DestroyOtherListenerDoesNotRun) + "_" + scenario + "_B", typeof(EmptyMessageAwareComponent) ); _spawned.Add(a); _spawned.Add(b); + InstanceId targetId = default; + if (scenario.Kind != MessageKind.Untargeted) + { + GameObject target = new( + nameof(DestroyOtherListenerDoesNotRun) + "_" + scenario + "_Target" + ); + _spawned.Add(target); + targetId = target; + } + EmptyMessageAwareComponent compA = a.GetComponent(); EmptyMessageAwareComponent compB = b.GetComponent(); MessageRegistrationToken tokenA = GetToken(compA); @@ -51,8 +67,11 @@ public IEnumerator UntargetedDestroyOtherListenerDoesNotRun() int firstCount = 0; int secondCount = 0; - _ = tokenA.RegisterUntargeted( - (ref SimpleUntargetedMessage _) => + _ = RegisterCounter( + scenario, + tokenA, + targetId, + () => { firstCount++; Object.Destroy(b); @@ -60,77 +79,15 @@ public IEnumerator UntargetedDestroyOtherListenerDoesNotRun() DestroyerPriority ); - _ = tokenB.RegisterUntargeted( - (ref SimpleUntargetedMessage _) => - { - secondCount++; - } - ); + _ = RegisterCounter(scenario, tokenB, targetId, () => secondCount++); - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, targetId); Assert.AreEqual(1, firstCount, "First handler should run exactly once."); Assert.AreEqual(0, secondCount, "Second handler must not act after it is destroyed."); yield break; } - [UnityTest] - public IEnumerator TargetedGameObjectDestroyOtherListenerDoesNotRun() - { - GameObject a = new( - nameof(TargetedGameObjectDestroyOtherListenerDoesNotRun) + "_A", - typeof(EmptyMessageAwareComponent) - ); - GameObject b = new( - nameof(TargetedGameObjectDestroyOtherListenerDoesNotRun) + "_B", - typeof(EmptyMessageAwareComponent) - ); - GameObject target = new( - nameof(TargetedGameObjectDestroyOtherListenerDoesNotRun) + "_Target" - ); - _spawned.Add(a); - _spawned.Add(b); - _spawned.Add(target); - - EmptyMessageAwareComponent compA = a.GetComponent(); - EmptyMessageAwareComponent compB = b.GetComponent(); - MessageRegistrationToken tokenA = GetToken(compA); - MessageRegistrationToken tokenB = GetToken(compB); - - int firstCount = 0; - int secondCount = 0; - - _ = tokenA.RegisterGameObjectTargeted( - target, - (ref SimpleTargetedMessage _) => - { - firstCount++; - Object.Destroy(b); - }, - DestroyerPriority - ); - - _ = tokenB.RegisterGameObjectTargeted( - target, - (ref SimpleTargetedMessage _) => - { - secondCount++; - } - ); - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(target); - - Assert.AreEqual(1, firstCount, "First targeted handler should run once."); - Assert.AreEqual( - 0, - secondCount, - "Second targeted handler must not act after destruction." - ); - yield break; - } - [UnityTest] public IEnumerator TargetedComponentDestroyOtherListenerDoesNotRun() { @@ -248,66 +205,6 @@ public IEnumerator TargetedWithoutTargetingDestroyOtherListenerDoesNotRun() yield break; } - [UnityTest] - public IEnumerator BroadcastGameObjectDestroyOtherListenerDoesNotRun() - { - GameObject a = new( - nameof(BroadcastGameObjectDestroyOtherListenerDoesNotRun) + "_A", - typeof(EmptyMessageAwareComponent) - ); - GameObject b = new( - nameof(BroadcastGameObjectDestroyOtherListenerDoesNotRun) + "_B", - typeof(EmptyMessageAwareComponent) - ); - GameObject source = new( - nameof(BroadcastGameObjectDestroyOtherListenerDoesNotRun) + "_Source" - ); - _spawned.Add(a); - _spawned.Add(b); - _spawned.Add(source); - - EmptyMessageAwareComponent compA = a.GetComponent(); - EmptyMessageAwareComponent compB = b.GetComponent(); - MessageRegistrationToken tokenA = GetToken(compA); - MessageRegistrationToken tokenB = GetToken(compB); - - int firstCount = 0; - int secondCount = 0; - - _ = tokenA.RegisterGameObjectBroadcast( - source, - (ref SimpleBroadcastMessage _) => - { - firstCount++; - Object.Destroy(b); - }, - DestroyerPriority - ); - - _ = tokenB.RegisterGameObjectBroadcast( - source, - (ref SimpleBroadcastMessage _) => - { - secondCount++; - } - ); - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(source); - - Assert.AreEqual( - 1, - firstCount, - "First GameObject-sourced broadcast handler should run once." - ); - Assert.AreEqual( - 0, - secondCount, - "Second GameObject-sourced broadcast handler must not act after destruction." - ); - yield break; - } - [UnityTest] public IEnumerator BroadcastComponentDestroyOtherListenerDoesNotRun() { @@ -431,6 +328,89 @@ public IEnumerator BroadcastWithoutSourceDestroyOtherListenerDoesNotRun() ); yield break; } + + private static MessageRegistrationHandle RegisterCounter( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } } } diff --git a/Tests/Runtime/Core/MutationDuringEmissionTests.cs b/Tests/Runtime/Core/MutationDuringEmissionTests.cs index 07a04dbf..e99da093 100644 --- a/Tests/Runtime/Core/MutationDuringEmissionTests.cs +++ b/Tests/Runtime/Core/MutationDuringEmissionTests.cs @@ -1,10 +1,12 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; @@ -22,15 +24,20 @@ public sealed class MutationDuringEmissionTests : MessagingTestBase private const int ManyCount = 6; // Forces default iteration paths (>5) [UnityTest] - public IEnumerator UntargetedAddLocalHandlerMany() + [Category("Stress")] + public IEnumerator AddLocalHandlerMany( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedAddLocalHandlerMany), + nameof(AddLocalHandlerMany) + "_" + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; int[] counts = new int[ManyCount + 1]; MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount + 1]; @@ -40,21 +47,24 @@ public IEnumerator UntargetedAddLocalHandlerMany() for (int i = 0; i < ManyCount; i++) { int idx = i; - handles[idx] = token.RegisterUntargeted(_ => + Action onInvoke = () => { counts[idx]++; if (!added && idx == 0) { added = true; - handles[ManyCount] = token.RegisterUntargeted(_ => - counts[ManyCount]++ + handles[ManyCount] = RegisterCounter( + scenario, + token, + hostId, + () => counts[ManyCount]++ ); } - }); + }; + handles[idx] = RegisterCounter(scenario, token, hostId, onInvoke); } - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); int expected = ManyCount; int total = 0; for (int i = 0; i < ManyCount; i++) @@ -69,7 +79,7 @@ public IEnumerator UntargetedAddLocalHandlerMany() "Newly added handler must not run in the same emission." ); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); total = 0; for (int i = 0; i < ManyCount; i++) { @@ -98,6 +108,7 @@ public IEnumerator UntargetedAddLocalHandlerMany() } [UnityTest] + [Category("Stress")] public IEnumerator UntargetedRemoveSelfMany() { GameObject host = new( @@ -154,14 +165,31 @@ public IEnumerator UntargetedRemoveSelfMany() } [UnityTest] - public IEnumerator UntargetedAddHandlerAcrossHandlersMany() + [Category("Stress")] + public IEnumerator AddHandlerAcrossHandlersMany( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { // Many distinct MessageHandlers (bus-level list growth during iteration) + InstanceId targetId = default; + if (scenario.Kind != MessageKind.Untargeted) + { + GameObject targetGo = new( + nameof(AddHandlerAcrossHandlersMany) + "_" + scenario + "_Target" + ); + _spawned.Add(targetGo); + targetId = targetGo; + } + List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = new(); for (int i = 0; i < ManyCount; i++) { - GameObject go = new($"UntargetedBus_{i}", typeof(EmptyMessageAwareComponent)); + GameObject go = new( + $"{nameof(AddHandlerAcrossHandlersMany)}_{scenario}_Bus_{i}", + typeof(EmptyMessageAwareComponent) + ); _spawned.Add(go); EmptyMessageAwareComponent c = go.GetComponent(); listeners.Add((c, GetToken(c))); @@ -175,33 +203,40 @@ public IEnumerator UntargetedAddHandlerAcrossHandlersMany() for (int i = 0; i < listeners.Count; i++) { int idx = i; - MessageRegistrationHandle handle = listeners[i] - .token.RegisterUntargeted(_ => + MessageRegistrationToken listenerToken = listeners[i].token; + Action onInvoke = () => + { + counts[idx]++; + if (!added && idx == 0) { - counts[idx]++; - if (!added && idx == 0) - { - added = true; - GameObject extra = new( - "UntargetedBus_Extra", - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(extra); - EmptyMessageAwareComponent extraComp = - extra.GetComponent(); - MessageRegistrationToken extraToken = GetToken(extraComp); - MessageRegistrationHandle extraHandle = - extraToken.RegisterUntargeted(_ => - counts[ManyCount]++ - ); - handles.Add((extraToken, extraHandle)); - } - }); - handles.Add((listeners[i].token, handle)); + added = true; + GameObject extra = new( + $"{nameof(AddHandlerAcrossHandlersMany)}_{scenario}_Bus_Extra", + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(extra); + EmptyMessageAwareComponent extraComp = + extra.GetComponent(); + MessageRegistrationToken extraToken = GetToken(extraComp); + MessageRegistrationHandle extraHandle = RegisterCounter( + scenario, + extraToken, + targetId, + () => counts[ManyCount]++ + ); + handles.Add((extraToken, extraHandle)); + } + }; + MessageRegistrationHandle handle = RegisterCounter( + scenario, + listenerToken, + targetId, + onInvoke + ); + handles.Add((listenerToken, handle)); } - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, targetId); int total = 0; for (int i = 0; i < ManyCount; i++) @@ -220,7 +255,7 @@ public IEnumerator UntargetedAddHandlerAcrossHandlersMany() "Newly added MessageHandler must not run in the same emission." ); - msg.EmitUntargeted(); + EmitForScenario(scenario, targetId); total = 0; for (int i = 0; i < ManyCount; i++) { @@ -248,10 +283,11 @@ public IEnumerator UntargetedAddHandlerAcrossHandlersMany() } [UnityTest] - public IEnumerator TargetedAddLocalHandlerMany() + [Category("Stress")] + public IEnumerator TargetedWithoutTargetingAddLocalHandlerMany() { GameObject host = new( - nameof(TargetedAddLocalHandlerMany), + nameof(TargetedWithoutTargetingAddLocalHandlerMany), typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); @@ -265,18 +301,16 @@ public IEnumerator TargetedAddLocalHandlerMany() for (int i = 0; i < ManyCount; i++) { int idx = i; - handles[idx] = token.RegisterGameObjectTargeted( - host, - _ => + handles[idx] = token.RegisterTargetedWithoutTargeting( + (_, _) => { counts[idx]++; if (!added && idx == 0) { added = true; handles[ManyCount] = - token.RegisterGameObjectTargeted( - host, - _ => counts[ManyCount]++ + token.RegisterTargetedWithoutTargeting( + (_, _) => counts[ManyCount]++ ); } } @@ -294,12 +328,12 @@ public IEnumerator TargetedAddLocalHandlerMany() Assert.AreEqual( ManyCount, total, - "All baseline targeted handlers should run on first emission." + "All baseline targeted-without-targeting handlers should run on first emission." ); Assert.AreEqual( 0, counts[ManyCount], - "Newly added handler must not run in the same targeted emission." + "Newly added handler must not run in the same emission." ); msg.EmitGameObjectTargeted(host); @@ -312,12 +346,12 @@ public IEnumerator TargetedAddLocalHandlerMany() Assert.AreEqual( ManyCount * 2, total, - "Baseline targeted handlers should run again on second emission." + "Baseline handlers should run again on second emission." ); Assert.AreEqual( 1, counts[ManyCount], - "New targeted handler should run starting on the second emission." + "New handler should run starting on the second emission." ); for (int i = 0; i < handles.Length; i++) @@ -331,15 +365,15 @@ public IEnumerator TargetedAddLocalHandlerMany() } [UnityTest] - public IEnumerator BroadcastAddLocalHandlerMany() + [Category("Stress")] + public IEnumerator BroadcastWithoutSourceAddLocalHandlerMany() { - GameObject source = new( - nameof(BroadcastAddLocalHandlerMany), + GameObject host = new( + nameof(BroadcastWithoutSourceAddLocalHandlerMany), typeof(EmptyMessageAwareComponent) ); - _spawned.Add(source); - EmptyMessageAwareComponent component = - source.GetComponent(); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); int[] counts = new int[ManyCount + 1]; @@ -349,18 +383,16 @@ public IEnumerator BroadcastAddLocalHandlerMany() for (int i = 0; i < ManyCount; i++) { int idx = i; - handles[idx] = token.RegisterGameObjectBroadcast( - source, - _ => + handles[idx] = token.RegisterBroadcastWithoutSource( + (_, _) => { counts[idx]++; if (!added && idx == 0) { added = true; handles[ManyCount] = - token.RegisterGameObjectBroadcast( - source, - _ => counts[ManyCount]++ + token.RegisterBroadcastWithoutSource( + (_, _) => counts[ManyCount]++ ); } } @@ -368,7 +400,7 @@ public IEnumerator BroadcastAddLocalHandlerMany() } SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(source); + msg.EmitComponentBroadcast(component); int total = 0; for (int i = 0; i < ManyCount; i++) { @@ -378,15 +410,15 @@ public IEnumerator BroadcastAddLocalHandlerMany() Assert.AreEqual( ManyCount, total, - "All baseline broadcast handlers should run on first emission." + "All baseline broadcast-without-source handlers should run on first emission." ); Assert.AreEqual( 0, counts[ManyCount], - "Newly added broadcast handler must not run in the same emission." + "Newly added handler must not run in the same emission." ); - msg.EmitGameObjectBroadcast(source); + msg.EmitComponentBroadcast(component); total = 0; for (int i = 0; i < ManyCount; i++) { @@ -396,12 +428,12 @@ public IEnumerator BroadcastAddLocalHandlerMany() Assert.AreEqual( ManyCount * 2, total, - "Baseline broadcast handlers should run again on second emission." + "Baseline handlers should run again on second emission." ); Assert.AreEqual( 1, counts[ManyCount], - "New broadcast handler should run starting on the second emission." + "New handler should run starting on the second emission." ); for (int i = 0; i < handles.Length; i++) @@ -414,169 +446,163 @@ public IEnumerator BroadcastAddLocalHandlerMany() yield break; } + /// + /// Snapshot semantics regression: a handler at one priority bucket must + /// be allowed to deregister a handler at a later priority bucket, and + /// the deregistered handler must still fire on the in-flight emission + /// because its delegate was captured by the snapshot taken before any + /// handler ran. The TargetedWithoutTargeting dispatch path used to + /// snapshot per-bucket lazily inside the dispatch loop, so the + /// later-bucket snapshot was rebuilt after the earlier bucket's + /// handler had already mutated the typed cache, dropping the entry. + /// [UnityTest] - public IEnumerator TargetedWithoutTargetingAddLocalHandlerMany() + public IEnumerator TargetedWithoutTargetingDeregisterAcrossPrioritiesIsHonouredOnCurrentSnapshot() { GameObject host = new( - nameof(TargetedWithoutTargetingAddLocalHandlerMany), + nameof( + TargetedWithoutTargetingDeregisterAcrossPrioritiesIsHonouredOnCurrentSnapshot + ), typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); - int[] counts = new int[ManyCount + 1]; - MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount + 1]; - bool added = false; + int firstCount = 0; + int secondCount = 0; + MessageRegistrationHandle secondHandle = default; - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - handles[idx] = token.RegisterTargetedWithoutTargeting( - (_, _) => + _ = token.RegisterTargetedWithoutTargeting( + (_, _) => + { + ++firstCount; + if (secondHandle != default) { - counts[idx]++; - if (!added && idx == 0) - { - added = true; - handles[ManyCount] = - token.RegisterTargetedWithoutTargeting( - (_, _) => counts[ManyCount]++ - ); - } + token.RemoveRegistration(secondHandle); + secondHandle = default; } - ); - } + }, + priority: 0 + ); + + secondHandle = token.RegisterTargetedWithoutTargeting( + (_, _) => ++secondCount, + priority: 1 + ); SimpleTargetedMessage msg = new(); msg.EmitGameObjectTargeted(host); - int total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - Assert.AreEqual( - ManyCount, - total, - "All baseline targeted-without-targeting handlers should run on first emission." + 1, + firstCount, + "First emission must invoke primary exactly once. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); Assert.AreEqual( - 0, - counts[ManyCount], - "Newly added handler must not run in the same emission." + 1, + secondCount, + "Snapshot frozen at emission start must invoke handler scheduled for removal. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); msg.EmitGameObjectTargeted(host); - total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - Assert.AreEqual( - ManyCount * 2, - total, - "Baseline handlers should run again on second emission." + 2, + firstCount, + "Second emission must invoke primary again. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); Assert.AreEqual( 1, - counts[ManyCount], - "New handler should run starting on the second emission." + secondCount, + "Removed handler must not run on the next emission once snapshot is rebuilt. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); - - for (int i = 0; i < handles.Length; i++) - { - if (handles[i] != default) - { - token.RemoveRegistration(handles[i]); - } - } yield break; } + /// + /// Snapshot semantics regression mirror for BroadcastWithoutSource. The + /// dispatch path previously prefroze per-MessageHandler typed caches + /// only inside RunBroadcastWithoutSource (lazily, per priority bucket) + /// so a removal performed by an earlier bucket polluted the later + /// bucket's snapshot. + /// [UnityTest] - public IEnumerator BroadcastWithoutSourceAddLocalHandlerMany() + public IEnumerator BroadcastWithoutSourceDeregisterAcrossPrioritiesIsHonouredOnCurrentSnapshot() { GameObject host = new( - nameof(BroadcastWithoutSourceAddLocalHandlerMany), + nameof(BroadcastWithoutSourceDeregisterAcrossPrioritiesIsHonouredOnCurrentSnapshot), typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); - int[] counts = new int[ManyCount + 1]; - MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount + 1]; - bool added = false; + int firstCount = 0; + int secondCount = 0; + MessageRegistrationHandle secondHandle = default; - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - handles[idx] = token.RegisterBroadcastWithoutSource( - (_, _) => + _ = token.RegisterBroadcastWithoutSource( + (_, _) => + { + ++firstCount; + if (secondHandle != default) { - counts[idx]++; - if (!added && idx == 0) - { - added = true; - handles[ManyCount] = - token.RegisterBroadcastWithoutSource( - (_, _) => counts[ManyCount]++ - ); - } + token.RemoveRegistration(secondHandle); + secondHandle = default; } - ); - } + }, + priority: 0 + ); + + secondHandle = token.RegisterBroadcastWithoutSource( + (_, _) => ++secondCount, + priority: 1 + ); SimpleBroadcastMessage msg = new(); msg.EmitComponentBroadcast(component); - int total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - Assert.AreEqual( - ManyCount, - total, - "All baseline broadcast-without-source handlers should run on first emission." + 1, + firstCount, + "First emission must invoke primary exactly once. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); Assert.AreEqual( - 0, - counts[ManyCount], - "Newly added handler must not run in the same emission." + 1, + secondCount, + "Snapshot frozen at emission start must invoke handler scheduled for removal. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); msg.EmitComponentBroadcast(component); - total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - Assert.AreEqual( - ManyCount * 2, - total, - "Baseline handlers should run again on second emission." + 2, + firstCount, + "Second emission must invoke primary again. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); Assert.AreEqual( 1, - counts[ManyCount], - "New handler should run starting on the second emission." + secondCount, + "Removed handler must not run on the next emission once snapshot is rebuilt. firstCount={0}, secondCount={1}.", + firstCount, + secondCount ); - - for (int i = 0; i < handles.Length; i++) - { - if (handles[i] != default) - { - token.RemoveRegistration(handles[i]); - } - } yield break; } [UnityTest] + [Category("Stress")] public IEnumerator GlobalAcceptAllAddDuringHandlerMany() { // Create several listeners that globally accept all; add one more during handling; ensure it runs next pass only @@ -676,25 +702,36 @@ public IEnumerator GlobalAcceptAllAddDuringHandlerMany() } [UnityTest] - public IEnumerator UntargetedAddInterceptorDuringInterceptorDoesNotRunInSameEmission() + public IEnumerator AddInterceptorDuringInterceptorDoesNotRunInSameEmission( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { - GameObject host = new("InterceptorHost", typeof(EmptyMessageAwareComponent)); + GameObject host = new( + nameof(AddInterceptorDuringInterceptorDoesNotRunInSameEmission) + "_" + scenario, + typeof(EmptyMessageAwareComponent) + ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; int firstCount = 0; int secondCount = 0; MessageRegistrationHandle? second = null; - MessageRegistrationHandle first = token.RegisterUntargetedInterceptor( - (ref SimpleUntargetedMessage _) => + MessageRegistrationHandle first = RegisterInterceptor( + scenario, + token, + () => { firstCount++; if (second == null) { - second = token.RegisterUntargetedInterceptor( - (ref SimpleUntargetedMessage __) => + second = RegisterInterceptor( + scenario, + token, + () => { secondCount++; return true; @@ -706,8 +743,7 @@ public IEnumerator UntargetedAddInterceptorDuringInterceptorDoesNotRunInSameEmis } ); - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual( 1, firstCount, @@ -715,7 +751,7 @@ public IEnumerator UntargetedAddInterceptorDuringInterceptorDoesNotRunInSameEmis ); Assert.AreEqual(0, secondCount, "New interceptor should not run in the same emission."); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual( 2, firstCount, @@ -736,48 +772,53 @@ public IEnumerator UntargetedAddInterceptorDuringInterceptorDoesNotRunInSameEmis } [UnityTest] - public IEnumerator UntargetedAddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany() + [Category("Stress")] + public IEnumerator AddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedAddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany), + nameof(AddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany) + "_" + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; int[] handlerCounts = new int[ManyCount]; - MessageRegistrationHandle[] handlerHandles = new MessageRegistrationHandle[ManyCount]; int[] ppCounts = new int[ManyCount + 1]; + MessageRegistrationHandle[] handlerHandles = new MessageRegistrationHandle[ManyCount]; MessageRegistrationHandle ppHandle = default; bool added = false; for (int i = 0; i < ManyCount; i++) { int idx = i; - handlerHandles[idx] = token.RegisterUntargeted(_ => - { - handlerCounts[idx]++; - if (!added && idx == 0) + handlerHandles[idx] = RegisterCounter( + scenario, + token, + hostId, + () => { - added = true; - ppHandle = token.RegisterUntargetedPostProcessor( - (ref SimpleUntargetedMessage _) => ppCounts[ManyCount]++ - ); + handlerCounts[idx]++; + if (!added && idx == 0) + { + added = true; + ppHandle = RegisterPostProcessor( + scenario, + token, + hostId, + () => ppCounts[ManyCount]++ + ); + } } - }); - } - - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - _ = token.RegisterUntargetedPostProcessor( - (ref SimpleUntargetedMessage _) => ppCounts[idx]++ ); + _ = RegisterPostProcessor(scenario, token, hostId, () => ppCounts[idx]++); } - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); int handlerTotal = 0; for (int i = 0; i < ManyCount; i++) @@ -808,7 +849,7 @@ public IEnumerator UntargetedAddPostProcessorDuringHandlerDoesNotRunInSameEmissi "Newly added post-processor must not run in the same emission." ); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); ppTotal = 0; for (int i = 0; i < ManyCount; i++) { @@ -838,45 +879,55 @@ public IEnumerator UntargetedAddPostProcessorDuringHandlerDoesNotRunInSameEmissi } [UnityTest] - public IEnumerator UntargetedAddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany() + [Category("Stress")] + public IEnumerator AddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedAddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany), + nameof(AddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany) + + "_" + + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; int[] ppCounts = new int[ManyCount + 1]; MessageRegistrationHandle[] ppHandles = new MessageRegistrationHandle[ManyCount + 1]; - bool added = false; + // Ensure there is at least one handler so post-processors will run + MessageRegistrationHandle hdl = RegisterCounter(scenario, token, hostId, () => { }); + + bool added = false; for (int i = 0; i < ManyCount; i++) { int idx = i; - ppHandles[idx] = token.RegisterUntargetedPostProcessor( - (ref SimpleUntargetedMessage _) => + ppHandles[idx] = RegisterPostProcessor( + scenario, + token, + hostId, + () => { ppCounts[idx]++; if (!added && idx == 0) { added = true; - ppHandles[ManyCount] = token.RegisterUntargetedPostProcessor( - (ref SimpleUntargetedMessage _) => ppCounts[ManyCount]++ + ppHandles[ManyCount] = RegisterPostProcessor( + scenario, + token, + hostId, + () => ppCounts[ManyCount]++ ); } } ); } - // Ensure there is at least one handler so post-processors will run - MessageRegistrationHandle hdl = token.RegisterUntargeted( - (ref SimpleUntargetedMessage _) => { } - ); - - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); int total = 0; for (int i = 0; i < ManyCount; i++) { @@ -894,7 +945,7 @@ public IEnumerator UntargetedAddPostProcessorDuringPostProcessorDoesNotRunInSame "Newly added post-processor must not run in the same emission." ); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); total = 0; for (int i = 0; i < ManyCount; i++) { @@ -924,21 +975,22 @@ public IEnumerator UntargetedAddPostProcessorDuringPostProcessorDoesNotRunInSame } [UnityTest] - public IEnumerator TargetedAddHandlerAcrossHandlersMany() + [Category("Stress")] + public IEnumerator TargetedWithoutTargetingAddHandlerAcrossHandlersMany() { - GameObject target = new("TargetedAddHandlerAcrossHandlersMany_Target"); - _spawned.Add(target); - List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = new(); for (int i = 0; i < ManyCount; i++) { - GameObject go = new($"TargetedBus_{i}", typeof(EmptyMessageAwareComponent)); + GameObject go = new($"TWTBus_{i}", typeof(EmptyMessageAwareComponent)); _spawned.Add(go); EmptyMessageAwareComponent c = go.GetComponent(); listeners.Add((c, GetToken(c))); } + GameObject target = new("TWT_Target"); + _spawned.Add(target); + int[] counts = new int[ManyCount + 1]; List<(MessageRegistrationToken token, MessageRegistrationHandle handle)> handles = new(); @@ -948,16 +1000,15 @@ public IEnumerator TargetedAddHandlerAcrossHandlersMany() { int idx = i; MessageRegistrationHandle handle = listeners[i] - .token.RegisterGameObjectTargeted( - target, - _ => + .token.RegisterTargetedWithoutTargeting( + (_, _) => { counts[idx]++; if (!added && idx == 0) { added = true; GameObject extra = new( - "TargetedBus_Extra", + "TWTBus_Extra", typeof(EmptyMessageAwareComponent) ); _spawned.Add(extra); @@ -965,9 +1016,8 @@ public IEnumerator TargetedAddHandlerAcrossHandlersMany() extra.GetComponent(); MessageRegistrationToken extraToken = GetToken(extraComp); MessageRegistrationHandle extraHandle = - extraToken.RegisterGameObjectTargeted( - target, - _ => counts[ManyCount]++ + extraToken.RegisterTargetedWithoutTargeting( + (_, _) => counts[ManyCount]++ ); handles.Add((extraToken, extraHandle)); } @@ -1007,16 +1057,14 @@ public IEnumerator TargetedAddHandlerAcrossHandlersMany() } [UnityTest] - public IEnumerator BroadcastAddHandlerAcrossHandlersMany() + [Category("Stress")] + public IEnumerator BroadcastWithoutSourceAddHandlerAcrossHandlersMany() { - GameObject source = new(nameof(BroadcastAddHandlerAcrossHandlersMany)); - _spawned.Add(source); - List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = new(); for (int i = 0; i < ManyCount; i++) { - GameObject go = new($"BroadcastBus_{i}", typeof(EmptyMessageAwareComponent)); + GameObject go = new($"BWOBus_{i}", typeof(EmptyMessageAwareComponent)); _spawned.Add(go); EmptyMessageAwareComponent c = go.GetComponent(); listeners.Add((c, GetToken(c))); @@ -1031,16 +1079,15 @@ public IEnumerator BroadcastAddHandlerAcrossHandlersMany() { int idx = i; MessageRegistrationHandle handle = listeners[i] - .token.RegisterGameObjectBroadcast( - source, - _ => + .token.RegisterBroadcastWithoutSource( + (_, _) => { counts[idx]++; if (!added && idx == 0) { added = true; GameObject extra = new( - "BroadcastBus_Extra", + "BWOBus_Extra", typeof(EmptyMessageAwareComponent) ); _spawned.Add(extra); @@ -1048,168 +1095,8 @@ public IEnumerator BroadcastAddHandlerAcrossHandlersMany() extra.GetComponent(); MessageRegistrationToken extraToken = GetToken(extraComp); MessageRegistrationHandle extraHandle = - extraToken.RegisterGameObjectBroadcast( - source, - _ => counts[ManyCount]++ - ); - handles.Add((extraToken, extraHandle)); - } - } - ); - handles.Add((listeners[i].token, handle)); - } - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(source); - int total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - - Assert.AreEqual(ManyCount, total); - Assert.AreEqual(0, counts[ManyCount]); - - msg.EmitGameObjectBroadcast(source); - total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - - Assert.AreEqual(ManyCount * 2, total); - Assert.AreEqual(1, counts[ManyCount]); - - foreach ( - (MessageRegistrationToken token, MessageRegistrationHandle handle) entry in handles - ) - { - entry.token.RemoveRegistration(entry.handle); - } - yield break; - } - - [UnityTest] - public IEnumerator TargetedWithoutTargetingAddHandlerAcrossHandlersMany() - { - List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = - new(); - for (int i = 0; i < ManyCount; i++) - { - GameObject go = new($"TWTBus_{i}", typeof(EmptyMessageAwareComponent)); - _spawned.Add(go); - EmptyMessageAwareComponent c = go.GetComponent(); - listeners.Add((c, GetToken(c))); - } - - GameObject target = new("TWT_Target"); - _spawned.Add(target); - - int[] counts = new int[ManyCount + 1]; - List<(MessageRegistrationToken token, MessageRegistrationHandle handle)> handles = - new(); - bool added = false; - - for (int i = 0; i < listeners.Count; i++) - { - int idx = i; - MessageRegistrationHandle handle = listeners[i] - .token.RegisterTargetedWithoutTargeting( - (_, _) => - { - counts[idx]++; - if (!added && idx == 0) - { - added = true; - GameObject extra = new( - "TWTBus_Extra", - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(extra); - EmptyMessageAwareComponent extraComp = - extra.GetComponent(); - MessageRegistrationToken extraToken = GetToken(extraComp); - MessageRegistrationHandle extraHandle = - extraToken.RegisterTargetedWithoutTargeting( - (_, _) => counts[ManyCount]++ - ); - handles.Add((extraToken, extraHandle)); - } - } - ); - handles.Add((listeners[i].token, handle)); - } - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(target); - int total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - - Assert.AreEqual(ManyCount, total); - Assert.AreEqual(0, counts[ManyCount]); - - msg.EmitGameObjectTargeted(target); - total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += counts[i]; - } - - Assert.AreEqual(ManyCount * 2, total); - Assert.AreEqual(1, counts[ManyCount]); - - foreach ( - (MessageRegistrationToken token, MessageRegistrationHandle handle) entry in handles - ) - { - entry.token.RemoveRegistration(entry.handle); - } - yield break; - } - - [UnityTest] - public IEnumerator BroadcastWithoutSourceAddHandlerAcrossHandlersMany() - { - List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = - new(); - for (int i = 0; i < ManyCount; i++) - { - GameObject go = new($"BWOBus_{i}", typeof(EmptyMessageAwareComponent)); - _spawned.Add(go); - EmptyMessageAwareComponent c = go.GetComponent(); - listeners.Add((c, GetToken(c))); - } - - int[] counts = new int[ManyCount + 1]; - List<(MessageRegistrationToken token, MessageRegistrationHandle handle)> handles = - new(); - bool added = false; - - for (int i = 0; i < listeners.Count; i++) - { - int idx = i; - MessageRegistrationHandle handle = listeners[i] - .token.RegisterBroadcastWithoutSource( - (_, _) => - { - counts[idx]++; - if (!added && idx == 0) - { - added = true; - GameObject extra = new( - "BWOBus_Extra", - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(extra); - EmptyMessageAwareComponent extraComp = - extra.GetComponent(); - MessageRegistrationToken extraToken = GetToken(extraComp); - MessageRegistrationHandle extraHandle = - extraToken.RegisterBroadcastWithoutSource( - (_, _) => counts[ManyCount]++ + extraToken.RegisterBroadcastWithoutSource( + (_, _) => counts[ManyCount]++ ); handles.Add((extraToken, extraHandle)); } @@ -1249,6 +1136,7 @@ public IEnumerator BroadcastWithoutSourceAddHandlerAcrossHandlersMany() } [UnityTest] + [Category("Stress")] public IEnumerator TargetedWithoutTargetingRemoveOtherAcrossHandlersDuringEmissionMany() { List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = @@ -1303,6 +1191,7 @@ public IEnumerator TargetedWithoutTargetingRemoveOtherAcrossHandlersDuringEmissi } [UnityTest] + [Category("Stress")] public IEnumerator BroadcastWithoutSourceRemoveOtherAcrossHandlersDuringEmissionMany() { List<(EmptyMessageAwareComponent comp, MessageRegistrationToken token)> listeners = @@ -1356,23 +1245,34 @@ public IEnumerator BroadcastWithoutSourceRemoveOtherAcrossHandlersDuringEmission } [UnityTest] - public IEnumerator UntargetedPostProcessorRemoveOtherDuringPostProcessingMany() + [Category("Stress")] + public IEnumerator PostProcessorRemoveOtherDuringPostProcessingMany( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { - GameObject host = new("U_PP_Remove", typeof(EmptyMessageAwareComponent)); + GameObject host = new( + nameof(PostProcessorRemoveOtherDuringPostProcessingMany) + "_" + scenario, + typeof(EmptyMessageAwareComponent) + ); _spawned.Add(host); EmptyMessageAwareComponent comp = host.GetComponent(); MessageRegistrationToken token = GetToken(comp); + InstanceId hostId = host; // Ensure processing stage reached - _ = token.RegisterUntargeted(_ => { }); + _ = RegisterCounter(scenario, token, hostId, () => { }); MessageRegistrationHandle[] pp = new MessageRegistrationHandle[ManyCount]; int[] counts = new int[ManyCount]; for (int i = 0; i < ManyCount; i++) { int idx = i; - pp[idx] = token.RegisterUntargetedPostProcessor( - (ref SimpleUntargetedMessage _) => + pp[idx] = RegisterPostProcessor( + scenario, + token, + hostId, + () => { counts[idx]++; if (idx == 0) @@ -1383,12 +1283,11 @@ public IEnumerator UntargetedPostProcessorRemoveOtherDuringPostProcessingMany() ); } - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual(1, counts[0]); Assert.AreEqual(1, counts[1]); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual(2, counts[0]); Assert.AreEqual(1, counts[1]); @@ -1404,397 +1303,114 @@ public IEnumerator UntargetedPostProcessorRemoveOtherDuringPostProcessingMany() } [UnityTest] - public IEnumerator TargetedPostProcessorRemoveOtherDuringPostProcessingMany() + [Category("Stress")] + public IEnumerator RemoveOtherLocalHandlerDuringEmissionMany( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { - GameObject host = new("T_PP_Remove", typeof(EmptyMessageAwareComponent)); + GameObject host = new( + nameof(RemoveOtherLocalHandlerDuringEmissionMany) + "_" + scenario, + typeof(EmptyMessageAwareComponent) + ); _spawned.Add(host); - EmptyMessageAwareComponent comp = host.GetComponent(); - MessageRegistrationToken token = GetToken(comp); - - // Ensure processing stage reached - _ = token.RegisterGameObjectTargeted(host, _ => { }); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; - MessageRegistrationHandle[] pp = new MessageRegistrationHandle[ManyCount]; int[] counts = new int[ManyCount]; + MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount]; + for (int i = 0; i < ManyCount; i++) { int idx = i; - pp[idx] = token.RegisterGameObjectTargetedPostProcessor( - host, - (ref SimpleTargetedMessage _) => + handles[idx] = RegisterCounter( + scenario, + token, + hostId, + () => { counts[idx]++; if (idx == 0) { - token.RemoveRegistration(pp[1]); + token.RemoveRegistration(handles[1]); } } ); } - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); + EmitForScenario(scenario, hostId); Assert.AreEqual(1, counts[0]); Assert.AreEqual(1, counts[1]); - msg.EmitGameObjectTargeted(host); + EmitForScenario(scenario, hostId); Assert.AreEqual(2, counts[0]); Assert.AreEqual(1, counts[1]); - for (int i = 0; i < pp.Length; i++) + for (int i = 0; i < handles.Length; i++) { if (i == 1) { continue; } - token.RemoveRegistration(pp[i]); + token.RemoveRegistration(handles[i]); } yield break; } [UnityTest] - public IEnumerator BroadcastPostProcessorRemoveOtherDuringPostProcessingMany() + public IEnumerator UntargetedAddSameDelegateDuringEmissionDoesNotDuplicateInvocation() { - GameObject host = new("B_PP_Remove", typeof(EmptyMessageAwareComponent)); + GameObject host = new("SameDelegateHost", typeof(EmptyMessageAwareComponent)); _spawned.Add(host); - EmptyMessageAwareComponent comp = host.GetComponent(); - MessageRegistrationToken token = GetToken(comp); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); - // Ensure processing stage reached - _ = token.RegisterGameObjectBroadcast(host, _ => { }); + int count = 0; + MessageRegistrationHandle firstHandle = default; + MessageRegistrationHandle? secondHandle = null; - MessageRegistrationHandle[] pp = new MessageRegistrationHandle[ManyCount]; - int[] counts = new int[ManyCount]; - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - pp[idx] = token.RegisterGameObjectBroadcastPostProcessor( - host, - _ => - { - counts[idx]++; - if (idx == 0) - { - token.RemoveRegistration(pp[1]); - } - } - ); - } + firstHandle = token.RegisterUntargeted(Local); - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(1, counts[0]); - Assert.AreEqual(1, counts[1]); + SimpleUntargetedMessage msg = new(); + msg.EmitUntargeted(); + Assert.AreEqual(1, count); - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(2, counts[0]); - Assert.AreEqual(1, counts[1]); + msg.EmitUntargeted(); + Assert.AreEqual(2, count); - for (int i = 0; i < pp.Length; i++) + token.RemoveRegistration(firstHandle); + if (secondHandle.HasValue) { - if (i == 1) + token.RemoveRegistration(secondHandle.Value); + } + yield break; + + void Local(SimpleUntargetedMessage _) + { + count++; + if (secondHandle == null) { - continue; + secondHandle = token.RegisterUntargeted(Local); } - token.RemoveRegistration(pp[i]); } - yield break; } [UnityTest] - public IEnumerator UntargetedRemoveOtherLocalHandlerDuringEmissionMany() + public IEnumerator UntargetedAddLowerPriorityDuringEmissionRespectsNextEmissionOrder() { - GameObject host = new( - nameof(UntargetedRemoveOtherLocalHandlerDuringEmissionMany), - typeof(EmptyMessageAwareComponent) - ); + GameObject host = new("PriorityHost", typeof(EmptyMessageAwareComponent)); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); - int[] counts = new int[ManyCount]; - MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount]; + List order = new(); + MessageRegistrationHandle lowHandle = default; + bool added = false; - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - handles[idx] = token.RegisterUntargeted(_ => - { - counts[idx]++; - if (idx == 0) - { - token.RemoveRegistration(handles[1]); - } - }); - } - - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); - Assert.AreEqual(1, counts[0]); - Assert.AreEqual(1, counts[1]); - - msg.EmitUntargeted(); - Assert.AreEqual(2, counts[0]); - Assert.AreEqual(1, counts[1]); - - for (int i = 0; i < handles.Length; i++) - { - if (i == 1) - { - continue; - } - token.RemoveRegistration(handles[i]); - } - yield break; - } - - [UnityTest] - public IEnumerator TargetedRemoveOtherLocalHandlerDuringEmissionMany() - { - GameObject host = new( - nameof(TargetedRemoveOtherLocalHandlerDuringEmissionMany), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int[] counts = new int[ManyCount]; - MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount]; - - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - handles[idx] = token.RegisterGameObjectTargeted( - host, - _ => - { - counts[idx]++; - if (idx == 0) - { - token.RemoveRegistration(handles[1]); - } - } - ); - } - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(1, counts[0]); - Assert.AreEqual(1, counts[1]); - - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(2, counts[0]); - Assert.AreEqual(1, counts[1]); - - for (int i = 0; i < handles.Length; i++) - { - if (i == 1) - { - continue; - } - token.RemoveRegistration(handles[i]); - } - yield break; - } - - [UnityTest] - public IEnumerator BroadcastRemoveOtherLocalHandlerDuringEmissionMany() - { - GameObject host = new( - nameof(BroadcastRemoveOtherLocalHandlerDuringEmissionMany), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int[] counts = new int[ManyCount]; - MessageRegistrationHandle[] handles = new MessageRegistrationHandle[ManyCount]; - - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - handles[idx] = token.RegisterGameObjectBroadcast( - host, - _ => - { - counts[idx]++; - if (idx == 0) - { - token.RemoveRegistration(handles[1]); - } - } - ); - } - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(1, counts[0]); - Assert.AreEqual(1, counts[1]); - - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(2, counts[0]); - Assert.AreEqual(1, counts[1]); - - for (int i = 0; i < handles.Length; i++) - { - if (i == 1) - { - continue; - } - token.RemoveRegistration(handles[i]); - } - yield break; - } - - [UnityTest] - public IEnumerator TargetedAddInterceptorDuringInterceptorDoesNotRunInSameEmission() - { - GameObject host = new("TargetedInterceptorHost", typeof(EmptyMessageAwareComponent)); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int firstCount = 0; - int secondCount = 0; - MessageRegistrationHandle? second = null; - - MessageRegistrationHandle first = token.RegisterTargetedInterceptor( - (ref InstanceId _, ref SimpleTargetedMessage __) => - { - firstCount++; - if (second == null) - { - second = token.RegisterTargetedInterceptor( - (ref InstanceId __1, ref SimpleTargetedMessage __2) => - { - secondCount++; - return true; - } - ); - } - return true; - } - ); - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(1, firstCount); - Assert.AreEqual(0, secondCount); - - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(2, firstCount); - Assert.AreEqual(1, secondCount); - - token.RemoveRegistration(first); - if (second.HasValue) - { - token.RemoveRegistration(second.Value); - } - yield break; - } - - [UnityTest] - public IEnumerator BroadcastAddInterceptorDuringInterceptorDoesNotRunInSameEmission() - { - GameObject host = new("BroadcastInterceptorHost", typeof(EmptyMessageAwareComponent)); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int firstCount = 0; - int secondCount = 0; - MessageRegistrationHandle? second = null; - - MessageRegistrationHandle first = token.RegisterBroadcastInterceptor( - (ref InstanceId _, ref SimpleBroadcastMessage __) => - { - firstCount++; - if (second == null) - { - second = token.RegisterBroadcastInterceptor( - (ref InstanceId __1, ref SimpleBroadcastMessage __2) => - { - secondCount++; - return true; - } - ); - } - return true; - } - ); - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(1, firstCount); - Assert.AreEqual(0, secondCount); - - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(2, firstCount); - Assert.AreEqual(1, secondCount); - - token.RemoveRegistration(first); - if (second.HasValue) - { - token.RemoveRegistration(second.Value); - } - yield break; - } - - [UnityTest] - public IEnumerator UntargetedAddSameDelegateDuringEmissionDoesNotDuplicateInvocation() - { - GameObject host = new("SameDelegateHost", typeof(EmptyMessageAwareComponent)); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int count = 0; - MessageRegistrationHandle firstHandle = default; - MessageRegistrationHandle? secondHandle = null; - - firstHandle = token.RegisterUntargeted(Local); - - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); - Assert.AreEqual(1, count); - - msg.EmitUntargeted(); - Assert.AreEqual(2, count); - - token.RemoveRegistration(firstHandle); - if (secondHandle.HasValue) - { - token.RemoveRegistration(secondHandle.Value); - } - yield break; - - void Local(SimpleUntargetedMessage _) - { - count++; - if (secondHandle == null) - { - secondHandle = token.RegisterUntargeted(Local); - } - } - } - - [UnityTest] - public IEnumerator UntargetedAddLowerPriorityDuringEmissionRespectsNextEmissionOrder() - { - GameObject host = new("PriorityHost", typeof(EmptyMessageAwareComponent)); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - List order = new(); - MessageRegistrationHandle lowHandle = default; - bool added = false; - - MessageRegistrationHandle highHandle = - token.RegisterUntargeted( - _ => + MessageRegistrationHandle highHandle = + token.RegisterUntargeted( + _ => { order.Add(1); if (!added) @@ -1825,315 +1441,184 @@ public IEnumerator UntargetedAddLowerPriorityDuringEmissionRespectsNextEmissionO yield break; } - [UnityTest] - public IEnumerator TargetedAddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany() + private static MessageRegistrationHandle RegisterCounter( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) { - GameObject host = new( - nameof(TargetedAddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int[] handlerCounts = new int[ManyCount]; - int[] ppCounts = new int[ManyCount + 1]; - MessageRegistrationHandle[] handlerHandles = new MessageRegistrationHandle[ManyCount]; - MessageRegistrationHandle ppHandle = default; - bool added = false; - - for (int i = 0; i < ManyCount; i++) + switch (scenario.Kind) { - int idx = i; - handlerHandles[idx] = token.RegisterGameObjectTargeted( - host, - _ => - { - handlerCounts[idx]++; - if (!added && idx == 0) - { - added = true; - ppHandle = token.RegisterGameObjectTargetedPostProcessor( - host, - (ref SimpleTargetedMessage _) => ppCounts[ManyCount]++ - ); - } - } - ); - _ = token.RegisterGameObjectTargetedPostProcessor( - host, - (ref SimpleTargetedMessage _) => ppCounts[idx]++ - ); - } - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - - int handlerTotal = 0; - for (int i = 0; i < ManyCount; i++) - { - handlerTotal += handlerCounts[i]; - } - - Assert.AreEqual(ManyCount, handlerTotal); - - int ppTotal = 0; - for (int i = 0; i < ManyCount; i++) - { - ppTotal += ppCounts[i]; - } - - Assert.AreEqual(ManyCount, ppTotal); - Assert.AreEqual(0, ppCounts[ManyCount]); - - msg.EmitGameObjectTargeted(host); - ppTotal = 0; - for (int i = 0; i < ManyCount; i++) - { - ppTotal += ppCounts[i]; - } - - Assert.AreEqual(ManyCount * 2, ppTotal); - Assert.AreEqual(1, ppCounts[ManyCount]); - - foreach (MessageRegistrationHandle h in handlerHandles) - { - token.RemoveRegistration(h); - } - if (ppHandle != default) - { - token.RemoveRegistration(ppHandle); + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } } - yield break; } - [UnityTest] - public IEnumerator TargetedAddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany() + private static MessageRegistrationHandle RegisterPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) { - GameObject host = new( - nameof(TargetedAddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int[] ppCounts = new int[ManyCount + 1]; - MessageRegistrationHandle[] ppHandles = new MessageRegistrationHandle[ManyCount + 1]; - - // Ensure there is a handler so post processing will run - MessageRegistrationHandle hdl = token.RegisterGameObjectTargeted( - host, - _ => { } - ); - - bool added = false; - for (int i = 0; i < ManyCount; i++) - { - int idx = i; - ppHandles[idx] = token.RegisterGameObjectTargetedPostProcessor( - host, - (ref SimpleTargetedMessage _) => - { - ppCounts[idx]++; - if (!added && idx == 0) - { - added = true; - ppHandles[ManyCount] = token.RegisterGameObjectTargetedPostProcessor( - host, - (ref SimpleTargetedMessage __) => ppCounts[ManyCount]++ - ); - } - } - ); - } - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - int total = 0; - for (int i = 0; i < ManyCount; i++) + switch (scenario.Kind) { - total += ppCounts[i]; - } - - Assert.AreEqual(ManyCount, total); - Assert.AreEqual(0, ppCounts[ManyCount]); - - msg.EmitGameObjectTargeted(host); - total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += ppCounts[i]; - } - - Assert.AreEqual(ManyCount * 2, total); - Assert.AreEqual(1, ppCounts[ManyCount]); - - token.RemoveRegistration(hdl); - for (int i = 0; i < ppHandles.Length; i++) - { - if (ppHandles[i] != default) + case MessageKind.Untargeted: { - token.RemoveRegistration(ppHandles[i]); + return ScenarioHarness.RegisterUntargetedPostProcessor( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedPostProcessor( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastPostProcessor( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); } } - yield break; } - [UnityTest] - public IEnumerator BroadcastAddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany() + private static MessageRegistrationHandle RegisterInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Func body, + int priority = 0 + ) { - GameObject host = new( - nameof(BroadcastAddPostProcessorDuringHandlerDoesNotRunInSameEmissionMany), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int[] handlerCounts = new int[ManyCount]; - int[] ppCounts = new int[ManyCount + 1]; - MessageRegistrationHandle[] handlerHandles = new MessageRegistrationHandle[ManyCount]; - MessageRegistrationHandle ppHandle = default; - bool added = false; - - for (int i = 0; i < ManyCount; i++) + switch (scenario.Kind) { - int idx = i; - handlerHandles[idx] = token.RegisterGameObjectBroadcast( - host, - _ => - { - handlerCounts[idx]++; - if (!added && idx == 0) - { - added = true; - ppHandle = - token.RegisterGameObjectBroadcastPostProcessor( - host, - _ => ppCounts[ManyCount]++ - ); - } - } - ); - _ = token.RegisterGameObjectBroadcastPostProcessor( - host, - _ => ppCounts[idx]++ - ); - } - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); - - int handlerTotal = 0; - for (int i = 0; i < ManyCount; i++) - { - handlerTotal += handlerCounts[i]; - } - - Assert.AreEqual(ManyCount, handlerTotal); - - int ppTotal = 0; - for (int i = 0; i < ManyCount; i++) - { - ppTotal += ppCounts[i]; - } - - Assert.AreEqual(ManyCount, ppTotal); - Assert.AreEqual(0, ppCounts[ManyCount]); - - msg.EmitGameObjectBroadcast(host); - ppTotal = 0; - for (int i = 0; i < ManyCount; i++) - { - ppTotal += ppCounts[i]; - } - - Assert.AreEqual(ManyCount * 2, ppTotal); - Assert.AreEqual(1, ppCounts[ManyCount]); - - foreach (MessageRegistrationHandle h in handlerHandles) - { - token.RemoveRegistration(h); - } - if (ppHandle != default) - { - token.RemoveRegistration(ppHandle); + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => body(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage __) => body(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage __) => body(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } } - yield break; } - [UnityTest] - public IEnumerator BroadcastAddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany() + private static void EmitForScenario(MessageScenario scenario, InstanceId target) { - GameObject host = new( - nameof(BroadcastAddPostProcessorDuringPostProcessorDoesNotRunInSameEmissionMany), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - int[] ppCounts = new int[ManyCount + 1]; - MessageRegistrationHandle[] ppHandles = new MessageRegistrationHandle[ManyCount + 1]; - - // Ensure at least one handler exists so post-processing runs - MessageRegistrationHandle hdl = - token.RegisterGameObjectBroadcast(host, _ => { }); - - bool added = false; - for (int i = 0; i < ManyCount; i++) + switch (scenario.Kind) { - int idx = i; - ppHandles[idx] = - token.RegisterGameObjectBroadcastPostProcessor( - host, - _ => - { - ppCounts[idx]++; - if (!added && idx == 0) - { - added = true; - ppHandles[ManyCount] = - token.RegisterGameObjectBroadcastPostProcessor( - host, - _ => ppCounts[ManyCount]++ - ); - } - } - ); - } - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); - int total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += ppCounts[i]; - } - - Assert.AreEqual(ManyCount, total); - Assert.AreEqual(0, ppCounts[ManyCount]); - - msg.EmitGameObjectBroadcast(host); - total = 0; - for (int i = 0; i < ManyCount; i++) - { - total += ppCounts[i]; - } - - Assert.AreEqual(ManyCount * 2, total); - Assert.AreEqual(1, ppCounts[ManyCount]); - - token.RemoveRegistration(hdl); - for (int i = 0; i < ppHandles.Length; i++) - { - if (ppHandles[i] != default) + case MessageKind.Untargeted: { - token.RemoveRegistration(ppHandles[i]); + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); } } - yield break; } } } diff --git a/Tests/Runtime/Core/MutationHighVolumeStressTests.cs b/Tests/Runtime/Core/MutationHighVolumeStressTests.cs index 09e9d946..a846c97c 100644 --- a/Tests/Runtime/Core/MutationHighVolumeStressTests.cs +++ b/Tests/Runtime/Core/MutationHighVolumeStressTests.cs @@ -19,6 +19,7 @@ namespace DxMessaging.Tests.Runtime.Core /// emission they were created in, but must participate in subsequent emissions. These tests cover normal handlers, /// interceptors, global accept-all registrations, and post-processors. /// + [Category("Stress")] public sealed class MutationHighVolumeStressTests : MessagingTestBase { private const int InitialListenerCount = 32; diff --git a/Tests/Runtime/Core/MutationInterceptorTests.cs b/Tests/Runtime/Core/MutationInterceptorTests.cs index 22796f5d..8fad41f2 100644 --- a/Tests/Runtime/Core/MutationInterceptorTests.cs +++ b/Tests/Runtime/Core/MutationInterceptorTests.cs @@ -1,9 +1,11 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using DxMessaging.Core; using DxMessaging.Core.Extensions; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; @@ -13,28 +15,36 @@ namespace DxMessaging.Tests.Runtime.Core public sealed class MutationInterceptorTests : MessagingTestBase { [UnityTest] - public IEnumerator UntargetedAddBlockingInterceptorDuringInterceptor() + public IEnumerator AddBlockingInterceptorDuringInterceptor( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedAddBlockingInterceptorDuringInterceptor), + nameof(AddBlockingInterceptorDuringInterceptor) + "_" + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent comp = host.GetComponent(); MessageRegistrationToken token = GetToken(comp); + InstanceId hostId = host; int first = 0; int second = 0; MessageRegistrationHandle? secondHandle = null; - MessageRegistrationHandle firstHandle = token.RegisterUntargetedInterceptor( - (ref SimpleUntargetedMessage _) => + MessageRegistrationHandle firstHandle = RegisterInterceptor( + scenario, + token, + () => { first++; if (secondHandle == null) { - secondHandle = token.RegisterUntargetedInterceptor( - (ref SimpleUntargetedMessage __) => + secondHandle = RegisterInterceptor( + scenario, + token, + () => { second++; return true; @@ -46,8 +56,7 @@ public IEnumerator UntargetedAddBlockingInterceptorDuringInterceptor() } ); - SimpleUntargetedMessage msg = new(); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual(1, first); Assert.AreEqual( 0, @@ -55,13 +64,13 @@ public IEnumerator UntargetedAddBlockingInterceptorDuringInterceptor() "New interceptor must not run in the same emission when blocked." ); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual(2, first); Assert.AreEqual(0, second, "Pipeline remains blocked by first; second not invoked."); // Unblock by removing the first token.RemoveRegistration(firstHandle); - msg.EmitUntargeted(); + EmitForScenario(scenario, hostId); Assert.AreEqual(2, first); Assert.AreEqual(1, second, "Second runs once first no longer blocks."); @@ -73,116 +82,84 @@ public IEnumerator UntargetedAddBlockingInterceptorDuringInterceptor() yield break; } - [UnityTest] - public IEnumerator TargetedAddBlockingInterceptorDuringInterceptor() + private static MessageRegistrationHandle RegisterInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Func body, + int priority = 0 + ) { - GameObject host = new( - nameof(TargetedAddBlockingInterceptorDuringInterceptor), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent comp = host.GetComponent(); - MessageRegistrationToken token = GetToken(comp); - - int first = 0; - int second = 0; - MessageRegistrationHandle? secondHandle = null; - - MessageRegistrationHandle firstHandle = token.RegisterTargetedInterceptor( - (ref InstanceId _, ref SimpleTargetedMessage __) => + switch (scenario.Kind) + { + case MessageKind.Untargeted: { - first++; - if (secondHandle == null) - { - secondHandle = token.RegisterTargetedInterceptor( - (ref InstanceId __1, ref SimpleTargetedMessage __2) => - { - second++; - return true; - } - ); - } - - return false; + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => body(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage __) => body(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage __) => body(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); } - ); - - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(1, first); - Assert.AreEqual(0, second); - - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(2, first); - Assert.AreEqual(0, second); - - token.RemoveRegistration(firstHandle); - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(2, first); - Assert.AreEqual(1, second); - - token.RemoveRegistration(firstHandle); - if (secondHandle.HasValue) - { - token.RemoveRegistration(secondHandle.Value); } - yield break; } - [UnityTest] - public IEnumerator BroadcastAddBlockingInterceptorDuringInterceptor() + private static void EmitForScenario(MessageScenario scenario, InstanceId target) { - GameObject host = new( - nameof(BroadcastAddBlockingInterceptorDuringInterceptor), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent comp = host.GetComponent(); - MessageRegistrationToken token = GetToken(comp); - - int first = 0; - int second = 0; - MessageRegistrationHandle? secondHandle = null; - - MessageRegistrationHandle firstHandle = token.RegisterBroadcastInterceptor( - (ref InstanceId _, ref SimpleBroadcastMessage __) => + switch (scenario.Kind) + { + case MessageKind.Untargeted: { - first++; - if (secondHandle == null) - { - secondHandle = token.RegisterBroadcastInterceptor( - (ref InstanceId __1, ref SimpleBroadcastMessage __2) => - { - second++; - return true; - } - ); - } - - return false; + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); } - ); - - SimpleBroadcastMessage msg = new(); - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(1, first); - Assert.AreEqual(0, second); - - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(2, first); - Assert.AreEqual(0, second); - - token.RemoveRegistration(firstHandle); - msg.EmitGameObjectBroadcast(host); - Assert.AreEqual(2, first); - Assert.AreEqual(1, second); - - token.RemoveRegistration(firstHandle); - if (secondHandle.HasValue) - { - token.RemoveRegistration(secondHandle.Value); } - yield break; } } } diff --git a/Tests/Runtime/Core/MutationPostProcessorMoreTests.cs b/Tests/Runtime/Core/MutationPostProcessorMoreTests.cs index 67903765..36180e49 100644 --- a/Tests/Runtime/Core/MutationPostProcessorMoreTests.cs +++ b/Tests/Runtime/Core/MutationPostProcessorMoreTests.cs @@ -1,7 +1,9 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; + using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Tests.Runtime.Scripts.Components; @@ -12,104 +14,619 @@ namespace DxMessaging.Tests.Runtime.Core public sealed class MutationPostProcessorMoreTests : MessagingTestBase { + public enum PostProcessorVariant + { + Untargeted, + Targeted, + Broadcast, + TargetedWithoutTargeting, + BroadcastWithoutSource, + } + + public static IEnumerable AllVariants + { + get + { + yield return PostProcessorVariant.Untargeted; + yield return PostProcessorVariant.Targeted; + yield return PostProcessorVariant.Broadcast; + yield return PostProcessorVariant.TargetedWithoutTargeting; + yield return PostProcessorVariant.BroadcastWithoutSource; + } + } + + public static IEnumerable SourceFilteredVariants + { + get + { + yield return PostProcessorVariant.Targeted; + yield return PostProcessorVariant.Broadcast; + } + } + [UnityTest] - public IEnumerator TargetedWithoutTargetingAddPostProcessorDuringHandler() + public IEnumerator BaselineFirstEmissionFiresHandlerAndPostProcessor( + [ValueSource(nameof(AllVariants))] PostProcessorVariant variant + ) { GameObject host = new( - nameof(TargetedWithoutTargetingAddPostProcessorDuringHandler), + nameof(BaselineFirstEmissionFiresHandlerAndPostProcessor) + "_" + variant, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); - MessageRegistrationToken token = GetToken( - host.GetComponent() + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + int ppCount = 0; + _ = RegisterHandler(variant, token, hostId, () => ++handlerCount); + _ = RegisterPostProcessor(variant, token, hostId, () => ++ppCount); + + Emit(variant, host); + + Assert.AreEqual( + 1, + handlerCount, + "[{0}] Baseline handler must fire exactly once on first emission. sourceId={1}, handlerCount={2}.", + variant, + hostId.Id, + handlerCount + ); + Assert.AreEqual( + 1, + ppCount, + "[{0}] Baseline post-processor must fire exactly once on first emission. sourceId={1}, ppCount={2}.", + variant, + hostId.Id, + ppCount + ); + yield break; + } + + [UnityTest] + public IEnumerator AddPostProcessorDuringHandlerDoesNotRunInSameEmission( + [ValueSource(nameof(AllVariants))] PostProcessorVariant variant + ) + { + GameObject host = new( + nameof(AddPostProcessorDuringHandlerDoesNotRunInSameEmission) + "_" + variant, + typeof(EmptyMessageAwareComponent) ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; - int[] ppCounts = new int[2]; - MessageRegistrationHandle ppNew = default; + int originalPpCount = 0; + int newPpCount = 0; bool added = false; - // Ensure we have handlers to trigger post-processing - _ = token.RegisterTargetedWithoutTargeting( - (_, _) => + _ = RegisterHandler( + variant, + token, + hostId, + () => { - if (!added) + if (added) { - added = true; - ppNew = token.RegisterTargetedWithoutTargetingPostProcessor( - (ref InstanceId _, ref SimpleTargetedMessage __) => ppCounts[1]++ - ); + return; } + + added = true; + _ = RegisterPostProcessor(variant, token, hostId, () => ++newPpCount); } ); + _ = RegisterPostProcessor(variant, token, hostId, () => ++originalPpCount); + + Emit(variant, host); - _ = token.RegisterTargetedWithoutTargetingPostProcessor( - (ref InstanceId _, ref SimpleTargetedMessage __) => ppCounts[0]++ + Assert.AreEqual( + 1, + originalPpCount, + "[{0}] Original post-processor must run exactly once on first emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); + Assert.AreEqual( + 0, + newPpCount, + "[{0}] Post-processor registered during handler dispatch must not fire on the in-flight emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount ); - SimpleTargetedMessage msg = new(); - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(1, ppCounts[0]); - Assert.AreEqual(0, ppCounts[1]); + Emit(variant, host); - msg.EmitGameObjectTargeted(host); - Assert.AreEqual(2, ppCounts[0]); - Assert.AreEqual(1, ppCounts[1]); + Assert.AreEqual( + 2, + originalPpCount, + "[{0}] Original post-processor must run again on second emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); + Assert.AreEqual( + 1, + newPpCount, + "[{0}] Newly added post-processor must run starting on the second emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); + yield break; + } - if (ppNew != default) - { - token.RemoveRegistration(ppNew); - } + [UnityTest] + public IEnumerator AddPostProcessorAtDifferentPriorityDuringHandlerDoesNotRunInSameEmission( + [ValueSource(nameof(AllVariants))] PostProcessorVariant variant + ) + { + GameObject host = new( + nameof(AddPostProcessorAtDifferentPriorityDuringHandlerDoesNotRunInSameEmission) + + "_" + + variant, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int originalPpCount = 0; + int newPpCount = 0; + bool added = false; + + _ = RegisterHandler( + variant, + token, + hostId, + () => + { + if (added) + { + return; + } + + added = true; + _ = RegisterPostProcessor( + variant, + token, + hostId, + () => ++newPpCount, + priority: 7 + ); + } + ); + _ = RegisterPostProcessor(variant, token, hostId, () => ++originalPpCount, priority: 0); + + Emit(variant, host); + + Assert.AreEqual( + 1, + originalPpCount, + "[{0}] Original post-processor must run exactly once on first emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); + Assert.AreEqual( + 0, + newPpCount, + "[{0}] Post-processor registered at a new priority during dispatch must not fire on the in-flight emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); + + Emit(variant, host); + + Assert.AreEqual( + 2, + originalPpCount, + "[{0}] Original post-processor must run again on second emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); + Assert.AreEqual( + 1, + newPpCount, + "[{0}] Newly added post-processor at a new priority must run starting on the second emission. sourceId={1}, originalPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + originalPpCount, + newPpCount + ); yield break; } [UnityTest] - public IEnumerator BroadcastWithoutSourceAddPostProcessorDuringHandler() + public IEnumerator AddManyPostProcessorsDuringHandlerNoneRunInSameEmission( + [ValueSource(nameof(AllVariants))] PostProcessorVariant variant + ) { + const int NewCount = 4; + GameObject host = new( - nameof(BroadcastWithoutSourceAddPostProcessorDuringHandler), + nameof(AddManyPostProcessorsDuringHandlerNoneRunInSameEmission) + "_" + variant, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); - MessageRegistrationToken token = GetToken( - host.GetComponent() + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int originalPpCount = 0; + int[] newCounts = new int[NewCount]; + bool added = false; + + _ = RegisterHandler( + variant, + token, + hostId, + () => + { + if (added) + { + return; + } + + added = true; + for (int i = 0; i < NewCount; i++) + { + int idx = i; + _ = RegisterPostProcessor(variant, token, hostId, () => ++newCounts[idx]); + } + } + ); + _ = RegisterPostProcessor(variant, token, hostId, () => ++originalPpCount); + + Emit(variant, host); + + Assert.AreEqual( + 1, + originalPpCount, + "[{0}] Original post-processor must run exactly once on first emission. sourceId={1}, originalPpCount={2}.", + variant, + hostId.Id, + originalPpCount + ); + for (int i = 0; i < NewCount; i++) + { + Assert.AreEqual( + 0, + newCounts[i], + "[{0}] Post-processor #{1} registered during handler dispatch must not fire on the in-flight emission. sourceId={2}, count={3}.", + variant, + i, + hostId.Id, + newCounts[i] + ); + } + + Emit(variant, host); + + Assert.AreEqual( + 2, + originalPpCount, + "[{0}] Original post-processor must run again on second emission. sourceId={1}, originalPpCount={2}.", + variant, + hostId.Id, + originalPpCount + ); + for (int i = 0; i < NewCount; i++) + { + Assert.AreEqual( + 1, + newCounts[i], + "[{0}] Newly added post-processor #{1} must run starting on the second emission. sourceId={2}, count={3}.", + variant, + i, + hostId.Id, + newCounts[i] + ); + } + yield break; + } + + [UnityTest] + public IEnumerator AddPostProcessorOnDifferentMessageHandlerDuringHandler( + [ValueSource(nameof(AllVariants))] PostProcessorVariant variant + ) + { + GameObject hostA = new( + nameof(AddPostProcessorOnDifferentMessageHandlerDuringHandler) + "_A_" + variant, + typeof(EmptyMessageAwareComponent) ); + GameObject hostB = new( + nameof(AddPostProcessorOnDifferentMessageHandlerDuringHandler) + "_B_" + variant, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(hostA); + _spawned.Add(hostB); + + EmptyMessageAwareComponent componentA = + hostA.GetComponent(); + EmptyMessageAwareComponent componentB = + hostB.GetComponent(); + MessageRegistrationToken tokenA = GetToken(componentA); + MessageRegistrationToken tokenB = GetToken(componentB); - int[] ppCounts = new int[2]; - MessageRegistrationHandle ppNew = default; + InstanceId hostId = hostA; + + int existingPpCount = 0; + int newPpCount = 0; bool added = false; - // Ensure we have handlers to trigger post-processing - _ = token.RegisterBroadcastWithoutSource( - (_, _) => + _ = RegisterPostProcessor(variant, tokenA, hostId, () => ++existingPpCount); + + _ = RegisterHandler( + variant, + tokenA, + hostId, + () => { - if (!added) + if (added) { - added = true; - ppNew = token.RegisterBroadcastWithoutSourcePostProcessor( - (ref InstanceId _, ref SimpleBroadcastMessage __) => ppCounts[1]++ - ); + return; } + + added = true; + _ = RegisterPostProcessor(variant, tokenB, hostId, () => ++newPpCount); } ); - _ = token.RegisterBroadcastWithoutSourcePostProcessor( - (ref InstanceId _, ref SimpleBroadcastMessage __) => ppCounts[0]++ + Emit(variant, hostA); + + Assert.AreEqual( + 1, + existingPpCount, + "[{0}] Existing PP must fire once on first emission. sourceId={1}, existingPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + existingPpCount, + newPpCount + ); + Assert.AreEqual( + 0, + newPpCount, + "[{0}] PP registered through a different MessageHandler during dispatch must not fire on the in-flight emission. sourceId={1}, existingPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + existingPpCount, + newPpCount + ); + + Emit(variant, hostA); + + Assert.AreEqual( + 2, + existingPpCount, + "[{0}] Existing PP must fire again on second emission. sourceId={1}, existingPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + existingPpCount, + newPpCount + ); + Assert.AreEqual( + 1, + newPpCount, + "[{0}] Cross-handler PP must fire starting on the second emission. sourceId={1}, existingPpCount={2}, newPpCount={3}.", + variant, + hostId.Id, + existingPpCount, + newPpCount + ); + yield break; + } + + [UnityTest] + public IEnumerator PostProcessorIgnoresEmissionFromDifferentSource( + [ValueSource(nameof(SourceFilteredVariants))] PostProcessorVariant variant + ) + { + GameObject registeredHost = new( + nameof(PostProcessorIgnoresEmissionFromDifferentSource) + "_reg_" + variant, + typeof(EmptyMessageAwareComponent) + ); + GameObject otherHost = new( + nameof(PostProcessorIgnoresEmissionFromDifferentSource) + "_other_" + variant, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(registeredHost); + _spawned.Add(otherHost); + + EmptyMessageAwareComponent registeredComponent = + registeredHost.GetComponent(); + MessageRegistrationToken token = GetToken(registeredComponent); + + InstanceId registeredSource = registeredHost; + InstanceId emissionSource = otherHost; + Assert.AreNotEqual( + registeredSource.Id, + emissionSource.Id, + "[{0}] Negative-case fixture requires two distinct InstanceIds. registeredSourceId={1}, emissionSourceId={2}.", + variant, + registeredSource.Id, + emissionSource.Id ); - SimpleBroadcastMessage msg = new(); - msg.EmitComponentBroadcast(host.GetComponent()); - Assert.AreEqual(1, ppCounts[0]); - Assert.AreEqual(0, ppCounts[1]); + int ppCount = 0; + _ = RegisterPostProcessor(variant, token, registeredSource, () => ++ppCount); + + Emit(variant, otherHost); - msg.EmitComponentBroadcast(host.GetComponent()); - Assert.AreEqual(2, ppCounts[0]); - Assert.AreEqual(1, ppCounts[1]); + Assert.AreEqual( + 0, + ppCount, + "[{0}] Source-filtered post-processor must not fire when the emission source differs. registeredSourceId={1}, emissionSourceId={2}, ppCount={3}.", + variant, + registeredSource.Id, + emissionSource.Id, + ppCount + ); + yield break; + } - if (ppNew != default) + private static MessageRegistrationHandle RegisterHandler( + PostProcessorVariant variant, + MessageRegistrationToken token, + InstanceId source, + Action onInvoked, + int priority = 0 + ) + { + switch (variant) { - token.RemoveRegistration(ppNew); + case PostProcessorVariant.Untargeted: + { + return token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.Targeted: + { + return token.RegisterTargeted( + source, + (ref SimpleTargetedMessage _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.Broadcast: + { + return token.RegisterBroadcast( + source, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.TargetedWithoutTargeting: + { + return token.RegisterTargetedWithoutTargeting( + (_, _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.BroadcastWithoutSource: + { + return token.RegisterBroadcastWithoutSource( + (_, _) => onInvoked(), + priority: priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(variant), + variant, + "Unsupported post-processor variant." + ); + } + } + } + + private static MessageRegistrationHandle RegisterPostProcessor( + PostProcessorVariant variant, + MessageRegistrationToken token, + InstanceId source, + Action onInvoked, + int priority = 0 + ) + { + switch (variant) + { + case PostProcessorVariant.Untargeted: + { + return token.RegisterUntargetedPostProcessor( + (ref SimpleUntargetedMessage _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.Targeted: + { + return token.RegisterTargetedPostProcessor( + source, + (ref SimpleTargetedMessage _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.Broadcast: + { + return token.RegisterBroadcastPostProcessor( + source, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.TargetedWithoutTargeting: + { + return token.RegisterTargetedWithoutTargetingPostProcessor( + (ref InstanceId _, ref SimpleTargetedMessage __) => onInvoked(), + priority: priority + ); + } + case PostProcessorVariant.BroadcastWithoutSource: + { + return token.RegisterBroadcastWithoutSourcePostProcessor( + (ref InstanceId _, ref SimpleBroadcastMessage __) => onInvoked(), + priority: priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(variant), + variant, + "Unsupported post-processor variant." + ); + } + } + } + + private static void Emit(PostProcessorVariant variant, GameObject host) + { + switch (variant) + { + case PostProcessorVariant.Untargeted: + { + SimpleUntargetedMessage untargeted = new(); + untargeted.EmitUntargeted(); + return; + } + case PostProcessorVariant.Targeted: + case PostProcessorVariant.TargetedWithoutTargeting: + { + SimpleTargetedMessage targeted = new(); + targeted.EmitGameObjectTargeted(host); + return; + } + case PostProcessorVariant.Broadcast: + case PostProcessorVariant.BroadcastWithoutSource: + { + SimpleBroadcastMessage broadcast = new(); + broadcast.EmitGameObjectBroadcast(host); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(variant), + variant, + "Unsupported post-processor variant." + ); + } } - yield break; } } } diff --git a/Tests/Runtime/Core/NominalTests.cs b/Tests/Runtime/Core/NominalTests.cs index 361f097e..936456e2 100644 --- a/Tests/Runtime/Core/NominalTests.cs +++ b/Tests/Runtime/Core/NominalTests.cs @@ -15,8 +15,19 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine.TestTools; using Object = UnityEngine.Object; + [Category("Stress")] public sealed class NominalTests : MessagingTestBase { + [SetUp] + public override void Setup() + { + base.Setup(); + // Run(...) helpers below loop _numRegistrations times across many + // tests; restore the legacy stress fan-out so coverage matches the + // pre-Phase-A baseline. + _numRegistrations = StressRegistrations; + } + [UnityTest] public IEnumerator Nominal() { diff --git a/Tests/Runtime/Core/NullAndInvalidInputTests.cs b/Tests/Runtime/Core/NullAndInvalidInputTests.cs new file mode 100644 index 00000000..9e1967ea --- /dev/null +++ b/Tests/Runtime/Core/NullAndInvalidInputTests.cs @@ -0,0 +1,465 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using BusType = DxMessaging.Core.MessageBus.MessageBus; + + /// + /// Pins the behavior of the public messaging surface when callers provide + /// null delegates, default s, or unknown handles. + /// Each test creates a fresh bus and token so the global state observed by + /// the rest of the suite is untouched. Cases marked "Pinning current behavior" + /// codify what the implementation does today; if the contract is ever changed + /// deliberately, those tests must be updated alongside the source. + /// + [TestFixture] + public sealed class NullAndInvalidInputTests + { + private const int OwnerInstanceId = 1; + private const int TargetInstanceId = 2; + private const int SourceInstanceId = 3; + + /// + /// Resets all DxMessaging static state before each test so inter-fixture + /// ordering cannot pollute these tests' starting state. + /// + [SetUp] + public void ResetBeforeTest() + { + DxMessagingStaticState.Reset(); + } + + /// + /// Resets all DxMessaging static state after each test so the two cases + /// that mutate the global message bus (the static-reset sentinel and the + /// SetGlobalMessageBus null-argument check) cannot leak configuration + /// into other fixtures or subsequent tests in this fixture. + /// + [TearDown] + public void ResetGlobalState() + { + DxMessagingStaticState.Reset(); + } + + /// + /// Parameterized verification that the registration surface rejects null + /// handler delegates with . Covers + /// FastHandler, Action<T>, and interceptor variants for all three + /// dispatch kinds. + /// + [Test] + public void RegisterMethodThrowsOnNullHandler( + [ValueSource(nameof(NullHandlerCases))] NullHandlerCase testCase + ) + { + using TokenScope scope = TokenScope.Create(); + ArgumentNullException ex = Assert.Throws(() => + testCase.Action(scope.Token) + ); + Assert.IsNotNull(ex, $"Expected ArgumentNullException for case '{testCase}'."); + } + + /// + /// Parameterized verification that + /// silently tolerates default handles, foreign handles, and double-remove. + /// + [Test] + public void RemoveRegistrationIsNoOpForUnknownHandle( + [ValueSource(nameof(NoOpHandleCases))] NoOpHandleCase testCase + ) + { + Assert.DoesNotThrow(() => testCase.Action()); + } + + [Test] + public void RegisterTargetedAcceptsDefaultInstanceIdSilently() + { + // Pinning current behavior: default(InstanceId) is treated as a normal + // identifier (zero) by the bus rather than rejected. If the contract + // changes to disallow it, this test must be updated deliberately. + using TokenScope scope = TokenScope.Create(); + int invocationCount = 0; + MessageRegistrationHandle handle = scope.Token.RegisterTargeted( + default, + (ref SimpleTargetedMessage _) => ++invocationCount + ); + + SimpleTargetedMessage message = new(); + message.EmitTargeted(default(InstanceId), scope.Bus); + Assert.AreEqual(1, invocationCount); + + scope.Token.RemoveRegistration(handle); + } + + [Test] + public void RegisterBroadcastAcceptsDefaultInstanceIdSilently() + { + // Pinning current behavior: default(InstanceId) is treated as a normal + // source identifier rather than rejected. + using TokenScope scope = TokenScope.Create(); + int invocationCount = 0; + MessageRegistrationHandle handle = + scope.Token.RegisterBroadcast( + default, + (ref SimpleBroadcastMessage _) => ++invocationCount + ); + + SimpleBroadcastMessage message = new(); + message.EmitBroadcast(default(InstanceId), scope.Bus); + Assert.AreEqual(1, invocationCount); + + scope.Token.RemoveRegistration(handle); + } + + [Test] + public void MessageHandlerMessageBusIsNeverNullAfterStaticReset() + { + IMessageBus before = MessageHandler.MessageBus; + Assert.IsNotNull(before, "Global message bus must be available before reset."); + + DxMessagingStaticState.Reset(); + + IMessageBus after = MessageHandler.MessageBus; + Assert.IsNotNull( + after, + "Global message bus must be re-established after DxMessagingStaticState.Reset." + ); + } + + [Test] + public void SetGlobalMessageBusRejectsNullArgument() + { + Assert.Throws(() => + MessageHandler.SetGlobalMessageBus((BusType)null) + ); + Assert.Throws(() => + MessageHandler.SetGlobalMessageBus((IMessageBus)null) + ); + } + + [Test] + public void MessageRegistrationTokenCreateRejectsNullHandler() + { + Assert.Throws(() => MessageRegistrationToken.Create(null)); + } + + [Test] + public void EmitUntargetedClassMessageWithNullPayloadDoesNotCrashWithoutHandlers() + { + // Pinning current behavior: a null class message dispatched through a + // bus with zero registered handlers is a no-op rather than an exception. + // The reflective UntypedUntargetedBroadcast path would dereference the + // payload, but the strongly typed shorthand does not. + BusType bus = new BusType(); + Assert.DoesNotThrow(() => bus.EmitUntargeted((ClassUntargetedMessage)null)); + } + + [Test] + public void EmitUntargetedClassMessageWithNullPayloadAndHandlerInvokesHandler() + { + // Pinning current behavior: the bus does not dereference the message + // reference for dispatch (it uses typeof(TMessage) for the lookup), so + // a null class payload still reaches a handler that does not access + // any member of the message. + using TokenScope scope = TokenScope.Create(); + int invocationCount = 0; + MessageRegistrationHandle handle = + scope.Token.RegisterUntargeted( + (ref ClassUntargetedMessage _) => ++invocationCount + ); + + Assert.DoesNotThrow(() => scope.Bus.EmitUntargeted((ClassUntargetedMessage)null)); + Assert.AreEqual( + 1, + invocationCount, + "Handler should be invoked even with a null class payload because the bus does not dereference the message reference." + ); + + scope.Token.RemoveRegistration(handle); + } + + [Test] + public void EmitUntargetedClassMessageWithNullPayloadThrowsWhenHandlerDereferences() + { + // Pins the user-visible boundary: if the caller's handler dereferences + // a null message payload, the resulting NullReferenceException surfaces + // through the bus to the emit call. The framework does not catch it. + using TokenScope scope = TokenScope.Create(); + MessageRegistrationHandle handle = + scope.Token.RegisterUntargeted( + (ref ClassUntargetedMessage message) => _ = message.GetType() + ); + + Assert.Throws(() => + scope.Bus.EmitUntargeted((ClassUntargetedMessage)null) + ); + + scope.Token.RemoveRegistration(handle); + } + + [Test] + public void EmitUntargetedThroughNullBusThrows() + { + ClassUntargetedMessage message = new ClassUntargetedMessage(); + Assert.Throws(() => + MessageBusExtensions.EmitUntargeted((IMessageBus)null, message) + ); + } + + [Test] + public void EmitTargetedThroughNullBusThrows() + { + SimpleTargetedMessage message = new(); + InstanceId target = new(TargetInstanceId); + Assert.Throws(() => + MessageBusExtensions.EmitTargeted((IMessageBus)null, target, ref message) + ); + } + + [Test] + public void EmitBroadcastThroughNullBusThrows() + { + SimpleBroadcastMessage message = new(); + InstanceId source = new(SourceInstanceId); + Assert.Throws(() => + MessageBusExtensions.EmitBroadcast((IMessageBus)null, source, ref message) + ); + } + + [Test] + public void TargetedBroadcastWithDefaultTargetIsAccepted() + { + // Pinning current behavior: default(InstanceId) is a valid target. The + // bus does not enforce a non-zero identifier on the dispatch path. + BusType bus = new BusType(); + MessageHandler handler = new MessageHandler(new InstanceId(OwnerInstanceId), bus) + { + active = true, + }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + int invocationCount = 0; + _ = token.RegisterTargeted( + default, + (ref SimpleTargetedMessage _) => ++invocationCount + ); + token.Enable(); + + SimpleTargetedMessage message = new(); + InstanceId zero = default; + bus.TargetedBroadcast(ref zero, ref message); + + Assert.AreEqual(1, invocationCount); + token.Dispose(); + } + + public static IEnumerable NullHandlerCases + { + get + { + yield return new NullHandlerCase( + "RegisterUntargeted FastHandler null", + token => + token.RegisterUntargeted( + (MessageHandler.FastHandler)null + ) + ); + yield return new NullHandlerCase( + "RegisterUntargeted Action null", + token => + token.RegisterUntargeted( + (Action)null + ) + ); + yield return new NullHandlerCase( + "RegisterTargeted FastHandler null", + token => + token.RegisterTargeted( + new InstanceId(TargetInstanceId), + (MessageHandler.FastHandler)null + ) + ); + yield return new NullHandlerCase( + "RegisterTargeted Action null", + token => + token.RegisterTargeted( + new InstanceId(TargetInstanceId), + (Action)null + ) + ); + yield return new NullHandlerCase( + "RegisterBroadcast FastHandler null", + token => + token.RegisterBroadcast( + new InstanceId(SourceInstanceId), + (MessageHandler.FastHandler)null + ) + ); + yield return new NullHandlerCase( + "RegisterBroadcast Action null", + token => + token.RegisterBroadcast( + new InstanceId(SourceInstanceId), + (Action)null + ) + ); + yield return new NullHandlerCase( + "RegisterUntargetedInterceptor null", + token => token.RegisterUntargetedInterceptor(null) + ); + yield return new NullHandlerCase( + "RegisterTargetedInterceptor null", + token => token.RegisterTargetedInterceptor(null) + ); + yield return new NullHandlerCase( + "RegisterBroadcastInterceptor null", + token => token.RegisterBroadcastInterceptor(null) + ); + } + } + + public static IEnumerable NoOpHandleCases + { + get + { + yield return new NoOpHandleCase( + "Default handle", + () => + { + using TokenScope scope = TokenScope.Create(); + scope.Token.RemoveRegistration(default(MessageRegistrationHandle)); + } + ); + yield return new NoOpHandleCase( + "Foreign handle", + () => + { + using TokenScope scope = TokenScope.Create(); + MessageRegistrationHandle foreign = + MessageRegistrationHandle.CreateMessageRegistrationHandle(); + scope.Token.RemoveRegistration(foreign); + } + ); + yield return new NoOpHandleCase( + "Double remove of valid handle", + () => + { + using TokenScope scope = TokenScope.Create(); + int invocationCount = 0; + MessageRegistrationHandle handle = + scope.Token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++invocationCount + ); + scope.Token.RemoveRegistration(handle); + scope.Token.RemoveRegistration(handle); + + SimpleUntargetedMessage message = new(); + scope.Bus.EmitUntargeted(ref message); + Assert.AreEqual( + 0, + invocationCount, + "Doubled removal must not resurrect the handler." + ); + } + ); + } + } + + /// + /// One null-handler scenario: pairs a description with a delegate that + /// invokes the failing registration on the supplied token. + /// + public sealed class NullHandlerCase + { + public string Description { get; } + + public Action Action { get; } + + public NullHandlerCase(string description, Action action) + { + Description = description; + Action = action; + } + + public override string ToString() + { + return Description; + } + } + + /// + /// One handle-removal scenario: pairs a description with a delegate that + /// performs the removal under a freshly created token scope. + /// + public sealed class NoOpHandleCase + { + public string Description { get; } + + public Action Action { get; } + + public NoOpHandleCase(string description, Action action) + { + Description = description; + Action = action; + } + + public override string ToString() + { + return Description; + } + } + + /// + /// Convenience holder that pairs a fresh , + /// , and enabled + /// for inline test setup. Every instance is isolated from the global bus so + /// tests do not leak handlers across cases. + /// + private sealed class TokenScope : IDisposable + { + private bool _disposed; + + internal BusType Bus { get; } + + internal MessageHandler Handler { get; } + + internal MessageRegistrationToken Token { get; } + + private TokenScope(BusType bus, MessageHandler handler, MessageRegistrationToken token) + { + Bus = bus; + Handler = handler; + Token = token; + } + + internal static TokenScope Create() + { + BusType bus = new BusType(); + MessageHandler handler = new MessageHandler(new InstanceId(OwnerInstanceId), bus) + { + active = true, + }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + token.Enable(); + return new TokenScope(bus, handler, token); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + Token.Dispose(); + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/NullAndInvalidInputTests.cs.meta b/Tests/Runtime/Core/NullAndInvalidInputTests.cs.meta new file mode 100644 index 00000000..458553df --- /dev/null +++ b/Tests/Runtime/Core/NullAndInvalidInputTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 839bd5fcc38b47a295f7a91b3939ba5e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/OrderingManyRegistrationsTests.cs b/Tests/Runtime/Core/OrderingManyRegistrationsTests.cs index e5a76509..eb978b4f 100644 --- a/Tests/Runtime/Core/OrderingManyRegistrationsTests.cs +++ b/Tests/Runtime/Core/OrderingManyRegistrationsTests.cs @@ -1,17 +1,20 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Tests.Runtime.Core { + using System; using System.Collections; using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; + [Category("Stress")] public sealed class OrderingManyRegistrationsTests : MessagingTestBase { private const int ManyRegistrationCount = 32; @@ -40,15 +43,19 @@ private static void AssertSequence(IList actual, string message) } [UnityTest] - public IEnumerator UntargetedHandlersManyRegistrationsMaintainOrder() + public IEnumerator HandlersManyRegistrationsMaintainOrder( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedHandlersManyRegistrationsMaintainOrder), + nameof(HandlersManyRegistrationsMaintainOrder) + "_" + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId targetId = component; List fastOrder = new(ManyRegistrationCount); List actionOrder = new(ManyRegistrationCount); @@ -56,27 +63,24 @@ public IEnumerator UntargetedHandlersManyRegistrationsMaintainOrder() for (int i = 0; i < ManyRegistrationCount; i++) { int index = i; - _ = token.RegisterUntargeted( - (ref SimpleUntargetedMessage _) => fastOrder.Add(index) - ); + _ = RegisterFastHandler(scenario, token, targetId, () => fastOrder.Add(index)); } for (int i = 0; i < ManyRegistrationCount; i++) { int index = i; - _ = token.RegisterUntargeted((SimpleUntargetedMessage _) => actionOrder.Add(index)); + _ = RegisterActionHandler(scenario, token, targetId, () => actionOrder.Add(index)); } - SimpleUntargetedMessage message = new(); - message.EmitUntargeted(); + EmitForScenario(scenario, targetId); AssertSequence( fastOrder, - "Untargeted fast handlers should run in registration order even with many entries." + $"{scenario.Kind} fast handlers should run in registration order even with many entries." ); AssertSequence( actionOrder, - "Untargeted action handlers should run in registration order even with many entries." + $"{scenario.Kind} action handlers should run in registration order even with many entries." ); yield break; } @@ -112,52 +116,6 @@ public IEnumerator UntargetedPostProcessorsManyRegistrationsMaintainOrder() yield break; } - [UnityTest] - public IEnumerator TargetedHandlersManyRegistrationsMaintainOrder() - { - GameObject host = new( - nameof(TargetedHandlersManyRegistrationsMaintainOrder), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - List fastOrder = new(ManyRegistrationCount); - List actionOrder = new(ManyRegistrationCount); - - for (int i = 0; i < ManyRegistrationCount; i++) - { - int index = i; - _ = token.RegisterComponentTargeted( - component, - (ref SimpleTargetedMessage _) => fastOrder.Add(index) - ); - } - - for (int i = 0; i < ManyRegistrationCount; i++) - { - int index = i; - _ = token.RegisterComponentTargeted( - component, - (SimpleTargetedMessage _) => actionOrder.Add(index) - ); - } - - SimpleTargetedMessage message = new(); - message.EmitComponentTargeted(component); - - AssertSequence( - fastOrder, - "Targeted fast handlers should run in registration order even with many entries." - ); - AssertSequence( - actionOrder, - "Targeted action handlers should run in registration order even with many entries." - ); - yield break; - } - [UnityTest] public IEnumerator TargetedPostProcessorsManyRegistrationsMaintainOrder() { @@ -294,52 +252,6 @@ public IEnumerator TargetedWithoutTargetingPostProcessorsManyRegistrationsMainta yield break; } - [UnityTest] - public IEnumerator BroadcastHandlersManyRegistrationsMaintainOrder() - { - GameObject host = new( - nameof(BroadcastHandlersManyRegistrationsMaintainOrder), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - List fastOrder = new(ManyRegistrationCount); - List actionOrder = new(ManyRegistrationCount); - - for (int i = 0; i < ManyRegistrationCount; i++) - { - int index = i; - _ = token.RegisterComponentBroadcast( - component, - (ref SimpleBroadcastMessage _) => fastOrder.Add(index) - ); - } - - for (int i = 0; i < ManyRegistrationCount; i++) - { - int index = i; - _ = token.RegisterComponentBroadcast( - component, - (SimpleBroadcastMessage _) => actionOrder.Add(index) - ); - } - - SimpleBroadcastMessage message = new(); - message.EmitComponentBroadcast(component); - - AssertSequence( - fastOrder, - "Broadcast fast handlers should run in registration order even with many entries." - ); - AssertSequence( - actionOrder, - "Broadcast action handlers should run in registration order even with many entries." - ); - yield break; - } - [UnityTest] public IEnumerator BroadcastPostProcessorsManyRegistrationsMaintainOrder() { @@ -575,23 +487,29 @@ public IEnumerator GlobalAcceptAllFastManyRegistrationsMaintainOrder() } [UnityTest] - public IEnumerator UntargetedInterceptorsManyRegistrationsMaintainOrder() + public IEnumerator InterceptorsManyRegistrationsMaintainOrder( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) { GameObject host = new( - nameof(UntargetedInterceptorsManyRegistrationsMaintainOrder), + nameof(InterceptorsManyRegistrationsMaintainOrder) + "_" + scenario, typeof(EmptyMessageAwareComponent) ); _spawned.Add(host); EmptyMessageAwareComponent component = host.GetComponent(); MessageRegistrationToken token = GetToken(component); + InstanceId targetId = component; List order = new(ManyRegistrationCount); for (int i = 0; i < ManyRegistrationCount; i++) { int index = i; - _ = token.RegisterUntargetedInterceptor( - (ref SimpleUntargetedMessage _) => + _ = RegisterInterceptor( + scenario, + token, + () => { order.Add(index); return true; @@ -599,84 +517,169 @@ public IEnumerator UntargetedInterceptorsManyRegistrationsMaintainOrder() ); } - SimpleUntargetedMessage message = new(); - message.EmitUntargeted(); + EmitForScenario(scenario, targetId); AssertSequence( order, - "Untargeted interceptors should run in registration order even with many entries." + $"{scenario.Kind} interceptors should run in registration order even with many entries." ); yield break; } - [UnityTest] - public IEnumerator TargetedInterceptorsManyRegistrationsMaintainOrder() + private static MessageRegistrationHandle RegisterFastHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked + ) { - GameObject host = new( - nameof(TargetedInterceptorsManyRegistrationsMaintainOrder), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - List order = new(ManyRegistrationCount); - - for (int i = 0; i < ManyRegistrationCount; i++) + switch (scenario.Kind) { - int index = i; - _ = token.RegisterTargetedInterceptor( - (ref InstanceId _, ref SimpleTargetedMessage _) => - { - order.Add(index); - return true; - } - ); + case MessageKind.Untargeted: + { + return token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => onInvoked() + ); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted( + target, + (ref SimpleTargetedMessage _) => onInvoked() + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + target, + (ref SimpleBroadcastMessage _) => onInvoked() + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } } - - SimpleTargetedMessage message = new(); - message.EmitComponentTargeted(component); - - AssertSequence( - order, - "Targeted interceptors should run in registration order even with many entries." - ); - yield break; } - [UnityTest] - public IEnumerator BroadcastInterceptorsManyRegistrationsMaintainOrder() + private static MessageRegistrationHandle RegisterActionHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked + ) { - GameObject host = new( - nameof(BroadcastInterceptorsManyRegistrationsMaintainOrder), - typeof(EmptyMessageAwareComponent) - ); - _spawned.Add(host); - EmptyMessageAwareComponent component = host.GetComponent(); - MessageRegistrationToken token = GetToken(component); - - List order = new(ManyRegistrationCount); - - for (int i = 0; i < ManyRegistrationCount; i++) + switch (scenario.Kind) { - int index = i; - _ = token.RegisterBroadcastInterceptor( - (ref InstanceId _, ref SimpleBroadcastMessage _) => - { - order.Add(index); - return true; - } - ); + case MessageKind.Untargeted: + { + return token.RegisterUntargeted( + (SimpleUntargetedMessage _) => onInvoked() + ); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted( + target, + (SimpleTargetedMessage _) => onInvoked() + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + target, + (SimpleBroadcastMessage _) => onInvoked() + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } } + } - SimpleBroadcastMessage message = new(); - message.EmitComponentBroadcast(component); + private static MessageRegistrationHandle RegisterInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Func body + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => body() + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage __) => body() + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage __) => body() + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } - AssertSequence( - order, - "Broadcast interceptors should run in registration order even with many entries." - ); - yield break; + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } } } } diff --git a/Tests/Runtime/Core/OrderingTests.cs b/Tests/Runtime/Core/OrderingTests.cs index b50b1aa2..653cf8f1 100644 --- a/Tests/Runtime/Core/OrderingTests.cs +++ b/Tests/Runtime/Core/OrderingTests.cs @@ -418,7 +418,7 @@ public IEnumerator GlobalAcceptAllUntargetedFastBeforeActions() MessageRegistrationToken token = GetToken(comp); List order = new(); - // Register action first, then fast — fast should still run first + // Register action first, then fast; fast should still run first _ = token.RegisterGlobalAcceptAll(_ => order.Add("A"), (_, _) => { }, (_, _) => { }); _ = token.RegisterGlobalAcceptAll( (ref IUntargetedMessage _) => order.Add("F"), @@ -506,7 +506,7 @@ public IEnumerator TargetedWithoutTargetingMixedFastBeforeActions() MessageRegistrationToken token = GetToken(comp); List order = new(); - // Register action then fast — fast should still be invoked first within the group + // Register action then fast; fast should still be invoked first within the group _ = token.RegisterTargetedWithoutTargeting( (InstanceId _, SimpleTargetedMessage _) => order.Add("A"), 0 diff --git a/Tests/Runtime/Core/PostProcessorTests.cs b/Tests/Runtime/Core/PostProcessorTests.cs index 64c4842f..b5de78fb 100644 --- a/Tests/Runtime/Core/PostProcessorTests.cs +++ b/Tests/Runtime/Core/PostProcessorTests.cs @@ -11,8 +11,19 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine; using UnityEngine.TestTools; + [Category("Stress")] public sealed class PostProcessorTests : MessagingTestBase { + [SetUp] + public override void Setup() + { + base.Setup(); + // Run(...) helpers below loop _numRegistrations times across many + // tests; restore the legacy stress fan-out so coverage matches the + // pre-Phase-A baseline. + _numRegistrations = StressRegistrations; + } + [UnityTest] public IEnumerator Untargeted() { diff --git a/Tests/Runtime/Core/ReentrantEmissionTests.cs b/Tests/Runtime/Core/ReentrantEmissionTests.cs new file mode 100644 index 00000000..25c0fea6 --- /dev/null +++ b/Tests/Runtime/Core/ReentrantEmissionTests.cs @@ -0,0 +1,1443 @@ +#if UNITY_2021_3_OR_NEWER +// ReSharper disable AccessToModifiedClosure +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// Verifies the snapshot semantics that govern re-entrant emission. The bus uses a + /// frozen handler list per emission, so additions and deletions made inside a + /// handler must not affect the in-flight dispatch but must be visible on the next + /// emission. These tests pin that contract across all three message kinds. + /// + public sealed class ReentrantEmissionTests : MessagingTestBase + { + private const int ReentrantSafetyDepth = 5; + + /// + /// A handler that re-emits the same message kind must terminate via an + /// explicit safety counter rather than running forever or until the stack + /// blows. The recursion is bounded inside the handler itself; this test + /// pins the deterministic invocation count so any future regression that + /// changes dispatch ordering, reentrancy guards, or counter semantics + /// fails loudly with exact numbers rather than a stack overflow. + /// + [UnityTest] + public IEnumerator EmitDuringHandlerDoesNotInfinitelyRecurse( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(EmitDuringHandlerDoesNotInfinitelyRecurse) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + const int MaxRecursionDepth = 2; + int totalInvocations = 0; + int currentDepth = 0; + + _ = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++totalInvocations; + if (currentDepth >= MaxRecursionDepth) + { + return; + } + + ++currentDepth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --currentDepth; + } + } + ); + + EmitForScenario(scenario, hostId); + + // Outer call increments to 1 then recurses; depth=1 increments to 2 then + // recurses; depth=2 increments to 3 and stops. After the cascade unwinds + // the handler is invoked exactly MaxRecursionDepth + 1 times. + Assert.AreEqual( + MaxRecursionDepth + 1, + totalInvocations, + "[{0}] Bounded recursive emit must invoke the handler exactly {1} times. totalInvocations={2}.", + scenario.Kind, + MaxRecursionDepth + 1, + totalInvocations + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2 * (MaxRecursionDepth + 1), + totalInvocations, + "[{0}] A second top-level emission must reproduce the same bounded cascade. expected={1}, totalInvocations={2}.", + scenario.Kind, + 2 * (MaxRecursionDepth + 1), + totalInvocations + ); + yield break; + } + + [UnityTest] + public IEnumerator RecursiveEmitTerminatesViaInterceptor( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(RecursiveEmitTerminatesViaInterceptor) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int depth = 0; + int handlerInvocations = 0; + int interceptorInvocations = 0; + + RegisterDepthLimitedInterceptor( + scenario, + token, + threshold: ReentrantSafetyDepth, + getDepth: () => depth, + onInvoked: () => ++interceptorInvocations + ); + + _ = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++handlerInvocations; + ++depth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --depth; + } + } + ); + + EmitForScenario(scenario, hostId); + + // Depth starts at 0. The interceptor approves while depth < threshold, + // so the handler runs and increments depth on each level; the cascade + // halts when depth reaches the threshold. Each emission consults the + // interceptor exactly once per attempted dispatch (one approval per + // handler invocation plus one final rejection that cancels dispatch). + Assert.AreEqual( + ReentrantSafetyDepth, + handlerInvocations, + "[{0}] Handler must run exactly threshold ({1}) times before the interceptor cancels dispatch. handlerInvocations={2}, interceptorInvocations={3}.", + scenario.Kind, + ReentrantSafetyDepth, + handlerInvocations, + interceptorInvocations + ); + Assert.AreEqual( + ReentrantSafetyDepth + 1, + interceptorInvocations, + "[{0}] Interceptor must run once per handler invocation plus once for the cancelling level. expected={1}, handlerInvocations={2}, interceptorInvocations={3}.", + scenario.Kind, + ReentrantSafetyDepth + 1, + handlerInvocations, + interceptorInvocations + ); + yield break; + } + + [UnityTest] + public IEnumerator RegisterDuringEmitIsDeferredToNextEmission( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(RegisterDuringEmitIsDeferredToNextEmission) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int primaryCount = 0; + int secondaryCount = 0; + MessageRegistrationHandle? secondaryHandle = null; + + MessageRegistrationHandle primaryHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++primaryCount; + secondaryHandle ??= RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++secondaryCount + ); + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + primaryCount, + "[{0}] First emission must invoke primary exactly once. primaryCount={1}, secondaryCount={2}.", + scenario.Kind, + primaryCount, + secondaryCount + ); + Assert.AreEqual( + 0, + secondaryCount, + "[{0}] New registration must not run during its own emission. primaryCount={1}, secondaryCount={2}.", + scenario.Kind, + primaryCount, + secondaryCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + primaryCount, + "[{0}] Second emission must increment primary to 2. primaryCount={1}, secondaryCount={2}.", + scenario.Kind, + primaryCount, + secondaryCount + ); + Assert.AreEqual( + 1, + secondaryCount, + "[{0}] New registration must be visible to the next emission. primaryCount={1}, secondaryCount={2}.", + scenario.Kind, + primaryCount, + secondaryCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 3, + primaryCount, + "[{0}] Third emission must increment primary to 3. primaryCount={1}, secondaryCount={2}.", + scenario.Kind, + primaryCount, + secondaryCount + ); + Assert.AreEqual( + 2, + secondaryCount, + "[{0}] Third emission must increment secondary to 2. primaryCount={1}, secondaryCount={2}.", + scenario.Kind, + primaryCount, + secondaryCount + ); + + token.RemoveRegistration(primaryHandle); + if (secondaryHandle.HasValue) + { + token.RemoveRegistration(secondaryHandle.Value); + } + + yield break; + } + + [UnityTest] + public IEnumerator DeregisterDuringEmitIsHonouredOnCurrentSnapshot( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(DeregisterDuringEmitIsHonouredOnCurrentSnapshot) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int firstCount = 0; + int secondCount = 0; + MessageRegistrationHandle secondHandle = default; + + _ = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++firstCount; + if (secondHandle != default) + { + token.RemoveRegistration(secondHandle); + secondHandle = default; + } + } + ); + + secondHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 1, + onInvoked: () => ++secondCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + firstCount, + "[{0}] First emission must invoke primary exactly once. firstCount={1}, secondCount={2}.", + scenario.Kind, + firstCount, + secondCount + ); + Assert.AreEqual( + 1, + secondCount, + "[{0}] Snapshot frozen at emission start must invoke handler scheduled for removal. firstCount={1}, secondCount={2}.", + scenario.Kind, + firstCount, + secondCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + firstCount, + "[{0}] Second emission must invoke primary again. firstCount={1}, secondCount={2}.", + scenario.Kind, + firstCount, + secondCount + ); + Assert.AreEqual( + 1, + secondCount, + "[{0}] Removed handler must not run on the next emission once snapshot is rebuilt. firstCount={1}, secondCount={2}.", + scenario.Kind, + firstCount, + secondCount + ); + yield break; + } + + [UnityTest] + public IEnumerator MultipleNestedEmissionsRespectSnapshotIsolation( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(MultipleNestedEmissionsRespectSnapshotIsolation) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + const int MaxDepth = 3; + int depth = 0; + int primaryInvocations = 0; + int latecomerInvocations = 0; + bool latecomerRegistered = false; + + _ = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++primaryInvocations; + if (depth >= MaxDepth) + { + return; + } + + ++depth; + try + { + EmitForScenario(scenario, hostId); + if (!latecomerRegistered) + { + latecomerRegistered = true; + _ = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++latecomerInvocations + ); + } + } + finally + { + --depth; + } + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + MaxDepth + 1, + primaryInvocations, + "[{0}] Nested emissions must each invoke the primary handler once. expected={1}, primaryInvocations={2}, latecomerInvocations={3}.", + scenario.Kind, + MaxDepth + 1, + primaryInvocations, + latecomerInvocations + ); + Assert.AreEqual( + 0, + latecomerInvocations, + "[{0}] Latecomer registered mid-dispatch must not appear in any in-flight snapshot. primaryInvocations={1}, latecomerInvocations={2}.", + scenario.Kind, + primaryInvocations, + latecomerInvocations + ); + + EmitForScenario(scenario, hostId); + Assert.GreaterOrEqual( + latecomerInvocations, + 1, + "[{0}] Latecomer must be visible to subsequent emissions after the recursive cascade settles. primaryInvocations={1}, latecomerInvocations={2}.", + scenario.Kind, + primaryInvocations, + latecomerInvocations + ); + yield break; + } + + /// + /// Same-priority deregistration during emission must respect the snapshot + /// frozen at the start of dispatch. Handler-A and handler-B share priority + /// 0; A removes B during its own callback. B must still run on the current + /// emission (snapshot semantics) but must NOT run on the second emission. + /// + [UnityTest] + public IEnumerator DeregisterSamePriorityDuringEmitIsHonouredOnCurrentSnapshot( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(DeregisterSamePriorityDuringEmitIsHonouredOnCurrentSnapshot) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int aCount = 0; + int bCount = 0; + MessageRegistrationHandle bHandle = default; + + MessageRegistrationHandle aHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++aCount; + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + bHandle = default; + } + } + ); + + bHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++bCount + ); + MessageRegistrationHandle bHandleSnapshot = bHandle; + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] First emission must invoke A exactly once. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] Same-priority handler scheduled for removal must still run on the current snapshot. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + aCount, + "[{0}] Second emission must invoke A again. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] B must NOT run on the second emission once removed. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + token.RemoveRegistration(aHandle); + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + } + // Reference snapshot to suppress unused-variable analyzer noise across + // future refactors. The remove above already used the live handle. + _ = bHandleSnapshot; + yield break; + } + + /// + /// Removing multiple handlers across distinct priority buckets during + /// emission must respect the snapshot for the current dispatch and only + /// take effect on the next emission. Handler-A at priority 0 removes + /// handler-B (priority 1) and handler-D (priority 3); handler-C + /// (priority 2) is untouched. All four must run on the first emission; + /// only A and C must run on the second. + /// + [UnityTest] + public IEnumerator DeregisterMultipleHandlersDuringEmitAcrossPriorities( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(DeregisterMultipleHandlersDuringEmitAcrossPriorities) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int aCount = 0; + int bCount = 0; + int cCount = 0; + int dCount = 0; + MessageRegistrationHandle bHandle = default; + MessageRegistrationHandle dHandle = default; + + MessageRegistrationHandle aHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++aCount; + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + bHandle = default; + } + if (dHandle != default) + { + token.RemoveRegistration(dHandle); + dHandle = default; + } + } + ); + bHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 1, + onInvoked: () => ++bCount + ); + MessageRegistrationHandle cHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 2, + onInvoked: () => ++cCount + ); + dHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 3, + onInvoked: () => ++dCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] First emission must run A once. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] First emission snapshot must still invoke B even though A removed it. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + Assert.AreEqual( + 1, + cCount, + "[{0}] First emission must run untouched C. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + Assert.AreEqual( + 1, + dCount, + "[{0}] First emission snapshot must still invoke D even though A removed it. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + aCount, + "[{0}] Second emission must run A again. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] B must NOT run on the second emission once removed. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + Assert.AreEqual( + 2, + cCount, + "[{0}] C must run on the second emission. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + Assert.AreEqual( + 1, + dCount, + "[{0}] D must NOT run on the second emission once removed. aCount={1}, bCount={2}, cCount={3}, dCount={4}.", + scenario.Kind, + aCount, + bCount, + cCount, + dCount + ); + + token.RemoveRegistration(aHandle); + token.RemoveRegistration(cHandle); + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + } + if (dHandle != default) + { + token.RemoveRegistration(dHandle); + } + yield break; + } + + /// + /// Mixed register-and-deregister-during-emit must respect both halves of + /// the snapshot contract. Handler-A at priority 0 registers a new + /// handler-X at priority 1 AND removes existing handler-B at priority 2. + /// First emission: A and B run, X does NOT (registered after snapshot). + /// Second emission: A and X run, B does NOT (removed before snapshot). + /// + [UnityTest] + public IEnumerator RegisterAndDeregisterDuringEmitInteractsCorrectly( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(RegisterAndDeregisterDuringEmitInteractsCorrectly) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int aCount = 0; + int bCount = 0; + int xCount = 0; + MessageRegistrationHandle bHandle = default; + MessageRegistrationHandle? xHandle = null; + + MessageRegistrationHandle aHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++aCount; + xHandle ??= RegisterCountingHandler( + scenario, + token, + hostId, + priority: 1, + onInvoked: () => ++xCount + ); + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + bHandle = default; + } + } + ); + bHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 2, + onInvoked: () => ++bCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] First emission must run A once. aCount={1}, bCount={2}, xCount={3}.", + scenario.Kind, + aCount, + bCount, + xCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] First emission snapshot must still invoke B even though A removed it. aCount={1}, bCount={2}, xCount={3}.", + scenario.Kind, + aCount, + bCount, + xCount + ); + Assert.AreEqual( + 0, + xCount, + "[{0}] X registered during dispatch must NOT run on the same emission. aCount={1}, bCount={2}, xCount={3}.", + scenario.Kind, + aCount, + bCount, + xCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + aCount, + "[{0}] Second emission must run A again. aCount={1}, bCount={2}, xCount={3}.", + scenario.Kind, + aCount, + bCount, + xCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] B must NOT run on the second emission once removed. aCount={1}, bCount={2}, xCount={3}.", + scenario.Kind, + aCount, + bCount, + xCount + ); + Assert.AreEqual( + 1, + xCount, + "[{0}] X must run on the second emission once visible to the snapshot. aCount={1}, bCount={2}, xCount={3}.", + scenario.Kind, + aCount, + bCount, + xCount + ); + + token.RemoveRegistration(aHandle); + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + } + if (xHandle.HasValue) + { + token.RemoveRegistration(xHandle.Value); + } + yield break; + } + + /// + /// Cross-MessageHandler same-priority deregister-during-emit. Two distinct + /// components host one handler each at the same priority; handler-A on + /// component-1 removes handler-B on component-2 during dispatch. The + /// snapshot contract requires both handlers to run on the current emission, + /// then only A on the next. This locks the contract for the + /// "single bucket, multi-MessageHandler" case where the snapshot-level + /// prefreeze is needed even though there is only one priority bucket. + /// + [UnityTest] + public IEnumerator DeregisterCrossMessageHandlerSamePriorityIsHonouredOnCurrentSnapshot( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject hostA = new( + nameof(DeregisterCrossMessageHandlerSamePriorityIsHonouredOnCurrentSnapshot) + + "A" + + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(hostA); + GameObject hostB = new( + nameof(DeregisterCrossMessageHandlerSamePriorityIsHonouredOnCurrentSnapshot) + + "B" + + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(hostB); + + EmptyMessageAwareComponent componentA = + hostA.GetComponent(); + EmptyMessageAwareComponent componentB = + hostB.GetComponent(); + MessageRegistrationToken tokenA = GetToken(componentA); + MessageRegistrationToken tokenB = GetToken(componentB); + + // Targeted/broadcast use a single shared instance id so both handlers + // dispatch in the same emission; untargeted ignores the id parameter. + InstanceId sharedId = hostA; + + int aCount = 0; + int bCount = 0; + MessageRegistrationHandle bHandle = default; + + MessageRegistrationHandle aHandle = RegisterCountingHandler( + scenario, + tokenA, + sharedId, + priority: 0, + onInvoked: () => + { + ++aCount; + if (bHandle != default) + { + tokenB.RemoveRegistration(bHandle); + bHandle = default; + } + } + ); + + bHandle = RegisterCountingHandler( + scenario, + tokenB, + sharedId, + priority: 0, + onInvoked: () => ++bCount + ); + + EmitForScenario(scenario, sharedId); + Assert.AreEqual( + 1, + aCount, + "[{0}] First emission must invoke A on its own MessageHandler. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] First emission snapshot must still invoke B on its own MessageHandler even though A removed it. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + EmitForScenario(scenario, sharedId); + Assert.AreEqual( + 2, + aCount, + "[{0}] Second emission must invoke A again. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] B must NOT run on the second emission once removed. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + tokenA.RemoveRegistration(aHandle); + if (bHandle != default) + { + tokenB.RemoveRegistration(bHandle); + } + yield break; + } + + /// + /// A handler that removes ITSELF mid-callback must still complete the + /// in-flight invocation (snapshot semantics) and must not run on the next + /// emission. This pins the corner case where the deregistration closure + /// mutates the same HandlerActionCache the in-flight dispatch is iterating. + /// + [UnityTest] + public IEnumerator HandlerRemovingItselfDuringEmitIsHonouredOnCurrentSnapshot( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerRemovingItselfDuringEmitIsHonouredOnCurrentSnapshot) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int aCount = 0; + MessageRegistrationHandle aHandle = default; + + aHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++aCount; + if (aHandle != default) + { + token.RemoveRegistration(aHandle); + aHandle = default; + } + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] Self-removing handler must complete its in-flight invocation. aCount={1}.", + scenario.Kind, + aCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] Self-removed handler must NOT run on the next emission. aCount={1}.", + scenario.Kind, + aCount + ); + yield break; + } + + /// + /// A handler that removes another handler and THEN throws must still apply + /// the deregistration to subsequent emissions, even though the bus does not + /// swallow exceptions and aborts the current dispatch. This pins that the + /// snapshot bookkeeping is durable across exceptional control flow: the + /// frozen-snapshot list is unaffected by the throw (no rollback), and the + /// removal that A performed before throwing takes effect on the next emit. + /// Pairs with HandlerThrowPreventsSubsequentHandlers in + /// HandlerExceptionTests which pins the propagate-don't-swallow + /// contract for the same dispatch. + /// + [UnityTest] + public IEnumerator HandlerThrowAfterDeregistrationStillPropagatesRemovalToNextEmit( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HandlerThrowAfterDeregistrationStillPropagatesRemovalToNextEmit) + + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int aCount = 0; + int bCount = 0; + const string ThrowMessage = "DxMessaging-test-throw-after-deregister"; + MessageRegistrationHandle bHandle = default; + + MessageRegistrationHandle aHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => + { + ++aCount; + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + bHandle = default; + } + throw new InvalidOperationException(ThrowMessage); + } + ); + bHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 1, + onInvoked: () => ++bCount + ); + + // First emit: A runs, removes B, then throws. The exception propagates; + // B does NOT fire on this emission per the bus's "propagate don't swallow" + // contract (the snapshot has B but dispatch never reaches it). Although + // A removed B before throwing, the snapshot was already frozen; but the + // dispatch loop bails out of the bucket walk after A's throw. + InvalidOperationException firstThrow = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual( + ThrowMessage, + firstThrow.Message, + "[{0}] First emit must propagate A's exception. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + aCount, + "[{0}] A must have run exactly once before throwing. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 0, + bCount, + "[{0}] B must NOT run on first emit; propagation aborts the bucket walk. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + // Second emit: A still registered (its registration wasn't unwound by + // the throw), so it throws again. B's removal from the prior emit took + // effect; B is no longer in the snapshot, so bCount stays at 0. + InvalidOperationException secondThrow = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual( + ThrowMessage, + secondThrow.Message, + "[{0}] Second emit must also propagate A's exception. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 2, + aCount, + "[{0}] A must run on second emit (still registered). aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 0, + bCount, + "[{0}] B must remain removed despite the prior exception. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + token.RemoveRegistration(aHandle); + if (bHandle != default) + { + token.RemoveRegistration(bHandle); + } + yield break; + } + + /// + /// An interceptor that deregisters a handler causes that handler to be + /// skipped on the IN-FLIGHT emission. This is intentional and follows + /// directly from the dispatch order: interceptors run BEFORE the + /// dispatch snapshot is acquired (see UntargetedBroadcast, + /// TargetedBroadcast, SourcedBroadcast), so any + /// registration mutation an interceptor performs is observable to the + /// dispatch path on the same emit. This test pins that contract so + /// future refactors that move snapshot acquisition above the + /// interceptor pass fail loudly. (Contrast with + /// , where + /// the deregistration is performed by a peer HANDLER; handlers run + /// after the snapshot is frozen, so the snapshot still dispatches the + /// removed peer.) + /// + [UnityTest] + public IEnumerator DeregisterFromInterceptorIsObservedOnCurrentEmission( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(DeregisterFromInterceptorIsObservedOnCurrentEmission) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int interceptorCount = 0; + int handlerCount = 0; + MessageRegistrationHandle handlerHandle = default; + + handlerHandle = RegisterCountingHandler( + scenario, + token, + hostId, + priority: 0, + onInvoked: () => ++handlerCount + ); + + // Interceptor returns true (allows dispatch to proceed) but removes + // the handler before dispatch reads its snapshot. Because + // interceptors run before the snapshot is acquired, the handler is + // already gone by the time dispatch builds the bucket array. + RegisterRemovingInterceptor( + scenario, + token, + () => + { + ++interceptorCount; + if (handlerHandle != default) + { + token.RemoveRegistration(handlerHandle); + handlerHandle = default; + } + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + interceptorCount, + "[{0}] Interceptor must run once on first emit. interceptorCount={1}, handlerCount={2}.", + scenario.Kind, + interceptorCount, + handlerCount + ); + Assert.AreEqual( + 0, + handlerCount, + "[{0}] Handler removed by interceptor must NOT run on the same emission " + + "(interceptors precede snapshot acquisition). interceptorCount={1}, handlerCount={2}.", + scenario.Kind, + interceptorCount, + handlerCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + interceptorCount, + "[{0}] Interceptor must run again on second emit. interceptorCount={1}, handlerCount={2}.", + scenario.Kind, + interceptorCount, + handlerCount + ); + Assert.AreEqual( + 0, + handlerCount, + "[{0}] Removed handler must remain absent on subsequent emissions. interceptorCount={1}, handlerCount={2}.", + scenario.Kind, + interceptorCount, + handlerCount + ); + yield break; + } + + private static void RegisterRemovingInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + _ = ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => + { + onInvoked(); + return true; + } + ); + return; + } + case MessageKind.Targeted: + { + _ = ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage _) => + { + onInvoked(); + return true; + } + ); + return; + } + case MessageKind.Broadcast: + { + _ = ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage _) => + { + onInvoked(); + return true; + } + ); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static MessageRegistrationHandle RegisterCountingHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + int priority, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void RegisterDepthLimitedInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + int threshold, + Func getDepth, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + _ = ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => + { + onInvoked(); + return getDepth() < threshold; + } + ); + return; + } + case MessageKind.Targeted: + { + _ = ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage _) => + { + onInvoked(); + return getDepth() < threshold; + } + ); + return; + } + case MessageKind.Broadcast: + { + _ = ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage _) => + { + onInvoked(); + return getDepth() < threshold; + } + ); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/ReentrantEmissionTests.cs.meta b/Tests/Runtime/Core/ReentrantEmissionTests.cs.meta new file mode 100644 index 00000000..50535c77 --- /dev/null +++ b/Tests/Runtime/Core/ReentrantEmissionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 409242791a9a43059bc98a87f9580ea4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/RegistrationTests.cs b/Tests/Runtime/Core/RegistrationTests.cs index f27eaedd..35ded891 100644 --- a/Tests/Runtime/Core/RegistrationTests.cs +++ b/Tests/Runtime/Core/RegistrationTests.cs @@ -14,12 +14,23 @@ namespace DxMessaging.Tests.Runtime.Core using UnityEngine; using UnityEngine.TestTools; + [Category("Stress")] public sealed class RegistrationTests : MessagingTestBase { private GameObject _test; private EmptyMessageAwareComponent _component; private MessageRegistrationToken _token; + [SetUp] + public override void Setup() + { + base.Setup(); + // RunRegistrationTest validates ordering and dispatch correctness + // across every registered handler, so it relies on the legacy + // stress-scale count rather than the smoke-check default. + _numRegistrations = StressRegistrations; + } + [UnitySetUp] public override IEnumerator UnitySetup() { diff --git a/Tests/Runtime/Core/SingleThreadContractTests.cs b/Tests/Runtime/Core/SingleThreadContractTests.cs new file mode 100644 index 00000000..a628adb3 --- /dev/null +++ b/Tests/Runtime/Core/SingleThreadContractTests.cs @@ -0,0 +1,129 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Threading; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using BusType = DxMessaging.Core.MessageBus.MessageBus; + + /// + /// Pins the documented threading contract: bus operations are not guaranteed + /// thread-safe. These tests do not assert correctness under concurrency; they + /// only assert that current behavior does not change silently. If a future + /// change introduces real thread-safety enforcement (e.g. throwing on misuse), + /// the sentinel will fail and force a deliberate review of the contract. + /// + [TestFixture] + public sealed class SingleThreadContractTests + { + private const int OwnerInstanceId = 1; + private const int SerialOwnerInstanceId = 2; + private const int BackgroundJoinTimeoutMilliseconds = 2000; + + [Test] + public void BusOperationFromNonMainThreadDoesNotCrash() + { + BusType bus = new BusType(); + MessageHandler handler = new MessageHandler(new InstanceId(OwnerInstanceId), bus) + { + active = true, + }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + int invocationCount = 0; + _ = token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => Interlocked.Increment(ref invocationCount) + ); + token.Enable(); + + Exception captured = null; + Thread worker = new Thread(() => + { + try + { + SimpleUntargetedMessage message = new(); + bus.UntargetedBroadcast(ref message); + } + catch (Exception e) + { + captured = e; + } + }) + { + IsBackground = true, + Name = "DxMessagingNonMainThreadSentinel", + }; + + worker.Start(); + bool joined = worker.Join(BackgroundJoinTimeoutMilliseconds); + + Assert.IsTrue( + joined, + "Background bus operation must terminate within the join timeout." + ); + + // Pinning current behavior: the bus does not enforce a threading contract. + // The dispatch path has no thread checks, so the handler is expected to + // run on the worker thread without any framework-level exception. We + // require strictly that no exception escapes - if one does, the test + // fails with full diagnostics so the contract change is reviewed. + if (captured != null) + { + Assert.Fail( + $"Background thread emission produced unexpected exception: {captured}" + ); + } + + // Contract pins that no exception escapes; the handler runs on the + // worker thread under cross-thread misuse so the counter should advance + // at least once. Tearing reads are possible in theory but the lone + // worker scenario is not concurrent enough to exhibit them. + Assert.GreaterOrEqual( + invocationCount, + 1, + "Handler should have been invoked at least once before any potential failure." + ); + + token.Dispose(); + } + + /// + /// Determinism smoke check (not a concurrency test): a long sequence of + /// serial emissions on the main thread must produce exactly one handler + /// invocation per emission with no drift, drop, or double-fire. + /// + [Test] + public void RepeatedSerialEmitProducesDeterministicCounts() + { + BusType bus = new BusType(); + MessageHandler handler = new MessageHandler(new InstanceId(SerialOwnerInstanceId), bus) + { + active = true, + }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + int invocationCount = 0; + _ = token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++invocationCount + ); + token.Enable(); + + const int Iterations = 50; + for (int i = 0; i < Iterations; ++i) + { + SimpleUntargetedMessage message = new(); + bus.UntargetedBroadcast(ref message); + } + + Assert.AreEqual( + Iterations, + invocationCount, + "Repeated single-thread emissions must dispatch deterministically." + ); + + token.Dispose(); + } + } +} +#endif diff --git a/Tests/Runtime/Core/SingleThreadContractTests.cs.meta b/Tests/Runtime/Core/SingleThreadContractTests.cs.meta new file mode 100644 index 00000000..f3269d58 --- /dev/null +++ b/Tests/Runtime/Core/SingleThreadContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10e254df5f304b8aa5ff68d7b2691bfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs b/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs new file mode 100644 index 00000000..f1353475 --- /dev/null +++ b/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs @@ -0,0 +1,96 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections; + using System.Diagnostics; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using NUnit.Framework; + using Scripts.Components; + using Scripts.Messages; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// In-default-suite speed guard rail. The Unity Edit + Play mode test run + /// is supposed to finish in under a minute once stress, performance, and + /// allocation tests are filtered out. Verifying the wall-clock total of + /// every fixture from inside a single test is impossible (NUnit fixtures + /// do not compose), so this test instead measures a representative unit + /// of work that mirrors the per-test load of the default suite. If this + /// proxy regresses, the full default suite is almost certainly going to + /// breach the 60-second budget too. + /// + /// + /// This test runs as part of the default Unity test suite - it is a fast + /// guard rail that fails when local performance regresses below the + /// 60-second whole-suite budget. The category scheme used across the rest + /// of the test suite is: + /// + /// Stress - high-volume registration / emission tests. + /// Performance - throughput / latency benchmarks. + /// Allocation - the zero-GC matrix. + /// + /// CI runs the default suite (uncategorized tests, including this guard + /// rail) on every PR; the other categories are opt-in. + /// + public sealed class SuiteSpeedBudgetTest : MessagingTestBase + { + private const int RepresentativeCycles = 100; + private static readonly TimeSpan RepresentativeBudget = TimeSpan.FromSeconds(5); + + /// + /// This test runs as part of the default Unity test suite - it is a + /// fast guard rail that fails when local performance regresses below + /// the 60-second whole-suite budget. + /// + [UnityTest] + public IEnumerator RepresentativeSubsetCompletesUnderBudget() + { + GameObject host = new( + nameof(RepresentativeSubsetCompletesUnderBudget), + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + + // Warm up so JIT and bus internals do not skew the measurement. + MessageRegistrationHandle warmupHandle = + token.RegisterUntargeted(_ => { }); + SimpleUntargetedMessage warmup = new(); + warmup.EmitUntargeted(); + token.RemoveRegistration(warmupHandle); + + int total = 0; + Stopwatch timer = Stopwatch.StartNew(); + for (int round = 0; round < RepresentativeCycles; ++round) + { + MessageRegistrationHandle handle = + token.RegisterUntargeted(_ => ++total); + SimpleUntargetedMessage message = new(); + message.EmitUntargeted(); + token.RemoveRegistration(handle); + } + + timer.Stop(); + + Assert.AreEqual( + RepresentativeCycles, + total, + "Representative cycle count drifted; the speed budget proxy is no longer representative." + ); + Assert.That( + timer.Elapsed, + Is.LessThan(RepresentativeBudget), + $"Representative load took {timer.Elapsed.TotalSeconds:0.00}s " + + $"(budget: {RepresentativeBudget.TotalSeconds:0.00}s). " + + "If this regresses, the default Unity Edit+Play suite is likely to exceed the 60s budget." + ); + yield break; + } + } +} + +#endif diff --git a/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs.meta b/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs.meta new file mode 100644 index 00000000..851c9ae0 --- /dev/null +++ b/Tests/Runtime/Core/SuiteSpeedBudgetTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f1b9e3c2a4d5b6e8c9d0e1f2a3b4c5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/TestAttributeContractTests.cs b/Tests/Runtime/Core/TestAttributeContractTests.cs index cb4892d5..451c4604 100644 --- a/Tests/Runtime/Core/TestAttributeContractTests.cs +++ b/Tests/Runtime/Core/TestAttributeContractTests.cs @@ -6,6 +6,9 @@ namespace DxMessaging.Tests.Runtime.Core using System.Collections.Generic; using System.Linq; using System.Reflection; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime; using NUnit.Framework; using UnityEngine.TestTools; @@ -73,6 +76,261 @@ public void UnityTestsReturnIEnumerator() ); } + /// + /// Flags TRUE TRIPLETS: [UnityTest] methods whose names start + /// with Untargeted, Targeted, AND Broadcast for + /// the same kind-stripped base name within a single fixture, none of + /// which are parameterized over . Such + /// triplets should be consolidated via + /// [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))]. + /// Methods that exist in only one or two kind variants (legitimate + /// kind-asymmetric tests, like RemoveRegistrationInsideUntargetedHandler) + /// are intentionally not flagged, because their counterpart kinds + /// either do not exist or test materially different mechanics. + /// Kind-specific fixtures named *Specific*Tests (for example + /// EmitUntargetedSpecificTests) are exempt because their + /// assertion semantics do not translate across kinds. The contract + /// pin lives in the tests-must-be-parameterized-by-message-kind + /// skill. + /// + [Test] + public void TripletEmitTestsUseScenarioParameterization() + { + // Group [UnityTest] methods by their declaring fixture and by the + // kind-stripped base name. A "triplet" is a base name that has + // Untargeted, Targeted, AND Broadcast siblings in the same + // fixture, none of which already accept a MessageScenario + // parameter. Already-consolidated methods short-circuit out so + // they cannot accidentally satisfy the triplet criterion. + Dictionary>> tripletsByFixture = new(); + + foreach (MethodInfo method in GetRuntimeTestMethods()) + { + if (!HasAttribute(method)) + { + continue; + } + + Type fixture = method.DeclaringType; + if (fixture == null) + { + continue; + } + + if (fixture.Name.IndexOf("Specific", StringComparison.Ordinal) >= 0) + { + // Kind-specific fixtures are exempt by design. + continue; + } + + string name = method.Name; + string kind; + string kindStripped; + + if (name.StartsWith("Untargeted", StringComparison.Ordinal)) + { + kind = "Untargeted"; + kindStripped = name.Substring("Untargeted".Length); + } + else if (name.StartsWith("Targeted", StringComparison.Ordinal)) + { + kind = "Targeted"; + kindStripped = name.Substring("Targeted".Length); + } + else if (name.StartsWith("Broadcast", StringComparison.Ordinal)) + { + kind = "Broadcast"; + kindStripped = name.Substring("Broadcast".Length); + } + else + { + continue; + } + + if (HasMessageScenarioParameter(method)) + { + // Already consolidated; do not contribute to triplet bucket. + continue; + } + + if ( + !tripletsByFixture.TryGetValue( + fixture, + out Dictionary> nameMap + ) + ) + { + nameMap = new Dictionary>(StringComparer.Ordinal); + tripletsByFixture[fixture] = nameMap; + } + + if (!nameMap.TryGetValue(kindStripped, out HashSet kindsSet)) + { + kindsSet = new HashSet(StringComparer.Ordinal); + nameMap[kindStripped] = kindsSet; + } + + kindsSet.Add(kind); + } + + // Triplets intentionally not consolidated due to kind-asymmetric behavior. + // Each entry must include a justification comment explaining why + // consolidation is unsafe. Future maintainers should be able to remove + // an exemption if they consolidate the triplet later. + HashSet exemptedTriplets = new HashSet(StringComparer.Ordinal) + { + // NominalTests.RemoveOrder: the Untargeted variant exercises three + // Run blocks, while Targeted and Broadcast each exercise five Run + // blocks (extra ComponentTargeted/ComponentBroadcast permutations + // only available for those kinds). The bodies are not structurally + // identical, and consolidating would weaken the kind-asymmetric + // coverage the longer variants provide. + "DxMessaging.Tests.Runtime.Core.NominalTests.RemoveOrder", + // OrderingManyRegistrationsTests.PostProcessorsManyRegistrationsMaintainOrder: + // the Untargeted variant registers only fast post-processors with + // a single ordering list, while Targeted and Broadcast register + // both fast and action post-processors with two lists. The number + // of register loops and assertion shape differs across kinds, so + // consolidation would either drop assertions or test a code path + // (action post-processors) that is not exercised today on the + // untargeted bus. + "DxMessaging.Tests.Runtime.Core.OrderingManyRegistrationsTests.PostProcessorsManyRegistrationsMaintainOrder", + // RegistrationTests.Interceptor: the Untargeted variant emits via + // a single EmitUntargeted path, while Targeted and Broadcast each + // exercise BOTH the GameObject-targeted and Component-targeted + // (or GameObject-broadcast and Component-broadcast) emit paths in + // the post-deregistration assertion to prove deregistration applies + // across both targeting/source variants. Consolidation would + // reduce the targeted/broadcast assertions to a single emit path. + "DxMessaging.Tests.Runtime.Core.RegistrationTests.Interceptor", + }; + + List offenders = new(); + foreach ( + KeyValuePair< + Type, + Dictionary> + > fixturePair in tripletsByFixture + ) + { + foreach (KeyValuePair> namePair in fixturePair.Value) + { + if (namePair.Value.Count == 3) + { + string fullKey = $"{fixturePair.Key.FullName}.{namePair.Key}"; + if (exemptedTriplets.Contains(fullKey)) + { + continue; + } + + offenders.Add( + $"{fixturePair.Key.FullName}: triplet '*{namePair.Key}' " + + "(Untargeted, Targeted, Broadcast variants all exist; " + + "consolidate via [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))])" + ); + } + } + } + + Assert.That( + offenders, + Is.Empty, + "Found triplet [UnityTest] methods (Untargeted/Targeted/Broadcast variants of the same base name in the same fixture) " + + "that are not parameterized by MessageScenario. Consolidate via " + + "[ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))]. See " + + ".llm/skills/testing/tests-must-be-parameterized-by-message-kind.md. Offenders:\n" + + string.Join("\n", offenders) + ); + } + + private static bool HasMessageScenarioParameter(MethodInfo method) + { + return method + .GetParameters() + .Any(parameter => parameter.ParameterType == typeof(MessageScenario)); + } + + /// + /// Pins the allocation-coverage contract: every value of + /// must be represented in + /// . Adding a new kind without + /// updating the scenario source - and therefore the allocation matrix + /// that consumes it - will trip this guard. The contract pin lives in + /// the allocation-coverage-required-for-dispatch skill. + /// + [Test] + public void EveryEmitPathHasAllocationCoverage() + { + HashSet covered = new(MessageScenarios.AllKinds.Select(s => s.Kind)); + List missing = new(); + + foreach (MessageKind kind in Enum.GetValues(typeof(MessageKind))) + { + if (!covered.Contains(kind)) + { + missing.Add(kind.ToString()); + } + } + + Assert.That( + missing, + Is.Empty, + "MessageScenarios.AllKinds must yield every MessageKind so the allocation matrix " + + "and parameterized tests cover all dispatch paths. Missing kinds: " + + string.Join(", ", missing) + + ". See .llm/skills/testing/allocation-coverage-required-for-dispatch.md." + ); + } + + /// + /// Smoke-checks that - which + /// every MessagingTestBase.Setup invokes - actually wipes the + /// global bus counters back to zero. If a future change splits the + /// reset into pieces this guard will fail before contaminated state + /// leaks into the rest of the suite. + /// + [Test] + public void DxMessagingStaticStateResetClearsBusCounts() + { + // Pollute the global bus so we can prove Reset clears it. + MessageHandler pollutingHandler = new( + new InstanceId(unchecked((int)0x517E0001)), + MessageHandler.MessageBus + ) + { + active = true, + }; + MessageRegistrationToken pollutingToken = MessageRegistrationToken.Create( + pollutingHandler, + MessageHandler.MessageBus + ); + _ = + pollutingToken.RegisterUntargeted( + ( + ref DxMessaging.Tests.Runtime.Scripts.Messages.SimpleUntargetedMessage _ + ) => { } + ); + pollutingToken.Enable(); + + try + { + DxMessagingStaticState.Reset(); + + IMessageBus bus = MessageHandler.MessageBus; + Assert.IsNotNull(bus, "MessageHandler.MessageBus must not be null after Reset."); + Assert.Zero(bus.RegisteredUntargeted, "Setup must leave Untargeted count at zero."); + Assert.Zero(bus.RegisteredTargeted, "Setup must leave Targeted count at zero."); + Assert.Zero(bus.RegisteredBroadcast, "Setup must leave Broadcast count at zero."); + } + finally + { + // Best-effort cleanup; Reset above already cleared the bus, + // but disposing the token is a no-op on a fresh state. + pollutingToken.Disable(); + DxMessagingStaticState.Reset(); + } + } + private static IEnumerable FindMethods(Func predicate) { return GetRuntimeTestMethods().Where(predicate); diff --git a/Tests/Runtime/TestUtilities/AllocationAssertions.cs b/Tests/Runtime/TestUtilities/AllocationAssertions.cs new file mode 100644 index 00000000..fd9656a5 --- /dev/null +++ b/Tests/Runtime/TestUtilities/AllocationAssertions.cs @@ -0,0 +1,71 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + using System; + using NUnit.Framework; + using UnityEngine.TestTools.Constraints; + using Is = NUnit.Framework.Is; + + /// + /// Centralizes the warm-up + assert pattern that previously lived inline in + /// every benchmark. Keeping it in one place ensures each call site uses the + /// same warmup and measured-iteration counts so a regression in one path + /// shows the same way as a regression in any other. + /// + public static class AllocationAssertions + { + public const int DefaultWarmupIterations = 8; + public const int DefaultMeasuredIterations = 32; + + /// + /// Runs a handful of times to JIT it, then + /// asserts that running it more + /// times allocates zero managed bytes. Both the inner action and the + /// outer assertion lambda are warmed before measurement so first-call + /// JIT overhead does not pollute the result. + /// + public static void AssertNoAllocations( + string label, + Action action, + int warmupIterations = DefaultWarmupIterations, + int measuredIterations = DefaultMeasuredIterations + ) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (warmupIterations < 0) + { + throw new ArgumentOutOfRangeException(nameof(warmupIterations)); + } + + if (measuredIterations <= 0) + { + throw new ArgumentOutOfRangeException(nameof(measuredIterations)); + } + + for (int i = 0; i < warmupIterations; ++i) + { + action(); + } + + TestDelegate lambdaUnderTest = () => + { + for (int i = 0; i < measuredIterations; ++i) + { + action(); + } + }; + + // Warm the wrapper lambda itself once so the first invocation's + // delegate-creation / JIT cost does not show up inside the + // Is.Not.AllocatingGCMemory measurement below. + lambdaUnderTest(); + + Assert.That(lambdaUnderTest, Is.Not.AllocatingGCMemory(), label); + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/AllocationAssertions.cs.meta b/Tests/Runtime/TestUtilities/AllocationAssertions.cs.meta new file mode 100644 index 00000000..238f8def --- /dev/null +++ b/Tests/Runtime/TestUtilities/AllocationAssertions.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 4a3b0bec0efd771a0c49170454561c50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Tests/Runtime/TestUtilities/MessageKind.cs b/Tests/Runtime/TestUtilities/MessageKind.cs new file mode 100644 index 00000000..03d6f14d --- /dev/null +++ b/Tests/Runtime/TestUtilities/MessageKind.cs @@ -0,0 +1,15 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + /// + /// Identifies one of the three DxMessaging dispatch categories. Used by the + /// parameterized test harness so a single test method can cover all kinds. + /// + public enum MessageKind + { + Untargeted, + Targeted, + Broadcast, + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/MessageKind.cs.meta b/Tests/Runtime/TestUtilities/MessageKind.cs.meta new file mode 100644 index 00000000..e5cbfd24 --- /dev/null +++ b/Tests/Runtime/TestUtilities/MessageKind.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 28587d4d0a9dfe66700062eddd6bfe1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Tests/Runtime/TestUtilities/MessageScenario.cs b/Tests/Runtime/TestUtilities/MessageScenario.cs new file mode 100644 index 00000000..baa13011 --- /dev/null +++ b/Tests/Runtime/TestUtilities/MessageScenario.cs @@ -0,0 +1,157 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + using System; + using System.Text; + + /// + /// Immutable description of a single parameterized test case, consumed by + /// NUnit [ValueSource]. The harness uses to pick the + /// right registration / emission overloads, while the boolean toggles let the + /// same test method exercise interceptor, post-processor, and diagnostics + /// permutations. + /// + public sealed class MessageScenario : IEquatable + { + public MessageKind Kind { get; } + + public string DisplayName { get; } + + public bool UseInterceptor { get; } + + public bool UsePostProcessor { get; } + + public bool DiagnosticsEnabled { get; } + + public MessageScenario( + MessageKind kind, + bool useInterceptor = false, + bool usePostProcessor = false, + bool diagnosticsEnabled = false + ) + { + Kind = kind; + UseInterceptor = useInterceptor; + UsePostProcessor = usePostProcessor; + DiagnosticsEnabled = diagnosticsEnabled; + DisplayName = ComposeDisplayName( + kind, + useInterceptor, + usePostProcessor, + diagnosticsEnabled + ); + } + + public static MessageScenario Untargeted() + { + return new MessageScenario(MessageKind.Untargeted); + } + + public static MessageScenario Targeted() + { + return new MessageScenario(MessageKind.Targeted); + } + + public static MessageScenario Broadcast() + { + return new MessageScenario(MessageKind.Broadcast); + } + + public MessageScenario WithInterceptor(bool useInterceptor) + { + return new MessageScenario( + Kind, + useInterceptor: useInterceptor, + usePostProcessor: UsePostProcessor, + diagnosticsEnabled: DiagnosticsEnabled + ); + } + + public MessageScenario WithPostProcessor(bool usePostProcessor) + { + return new MessageScenario( + Kind, + useInterceptor: UseInterceptor, + usePostProcessor: usePostProcessor, + diagnosticsEnabled: DiagnosticsEnabled + ); + } + + public MessageScenario WithDiagnostics(bool diagnosticsEnabled) + { + return new MessageScenario( + Kind, + useInterceptor: UseInterceptor, + usePostProcessor: UsePostProcessor, + diagnosticsEnabled: diagnosticsEnabled + ); + } + + public override string ToString() + { + return DisplayName; + } + + public bool Equals(MessageScenario other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Kind == other.Kind + && UseInterceptor == other.UseInterceptor + && UsePostProcessor == other.UsePostProcessor + && DiagnosticsEnabled == other.DiagnosticsEnabled; + } + + public override bool Equals(object obj) + { + return obj is MessageScenario other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + int hash = (int)Kind; + hash = (hash * 397) ^ (UseInterceptor ? 1 : 0); + hash = (hash * 397) ^ (UsePostProcessor ? 1 : 0); + hash = (hash * 397) ^ (DiagnosticsEnabled ? 1 : 0); + return hash; + } + } + + private static string ComposeDisplayName( + MessageKind kind, + bool useInterceptor, + bool usePostProcessor, + bool diagnosticsEnabled + ) + { + StringBuilder builder = new StringBuilder(kind.ToString()); + if (useInterceptor) + { + builder.Append("+Interceptor"); + } + + if (usePostProcessor) + { + builder.Append("+PostProcessor"); + } + + if (diagnosticsEnabled) + { + builder.Append("+Diagnostics"); + } + + return builder.ToString(); + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/MessageScenario.cs.meta b/Tests/Runtime/TestUtilities/MessageScenario.cs.meta new file mode 100644 index 00000000..12dda88c --- /dev/null +++ b/Tests/Runtime/TestUtilities/MessageScenario.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 4aa0676d7274371db0baaf59943723f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Tests/Runtime/TestUtilities/MessageScenarios.cs b/Tests/Runtime/TestUtilities/MessageScenarios.cs new file mode 100644 index 00000000..a74588b7 --- /dev/null +++ b/Tests/Runtime/TestUtilities/MessageScenarios.cs @@ -0,0 +1,81 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + using System.Collections.Generic; + + /// + /// Property-based sources for NUnit + /// [ValueSource]. Each property exposes the cases for one scenario + /// permutation set; properties are used so the source resolves by name and + /// returns a fresh enumeration to the test runner on every access. + /// + /// + /// + /// [Test] + /// public void DispatchAcrossKinds( + /// [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + /// MessageScenario scenario) + /// { + /// // ... + /// } + /// + /// + public static class MessageScenarios + { + public static IEnumerable AllKinds + { + get + { + yield return MessageScenario.Untargeted(); + yield return MessageScenario.Targeted(); + yield return MessageScenario.Broadcast(); + } + } + + public static IEnumerable KindsWithComponentTarget + { + get + { + yield return MessageScenario.Targeted(); + yield return MessageScenario.Broadcast(); + } + } + + public static IEnumerable WithAndWithoutInterceptor + { + get + { + foreach (MessageScenario scenario in AllKinds) + { + yield return scenario.WithInterceptor(false); + yield return scenario.WithInterceptor(true); + } + } + } + + public static IEnumerable WithAndWithoutPostProcessor + { + get + { + foreach (MessageScenario scenario in AllKinds) + { + yield return scenario.WithPostProcessor(false); + yield return scenario.WithPostProcessor(true); + } + } + } + + public static IEnumerable WithDiagnosticsToggle + { + get + { + foreach (MessageScenario scenario in AllKinds) + { + yield return scenario.WithDiagnostics(false); + yield return scenario.WithDiagnostics(true); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/MessageScenarios.cs.meta b/Tests/Runtime/TestUtilities/MessageScenarios.cs.meta new file mode 100644 index 00000000..0f3d61b4 --- /dev/null +++ b/Tests/Runtime/TestUtilities/MessageScenarios.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 3ea9fee30eddabf3b66579f51bb24ac7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Tests/Runtime/TestUtilities/ScenarioHarness.cs b/Tests/Runtime/TestUtilities/ScenarioHarness.cs new file mode 100644 index 00000000..af7e8730 --- /dev/null +++ b/Tests/Runtime/TestUtilities/ScenarioHarness.cs @@ -0,0 +1,536 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + using System; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + + /// + /// Picks the correct register / emit overload for a given + /// . Three method families are exposed (one per + /// message kind) because the underlying interfaces are different and + /// generic-only dispatch is not expressible without runtime reflection. + /// + public static class ScenarioHarness + { + /// + /// Registers a handler for an untargeted message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterUntargeted( + MessageScenario scenario, + MessageRegistrationToken token, + MessageHandler.FastHandler handler, + int priority = 0 + ) + where TMessage : IUntargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + if (scenario.Kind != MessageKind.Untargeted) + { + throw new ArgumentException( + $"RegisterUntargeted requires Kind=Untargeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterUntargeted(handler, priority: priority); + } + + /// + /// Registers a handler for a targeted message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterTargeted( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + MessageHandler.FastHandler handler, + int priority = 0 + ) + where TMessage : ITargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + if (scenario.Kind != MessageKind.Targeted) + { + throw new ArgumentException( + $"RegisterTargeted requires Kind=Targeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterTargeted(target, handler, priority: priority); + } + + /// + /// Registers a handler for a broadcast message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterBroadcast( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId source, + MessageHandler.FastHandler handler, + int priority = 0 + ) + where TMessage : IBroadcastMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + if (scenario.Kind != MessageKind.Broadcast) + { + throw new ArgumentException( + $"RegisterBroadcast requires Kind=Broadcast but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterBroadcast(source, handler, priority: priority); + } + + /// + /// Emits an untargeted struct message via the canonical extension method. + /// + public static void EmitUntargeted( + MessageScenario scenario, + ref TMessage message, + IMessageBus messageBus = null + ) + where TMessage : struct, IUntargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (scenario.Kind != MessageKind.Untargeted) + { + throw new ArgumentException( + $"EmitUntargeted requires Kind=Untargeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + message.EmitUntargeted(messageBus); + } + + /// + /// Emits an untargeted reference-type message via the canonical extension method. + /// + public static void EmitUntargeted( + MessageScenario scenario, + TMessage message, + IMessageBus messageBus = null + ) + where TMessage : class, IUntargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (scenario.Kind != MessageKind.Untargeted) + { + throw new ArgumentException( + $"EmitUntargeted requires Kind=Untargeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + message.EmitUntargeted(messageBus); + } + + /// + /// Emits a targeted struct message via the canonical extension method. + /// + public static void EmitTargeted( + MessageScenario scenario, + ref TMessage message, + InstanceId target, + IMessageBus messageBus = null + ) + where TMessage : struct, ITargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (scenario.Kind != MessageKind.Targeted) + { + throw new ArgumentException( + $"EmitTargeted requires Kind=Targeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + message.EmitTargeted(target, messageBus); + } + + /// + /// Emits a targeted reference-type message via the canonical extension method. + /// + public static void EmitTargeted( + MessageScenario scenario, + TMessage message, + InstanceId target, + IMessageBus messageBus = null + ) + where TMessage : class, ITargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (scenario.Kind != MessageKind.Targeted) + { + throw new ArgumentException( + $"EmitTargeted requires Kind=Targeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + message.EmitTargeted(target, messageBus); + } + + /// + /// Emits a broadcast struct message via the canonical extension method. + /// + public static void EmitBroadcast( + MessageScenario scenario, + ref TMessage message, + InstanceId source, + IMessageBus messageBus = null + ) + where TMessage : struct, IBroadcastMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (scenario.Kind != MessageKind.Broadcast) + { + throw new ArgumentException( + $"EmitBroadcast requires Kind=Broadcast but got {scenario.Kind}.", + nameof(scenario) + ); + } + + message.EmitBroadcast(source, messageBus); + } + + /// + /// Emits a broadcast reference-type message via the canonical extension method. + /// + public static void EmitBroadcast( + MessageScenario scenario, + TMessage message, + InstanceId source, + IMessageBus messageBus = null + ) + where TMessage : class, IBroadcastMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (scenario.Kind != MessageKind.Broadcast) + { + throw new ArgumentException( + $"EmitBroadcast requires Kind=Broadcast but got {scenario.Kind}.", + nameof(scenario) + ); + } + + message.EmitBroadcast(source, messageBus); + } + + /// + /// Registers an interceptor for an untargeted message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterUntargetedInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + IMessageBus.UntargetedInterceptor interceptor, + int priority = 0 + ) + where TMessage : IUntargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (interceptor == null) + { + throw new ArgumentNullException(nameof(interceptor)); + } + + if (scenario.Kind != MessageKind.Untargeted) + { + throw new ArgumentException( + $"RegisterUntargetedInterceptor requires Kind=Untargeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterUntargetedInterceptor(interceptor, priority: priority); + } + + /// + /// Registers an interceptor for a targeted message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterTargetedInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + IMessageBus.TargetedInterceptor interceptor, + int priority = 0 + ) + where TMessage : ITargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (interceptor == null) + { + throw new ArgumentNullException(nameof(interceptor)); + } + + if (scenario.Kind != MessageKind.Targeted) + { + throw new ArgumentException( + $"RegisterTargetedInterceptor requires Kind=Targeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterTargetedInterceptor(interceptor, priority: priority); + } + + /// + /// Registers an interceptor for a broadcast message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterBroadcastInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + IMessageBus.BroadcastInterceptor interceptor, + int priority = 0 + ) + where TMessage : IBroadcastMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (interceptor == null) + { + throw new ArgumentNullException(nameof(interceptor)); + } + + if (scenario.Kind != MessageKind.Broadcast) + { + throw new ArgumentException( + $"RegisterBroadcastInterceptor requires Kind=Broadcast but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterBroadcastInterceptor(interceptor, priority: priority); + } + + /// + /// Registers a post-processor for an untargeted message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterUntargetedPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + MessageHandler.FastHandler postProcessor, + int priority = 0 + ) + where TMessage : IUntargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (postProcessor == null) + { + throw new ArgumentNullException(nameof(postProcessor)); + } + + if (scenario.Kind != MessageKind.Untargeted) + { + throw new ArgumentException( + $"RegisterUntargetedPostProcessor requires Kind=Untargeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterUntargetedPostProcessor( + postProcessor, + priority: priority + ); + } + + /// + /// Registers a post-processor for a targeted message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterTargetedPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + MessageHandler.FastHandler postProcessor, + int priority = 0 + ) + where TMessage : ITargetedMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (postProcessor == null) + { + throw new ArgumentNullException(nameof(postProcessor)); + } + + if (scenario.Kind != MessageKind.Targeted) + { + throw new ArgumentException( + $"RegisterTargetedPostProcessor requires Kind=Targeted but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterTargetedPostProcessor( + target, + postProcessor, + priority: priority + ); + } + + /// + /// Registers a post-processor for a broadcast message. The scenario's + /// must be . + /// + public static MessageRegistrationHandle RegisterBroadcastPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId source, + MessageHandler.FastHandler postProcessor, + int priority = 0 + ) + where TMessage : IBroadcastMessage + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (postProcessor == null) + { + throw new ArgumentNullException(nameof(postProcessor)); + } + + if (scenario.Kind != MessageKind.Broadcast) + { + throw new ArgumentException( + $"RegisterBroadcastPostProcessor requires Kind=Broadcast but got {scenario.Kind}.", + nameof(scenario) + ); + } + + return token.RegisterBroadcastPostProcessor( + source, + postProcessor, + priority: priority + ); + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/ScenarioHarness.cs.meta b/Tests/Runtime/TestUtilities/ScenarioHarness.cs.meta new file mode 100644 index 00000000..809a9bc4 --- /dev/null +++ b/Tests/Runtime/TestUtilities/ScenarioHarness.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 3e69bdb973f18639f8bd5b7f57860a95 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 9b0ef31f..aa352ed7 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 19,842,500 | No | -| UniRx MessageBroker | 17,904,822 | No | -| MessagePipe (Global) | 97,610,562 | No | -| Zenject SignalBus | 2,569,043 | Yes | +| DxMessaging (Untargeted) - No-Copy | 19,410,250 | No | +| UniRx MessageBroker | 17,983,998 | No | +| MessagePipe (Global) | 97,769,139 | No | +| Zenject SignalBus | 2,420,160 | Yes | ### Comparisons (macOS) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index f87ac82e..408dc786 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -32,17 +32,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,622,657 | Yes | -| DxMessaging (GameObject) - Normal | 10,033,962 | No | -| DxMessaging (Component) - Normal | 10,047,509 | No | -| DxMessaging (GameObject) - No-Copy | 11,394,149 | No | -| DxMessaging (Component) - No-Copy | 8,577,398 | No | -| DxMessaging (Untargeted) - No-Copy | 19,529,600 | No | -| DxMessaging (Untargeted) - Interceptors | 7,639,572 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,476,848 | No | -| Reflexive (One Argument) | 2,837,183 | No | -| Reflexive (Two Arguments) | 2,300,236 | No | -| Reflexive (Three Arguments) | 2,322,060 | No | +| Unity | 2,381,723 | Yes | +| DxMessaging (GameObject) - Normal | 10,899,636 | No | +| DxMessaging (Component) - Normal | 10,986,548 | No | +| DxMessaging (GameObject) - No-Copy | 12,517,418 | No | +| DxMessaging (Component) - No-Copy | 9,296,087 | No | +| DxMessaging (Untargeted) - No-Copy | 19,309,752 | No | +| DxMessaging (Untargeted) - Interceptors | 7,696,568 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,545,742 | No | +| Reflexive (One Argument) | 2,846,178 | No | +| Reflexive (Two Arguments) | 2,345,039 | No | +| Reflexive (Three Arguments) | 2,330,838 | No | ## macOS diff --git a/llms.txt b/llms.txt index 9a479f8c..fd645b1c 100644 --- a/llms.txt +++ b/llms.txt @@ -208,7 +208,7 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: - **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 135+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 138+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -286,5 +286,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-01 +**Last Updated:** 2026-05-02 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata