Skip to content

test: add unit test suite and pull_request CI - #183

Draft
claude[bot] wants to merge 8 commits into
mainfrom
test/unit-test-suite-ci
Draft

claude[bot] wants to merge 8 commits into
mainfrom
test/unit-test-suite-ci

Conversation

@claude

@claude claude Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Requested by mwfarb · Slack thread

WIP / draft.

Before

There are no C# tests anywhere in the repository, and nothing at all is triggered by pull_request — grep -rn pull_request .github/ returns nothing on main. The only automated checks are the two native-plugin builds (pinned to feature branches) and release-please. RELEASE.md's prerequisites are entirely manual: "ensure the project compiles" and "hit Play and verify".

Tests/ exists but holds two shell scripts and eight JSON scene dumps — no test code.

After

Two suites, split by what they need to run rather than by what they cover, and a pull_request workflow that gates on the half which needs no licence.

Suite Location Needs In CI
Pure .NET Tests~/DotNet/ArenaUnity.PureTests .NET 8 SDK Always — this is the gate
Unity EditMode Tests/Editor Unity 6 Editor Only with licence secrets

What the .NET job gates today: 273 tests, ~150 ms, no secrets, so it runs identically on fork pull requests. It covers all 29 ArenaTopics getters as exact strings plus the positional invariants the inbound router depends on, ArenaCssColors.Normalize and its 148-entry table, the colour JSON converters, the full legacy-tolerance matrix of the boolean-object and string-object converters, and all 19 environment presets.

The Unity EditMode job stays skipped until licence secrets exist. A ${{ secrets.* }} expression is not evaluated in a job-level if:, so a small check-unity-license job probes for the secrets and publishes a job output that unity-editmode-tests gates on with needs:/if:. A skipped job does not fail the pull request, and secrets are never exposed to fork pull requests, so fork contributions skip it by design. To enable it, add under Settings > Secrets and variables > Actions either UNITY_LICENSE, or UNITY_EMAIL + UNITY_PASSWORD + UNITY_SERIAL.

How

The .NET project never copies package source — it links the real files out of Runtime/ as MSBuild Compile items, so the tests always compile the shipping code. Only the five sources verified free of UnityEngine are linked in. No UnityEngine shim, stub, or fake exists anywhere in this branch; a test that exercised a reimplementation of Vector3 would prove nothing about the real one, so anything needing Unity went to the EditMode suite instead. ArenaVectorJsonConverter was a candidate but its ArenaVector3Json / ArenaVector2Json types use [Tooltip] and Debug.LogWarning, so it is covered from the Unity side.

Tests~/ uses the same ~ convention as Samples~/ and Documentation~/, so UPM and Unity ignore it and nothing under it needs a .meta. Tests/Editor is imported, so every file there has one. The test assembly mirrors the Editor/VersionCheck isolation pattern: Editor-only, autoReferenced: false, gated on UNITY_INCLUDE_TESTS.

Because arena-unity is a UPM package with no Assets/ or ProjectSettings/, the Unity job creates a throwaway project, adds the checked-out package from disk, and sets "testables": ["io.conix.arena.unity"]. com.unity.test-framework is pinned to 1.4.6 per the pin-all-versions rule; it brings com.unity.ext.nunit (NUnit 3.5), so EditMode assertions stay inside the NUnit 3.0 API surface. Every action is SHA-pinned with the mandatory inline # vX.Y.Z comment.

com.unity.test-framework is not added to the package's dependencies by this branch. It is needed to run the tests, not to use the library, so forcing it on every consumer would be wrong. main never listed it: an earlier commit here (dfff1fe) added it to package.json and a later one (8f0bcec) took it back out, so package.json is unchanged relative to main and is not among this branch's changed files. The project that runs the tests declares the dependency itself, exactly pinned, next to its testables entry. The CI harness manifest above already did exactly that, so the EditMode job is unaffected, and Documentation~/Testing.md and CONTRIBUTING.md now tell a developer to do the same locally. Tests/Editor's asmdef is unchanged. What keeps that assembly out of a consumer's project is the testables gate, not its UNITY_INCLUDE_TESTS define constraint: UNITY_INCLUDE_TESTS is defined whenever the Editor compiles, so the constraint is satisfied there. What the constraint does is mark the assembly as a test assembly, which is both what subjects it to the testables check (CustomScriptAssembly.IsCompatibleWith returns false for a test assembly whose package metadata is not IsTestable) and what drops it from player builds, where the symbol is not defined. So a project that adds the package without naming it in testables never compiles that assembly, and its UnityEngine.TestRunner and nunit.framework.dll references are never resolved there.

New doc at Documentation~/Testing.md, linked from the README reference list, the AGENTS.md index, and CONTRIBUTING.md. RELEASE.md's prerequisites now start with running the tests.

Tests I have not run myself

The EditMode suite. There is no Unity Editor in the environment that produced this branch, so those ~1,900 lines are unexecuted. To keep that honest:

  • Every file was compiled against the real NUnit and Newtonsoft.Json assemblies with only UnityEngine, UnityEditor and conix.arena.unity left unresolved: no syntax errors, no NUnit or Newtonsoft API misuse.
  • Assertions that could not be derived with certainty from the source were left out rather than guessed — arbitrary-rotation round trips through eulerAngles, ColorUtility internal representation, Shader.Find paths, ParametricPlaneRandom, the internal mesh builders, and anything touching AR Foundation.
  • The most likely first-run failure is LogAssert.NoUnexpectedReceived() in the scene-corpus tests, which would be genuine signal: it would mean a real persisted scene trips an [OnError] handler.

The .NET suite was mutation-tested — 21 mutations across all five fixtures (a changed topic string, a deleted getter, a dropped dictionary entry, inverted branches, a broken comparer, corrupted preset values), each confirmed to fail a specific named test, each reverted, with git diff on Runtime/ empty afterwards. No mutation survived.

Behaviours pinned as currently-buggy

These tests assert behaviour that is wrong, so a known fault cannot change silently. Each carries a // PINS CURRENT BEHAVIOUR (bug) comment naming file:line, the correct behaviour, and the assertion to flip. Fixing one of these means flipping its assertion, not deleting the test.

Filed separately (this branch pins them, it does not fix them):

Found while writing these tests, not yet filed:

  • ArenaColorArrayJsonConverter.WriteJson (ArenaColorJsonConverter.cs:274) throws on a null array, unlike its scalar sibling. Newtonsoft's null-member short-circuit hides it on the common path.
  • ArenaBooleanObjectJsonConverter.WriteJson (ArenaBooleanObjectJsonConverter.cs:58) throws on null, although ReadJson returns null for both JSON null and boolean false.
  • ArenaStringObjectJsonConverter.cs:36 formats numbers with CurrentCulture, so under a comma-decimal locale an animation to / from value goes out as 1,5 and the A-Frame client cannot parse it. The same line emits .NET True / False casing for booleans instead of JavaScript's lowercase. Pinned with the culture set explicitly so the test does not depend on runner locale.
  • SphereBuilder.cs:54 over-allocates its index buffer and leaves a tail of degenerate zero-area triangles — 36 at the default 24x16 segmentation.
  • new ArenaTopics() zero-initializes the struct rather than running the all-optional-argument constructor, so every segment property is null, not empty string. The topic strings still come out well-shaped because interpolation renders null as empty, but topics.nameSpace.Length throws.
  • PlaneBuilder.Build divides by segments - 1 after clamping to 1, so a segment count below 1 yields NaN vertices. Not tested, to avoid a NaN-producing mesh in the suite.
  • meeting.json uses object_type: "prism", which is handled nowhere in the package.

Generated by Claude Code

claude added 7 commits August 19, 2026 16:02
Adds Tests~/DotNet/ArenaUnity.PureTests, a plain net8.0 NUnit project that
compiles the real package sources via <Compile Include> links rather than
copies, so the tests always exercise the shipping code. It needs no Unity
installation and no Unity license, which makes it usable as a pull-request
gate.

Only sources that are genuinely free of UnityEngine are linked in:

  Runtime/ArenaTopics.cs
  Runtime/Components/ArenaSceneEnvPresetsDefaults.cs
  Runtime/Schemas/Converter/ArenaColorJsonConverter.cs
  Runtime/Schemas/Converter/ArenaBooleanObjectJsonConverter.cs
  Runtime/Schemas/Converter/ArenaStringObjectJsonConverter.cs

272 tests covering:

  - all 29 ArenaTopics getters as exact strings, plus the positional
    invariants the inbound router in ArenaClientScene.Messaging.cs relies on
    (ArenaTopicTokens slot indices and the ArenaTopicSceneMsgTypes alphabet).
    Backs CONTRIBUTING.md rule #1 and the wire protocol shared with arena-py.
  - ArenaCssColors.Normalize and the integrity of its 148-entry table,
    including the CSS gray/grey and fuchsia/magenta synonym pairs.
  - ArenaColorJsonConverter and ArenaColorArrayJsonConverter round trips.
  - the full legacy-tolerance matrix of ArenaBooleanObjectJsonConverter<T>
    and ArenaStringObjectJsonConverter.
  - all 19 environment presets: valid JSON, the complete documented key set,
    A-Frame enum vocabularies, and colour values that normalize to hex.

Tests that need UnityEngine or the Editor are not here; they belong in the
Unity Test Runner suite.

Three pre-existing faults are pinned rather than fixed, each with a
// PINS CURRENT BEHAVIOUR (bug) comment naming file:line and the assertion to
flip once the fault is corrected:

  - ArenaColorJsonConverter.cs:274 ArenaColorArrayJsonConverter.WriteJson
    throws on a null array, unlike its scalar sibling.
  - ArenaBooleanObjectJsonConverter.cs:58 WriteJson throws on null, although
    ReadJson returns null for both JSON null and boolean false.
  - ArenaStringObjectJsonConverter.cs:36 formats numbers with CurrentCulture,
    so under a comma-decimal locale an animation to/from value goes out as
    "1,5" and the A-Frame client cannot parse it. The same line emits .NET
    "True"/"False" casing for booleans instead of JavaScript's lowercase.

The .gitignore blanket *.csproj rule exists for the project files Unity
generates and discards; this one is hand-maintained, so it is exempted, and
the test project's bin/ is ignored instead.
Adds Tests/Editor with an isolated test assembly following the same shape as
Editor/VersionCheck: Editor-only platform, autoReferenced off, and gated on
UNITY_INCLUDE_TESTS so it never enters a player build. overrideReferences is
on, so both nunit.framework.dll and Newtonsoft.Json.dll are named explicitly
in precompiledReferences.

Pins com.unity.test-framework to 1.4.6, the current stable release for
Unity 6; it brings com.unity.ext.nunit 2.0.3 (NUnit 3.5), so the assertions
here stay within the NUnit 3.0 API surface. A consuming project also needs
"testables": ["io.conix.arena.unity"] in its Packages/manifest.json for the
Test Runner to discover these tests; see Documentation~/Testing.md.

Covers only what genuinely needs UnityEngine or the Editor, with no overlap
with the plain .NET suite:

  - ArenaMqttClient.MqttTopicMatch: the MQTT wildcard matrix, the JWT
    publish-permission scenarios it actually serves in HasPerms, and the
    cross-class invariants that tie it to ArenaTopics - every SUB_* filter
    against its PUB_* topics, including the privacy direction where the
    public filter must NOT match an eight-segment private topic.
  - ArenaMqttClient.Base64UrlDecode.
  - ArenaUnity coordinate conversions: explicit Z-negation expectations,
    position/rotation/scale round trips, ArenaFloat, TimeSpanToString,
    MergeRawJson including array replacement, IsGltfType and primitives.
  - ArenaMessageJson's [OnDeserialized] attributes-to-data migration.
  - a corpus suite over all 8 Tests/JSON Scenes fixtures: deserialization,
    no unexpected log warnings, stable re-serialization, and a guard that the
    three legacy attributes-shaped fixtures stay legacy.
  - a reflection contract suite over every schema DTO: unique [JsonProperty]
    names, a ShouldSerialize for every wire member, def-backed defaults that
    match a real member, no undeclared members emitted, and [EnumMember]
    round trips. Rather than asserting a flat "{}" - which 62 DTOs correctly
    violate by hardcoding "required in json schema" - a fresh instance is
    required to emit exactly the members its own ShouldSerialize methods ask
    for.
  - the deterministic, publicly reachable ArenaMesh builders.

Behaviours pinned as currently-buggy, each with a comment naming file:line
and the assertion to flip:

  - Base64UrlDecode never maps '-' to '+' or '_' to '/', so a JWT payload
    segment in the base64url alphabet throws FormatException. Every caller
    feeds it a JWT. Refs #180. The test that the fix should make pass is
    already written and marked [Ignore].
  - SphereBuilder over-allocates its index buffer and leaves a tail of
    degenerate (0,0,0) triangles - 36 at the default segmentation.
  - ToUnityPositionString throws on a one- or two-component coordinate
    string, where ArenaVector3JsonConverter tolerates both.
  - TimeSpanToString tests each component's absolute value but formats the
    signed one, so a negative span renders negative numbers.

These tests cannot be executed without a Unity Editor. They were verified by
compiling every file against the real NUnit and Newtonsoft.Json assemblies,
leaving only UnityEngine, UnityEditor and conix.arena.unity unresolved: no
syntax errors and no NUnit or Newtonsoft API misuse. Assertions that could
not be derived with certainty from the source were left out rather than
guessed.
This repository had nothing triggered by pull_request before now. The new
.github/workflows/test.yaml runs on every pull request and on pushes to main,
with three jobs:

  - dotnet-tests: the real gate. Runs the plain .NET suite in Tests~/DotNet
    with NuGet caching. Uses no secrets, so it works identically for pull
    requests from forks, and it needs no Unity licence.
  - check-unity-license: probes for Unity licence secrets and publishes the
    answer as a job output. A ${{ secrets.* }} expression is not evaluated in
    a job-level if:, so the check has to happen in a step.
  - unity-editmode-tests: gated on that output, so a secret-less or fork run
    skips it instead of failing the pull request. Since arena-unity is a UPM
    package with no Assets/ or ProjectSettings/, the job creates a throwaway
    Unity project, adds the checked-out package to it by relative path, and
    sets "testables" so Tests/Editor is discovered.

Every action is pinned to a full commit SHA with the mandatory inline
# vX.Y.Z comment, per the CI conventions in CONTRIBUTING.md. The Unity editor
version is pinned to a patch release that has a published unityci/editor
image and is hoisted into a workflow-level env so it can be bumped in one
place.

Also adds Documentation~/Testing.md covering, for a developer on their own
machine: prerequisites and the exact command for each suite, how to filter
down to a single fixture or test, how to get the package into a project with
"testables" set, the Test Runner and -runTests -batchmode paths, which job CI
actually gates on and which secrets a maintainer must add to enable the other,
where to put a new test in each suite, and the PINS CURRENT BEHAVIOUR (bug)
convention so that whoever fixes one of those faults flips the assertion
instead of deleting the test.

Linked from the README library reference, the AGENTS.md index and
CONTRIBUTING.md. RELEASE.md's prerequisites previously listed only manual
verification; running the tests is now the first item.
The EditMode suite had never been executed. A real Editor run fails to
compile it, and once it compiles 73 of its test cases fail.

1. ArenaSchemaContractTests referenced ArenaUnity.Schemas.ArenaMessageJson
   from inside namespace ArenaUnity.Tests. `ArenaUnity` is both a namespace
   and a static class (Runtime/ArenaUnity.cs), so the name binds to the
   class and the compiler reports CS0426: "The type name 'Schemas' does not
   exist in the type 'ArenaUnity'". Use `using ArenaUnity.Schemas;` and the
   unqualified type name, as ArenaMessageJsonTests already does.

2. Dto_EmitsNoUndeclaredMembers and
   Dto_FreshInstanceSerializesExactlyItsRequiredMembers derived their
   expectations from [JsonProperty] fields only. Newtonsoft also serializes
   unattributed public members, and this schema set relies on that for the
   `object_type` discriminator and for the url/dep auto-properties on the
   hand DTOs. Both tests failed for 36 and 40 DTOs respectively. Widen the
   member query to what the default contract resolver actually emits.

3. UnknownMembers_SurviveARoundTrip asserted that an unknown "createdAt"
   member survives verbatim. It does not: Newtonsoft's default
   DateParseHandling converts the extension-data value to a DateTime, so
   the milliseconds are dropped on re-emit and reading the token back as a
   string yields a culture-formatted date. Pin the real behaviour, keep a
   non-date member to cover verbatim preservation, and say what the fix
   would be.

Also pin the hand DTOs emitting url and dep as explicit JSON nulls on
every publish, which matters because ARENA reads a null attribute as a
delete.

Verified by compiling all seven EditMode files against real NUnit 3.14 and
Newtonsoft 13.0.3 with the ArenaUnity class/namespace collision reproduced
(0 errors, 0 warnings), and by executing the five files that need no Unity
math: 626 passed, 1 ignored, 0 failed.
Independent mutation testing found two surviving mutants in the colour
table: changing peachpuff from #FFDAB9 to #FFDAB8, and swapping the values
of olive and olivedrab, both left all 272 tests green. The existing tests
check the count, the hex format, key casing, synonym agreement and eight
spot values, which leaves 131 of the 148 entries with no assertion on their
value at all - and Normalize_IsIdempotentOverTheWholeTable uses the table
as its own oracle, so it cannot notice.

Add the complete CSS Color Module Level 4 table as an independent
expectation, cross-checked against the color-name reference table. Both
mutants are now caught by
NameToHex_MatchesTheCssLevel4NamedColourTableEntryForEntry.

Also stop spelling ArenaCssColors as ArenaUnity.Schemas.Converter.ArenaCssColors
in the presets fixture. `ArenaUnity` is a static class as well as a
namespace, so that spelling only compiles because Runtime/ArenaUnity.cs is
not a linked source of this project - it is the same construct that failed
to compile in the EditMode suite.

273 passed, 0 failed.
…sertions

Preset_HasNoDuplicateKeys could not fail. Its own comment says JObject.Parse
silently keeps only the last of a duplicated key, then the test asserts
uniqueness on the parsed properties - which a JObject enforces by
construction. Adding a duplicated "seed" to the tron preset left all 272
tests green. Count the property-name tokens in the raw literal instead, and
also compare that count against the parsed property count so a silently
collapsed key is reported. The duplicate-key mutation now fails Preset_tron.

ToUnityRotationQuat_QuaternionOverload_IsItsOwnInverse asserted only that
applying the overload twice restores the original, which an implementation
that negated the wrong components - or the identity function - would also
satisfy. Assert the single application's component signs, both with and
without invertY, and keep the involution check.

Drop the redundant step-level `env: UNITY_VERSION: ${{ env.UNITY_VERSION }}`
in the Unity job. It re-declares the name it reads inside its own env block,
which is the one place the env context is documented not to resolve; the
workflow-level value is already visible to the script, and this step writes
ProjectVersion.txt, so an empty value would fail the run confusingly.

Correct three source line references in pin comments (SphereBuilder.cs:20
not :21, PolyhedronBuilder.cs:39-51, ArenaMqttClient.cs:240-246) and say
[Ignore] attribute rather than Assert.Ignore where the base64url pin
explains how to enable the post-fix test.

dotnet suite: 273 passed. The five EditMode files that need no Unity math:
626 passed, 1 ignored. All seven still typecheck with 0 warnings.
The Unity Test Framework is only needed to run the EditMode suite in
Tests/Editor, never to consume the library, so shipping it as a runtime
dependency forced it on every consumer of the package.

A project that runs the tests now installs it itself, pinned, alongside
its "testables" entry. The CI harness manifest in test.yaml already did
exactly that, so the EditMode job is unaffected; Documentation~/Testing.md
and CONTRIBUTING.md now tell a developer to do the same locally.

Tests/Editor/conix.arena.unity.Tests.Editor.asmdef is unchanged: its
UNITY_INCLUDE_TESTS define constraint plus the "testables" gate mean a
consumer that has not opted into the tests never compiles that assembly,
so its UnityEngine.TestRunner and nunit.framework.dll references are
never resolved.
Documentation~/Testing.md said the package "has to be listed in
testables, because Unity only discovers tests inside a package when it
is named there". Discovery is only half of it: testables decides whether
the test assembly is compiled at all, and it is the only thing that
keeps Tests/Editor out of a project that merely consumes the package.

Verified against UnityCsReference. CustomScriptAssembly.IsCompatibleWith
treats an asmdef as a test assembly when its defineConstraints contain
UNITY_INCLUDE_TESTS, and then rejects it in two separate places:

  if (!buildingForEditor && (isTestAssembly || isTestFrameworkAssembly)
      && !isBuildingWithTestAssemblies)
      return false;

  if (isTestAssembly && AssetPathMetaData != null
      && !AssetPathMetaData.IsTestable)
      return false;

The first only fires outside the Editor, because
EditorCompilationInterface.GetAdditionalEditorScriptCompilationOptions
sets both BuildingForEditor and BuildingIncludingTestAssemblies
unconditionally for AssembliesType.Editor - so it earns its keep in
player builds, where UNITY_INCLUDE_TESTS is also simply not defined. The
second is the testables gate: AssetPathMetaData.IsTestable is filled in
from the Package Manager over [RequiredByNativeCode], and is false for a
non-embedded package the project does not name in testables.

So the constraint is what marks the assembly as a test assembly - which
is what subjects it to the testables check and what drops it from player
builds - while testables is what a consuming project's Editor actually
gates on. Sharpen the testables sentence and spell the mechanism out so
the shorter, wrong version does not grow back.

Documentation only; the asmdef's defineConstraints is correct as it
stands and is unchanged. dotnet suite: 273 passed, 0 failed.
This was referenced Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant