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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Runtime/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Asobi.Tests")]
11 changes: 11 additions & 0 deletions Runtime/AssemblyInfo.cs.meta

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

35 changes: 34 additions & 1 deletion Runtime/WebSocket/AsobiRealtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,20 @@ public class AsobiRealtime : IDisposable
public event Action<string> OnVoteCastOk;
public event Action<string> OnVoteVetoOk;
public event Action<string> OnError;
public event Action<string> OnHeartbeat;
public event Action<string> OnMatchFinished;
public event Action<string> OnMatchmakerExpired;
public event Action<string> OnMatchmakerFailed;
public event Action<string> OnWorldFinished;
public event Action<string> OnWorldList;
public event Action<string> OnWorldPhaseChanged;

internal AsobiRealtime(AsobiClient client) => _client = client;

// Test-only: construct without a client/WebSocket so dispatch logic
// can be exercised in isolation.
internal AsobiRealtime() { }

public async Task ConnectAsync()
{
if (IsConnected) return;
Expand Down Expand Up @@ -275,7 +286,7 @@ async Task ReceiveLoop()
}
}

void HandleMessage(string raw)
internal void HandleMessage(string raw)
{
var msg = JsonUtility.FromJson<WsMessage>(raw);
if (msg == null) return;
Expand Down Expand Up @@ -304,10 +315,22 @@ void HandleMessage(string raw)
case "notification.new":
OnNotification?.Invoke(raw);
break;
// TODO deprecate: server only emits "match.matched". The
// "matchmaker.matched" alias is kept defensively against
// historical drift; remove in a future major version.
case "matchmaker.matched":
case "match.matched":
OnMatchmakerMatched?.Invoke(raw);
break;
case "match.finished":
OnMatchFinished?.Invoke(raw);
break;
case "match.matchmaker_expired":
OnMatchmakerExpired?.Invoke(raw);
break;
case "match.matchmaker_failed":
OnMatchmakerFailed?.Invoke(raw);
break;
case "match.vote_start":
OnVoteStart?.Invoke(raw);
break;
Expand All @@ -326,12 +349,21 @@ void HandleMessage(string raw)
case "world.terrain":
OnWorldTerrain?.Invoke(raw);
break;
case "world.list":
OnWorldList?.Invoke(raw);
break;
case "world.joined":
OnWorldJoined?.Invoke(raw);
break;
case "world.left":
OnWorldLeft?.Invoke(raw);
break;
case "world.phase_changed":
OnWorldPhaseChanged?.Invoke(raw);
break;
case "world.finished":
OnWorldFinished?.Invoke(raw);
break;
case "match.joined":
OnMatchJoined?.Invoke(raw);
break;
Expand Down Expand Up @@ -366,6 +398,7 @@ void HandleMessage(string raw)
OnPresenceUpdated?.Invoke(raw);
break;
case "session.heartbeat":
OnHeartbeat?.Invoke(raw);
break;
case "error":
OnError?.Invoke(raw);
Expand Down
180 changes: 180 additions & 0 deletions Tests/Runtime/DispatchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;

namespace Asobi.Tests
{
/// <summary>
/// Per-SDK protocol dispatch unit test.
///
/// Feeds every canonical server-emitted message envelope through the
/// SDK's WebSocket message handler and asserts the right SDK event
/// fires. Catches doc-vs-server drift (silent failures) before any
/// user reports a missed callback.
///
/// Fixtures live under Tests/Runtime/Resources/Fixtures/*.json and are
/// vendored from asobi/priv/protocol/fixtures (the canonical corpus).
/// </summary>
public class DispatchTests
{
// Maps server wire `type` -> the event name on AsobiRealtime that
// should fire for that fixture. Must stay in sync with the asobi
// protocol fixture corpus and the dispatch switch in
// AsobiRealtime.HandleMessage.
static readonly Dictionary<string, string> Expected = new()
{
{ "error", nameof(AsobiRealtime.OnError) },
{ "session.connected", nameof(AsobiRealtime.OnConnected) },
{ "session.heartbeat", nameof(AsobiRealtime.OnHeartbeat) },
{ "match.state", nameof(AsobiRealtime.OnMatchState) },
{ "match.matched", nameof(AsobiRealtime.OnMatchmakerMatched) },
{ "match.joined", nameof(AsobiRealtime.OnMatchJoined) },
{ "match.left", nameof(AsobiRealtime.OnMatchLeft) },
{ "match.finished", nameof(AsobiRealtime.OnMatchFinished) },
{ "match.matchmaker_expired", nameof(AsobiRealtime.OnMatchmakerExpired) },
{ "match.matchmaker_failed", nameof(AsobiRealtime.OnMatchmakerFailed) },
{ "match.vote_start", nameof(AsobiRealtime.OnVoteStart) },
{ "match.vote_tally", nameof(AsobiRealtime.OnVoteTally) },
{ "match.vote_result", nameof(AsobiRealtime.OnVoteResult) },
{ "match.vote_vetoed", nameof(AsobiRealtime.OnVoteVetoed) },
{ "matchmaker.queued", nameof(AsobiRealtime.OnMatchmakerQueued) },
{ "matchmaker.removed", nameof(AsobiRealtime.OnMatchmakerRemoved) },
{ "chat.joined", nameof(AsobiRealtime.OnChatJoined) },
{ "chat.left", nameof(AsobiRealtime.OnChatLeft) },
{ "chat.message", nameof(AsobiRealtime.OnChatMessage) },
{ "dm.sent", nameof(AsobiRealtime.OnDmSent) },
{ "dm.message", nameof(AsobiRealtime.OnDmMessage) },
{ "presence.updated", nameof(AsobiRealtime.OnPresenceUpdated) },
{ "notification.new", nameof(AsobiRealtime.OnNotification) },
{ "vote.cast_ok", nameof(AsobiRealtime.OnVoteCastOk) },
{ "vote.veto_ok", nameof(AsobiRealtime.OnVoteVetoOk) },
{ "world.tick", nameof(AsobiRealtime.OnWorldTick) },
{ "world.terrain", nameof(AsobiRealtime.OnWorldTerrain) },
{ "world.list", nameof(AsobiRealtime.OnWorldList) },
{ "world.joined", nameof(AsobiRealtime.OnWorldJoined) },
{ "world.left", nameof(AsobiRealtime.OnWorldLeft) },
{ "world.phase_changed", nameof(AsobiRealtime.OnWorldPhaseChanged) },
{ "world.finished", nameof(AsobiRealtime.OnWorldFinished) },
};

static IEnumerable<TestCaseData> FixtureCases()
{
foreach (var kv in Expected)
{
yield return new TestCaseData(kv.Key, kv.Value).SetName($"Dispatches_{kv.Key}");
}
}

[Test, TestCaseSource(nameof(FixtureCases))]
public void DispatchesFixtureToExpectedEvent(string wireType, string eventName)
{
var raw = LoadFixture(wireType);
Assert.That(raw, Is.Not.Null.And.Not.Empty,
$"fixture for '{wireType}' missing under Resources/Fixtures/");

var realtime = new AsobiRealtime();
var fired = false;
Subscribe(realtime, eventName, () => fired = true);

realtime.HandleMessage(raw);

Assert.That(fired, Is.True,
$"'{wireType}' did not fire {eventName}");
}

[Test]
public void EveryFixtureHasExpectedMapping()
{
var fixtures = Resources.LoadAll<TextAsset>("Fixtures");
Assert.That(fixtures.Length, Is.GreaterThan(0),
"no fixtures loaded from Resources/Fixtures/");

var unmapped = fixtures
.Select(f => f.name)
.Where(name => !Expected.ContainsKey(name))
.ToList();

Assert.That(unmapped, Is.Empty,
"fixtures with no Expected mapping (add a dispatch case + entry): "
+ string.Join(", ", unmapped));
}

[Test]
public void EveryExpectedHasFixture()
{
var fixtureNames = Resources.LoadAll<TextAsset>("Fixtures")
.Select(f => f.name)
.ToHashSet();

var stale = Expected.Keys
.Where(t => !fixtureNames.Contains(t))
.ToList();

Assert.That(stale, Is.Empty,
"Expected entries with no fixture (stale or missing fixture): "
+ string.Join(", ", stale));
}

[Test]
public void MatchmakerMatchedAliasesMatchMatched()
{
// Server only emits "match.matched"; "matchmaker.matched" is a
// historical alias kept defensively. This test pins that alias
// until it's removed in a future major.
var raw = "{\"type\":\"matchmaker.matched\",\"payload\":{\"match_id\":\"m1\"}}";

var realtime = new AsobiRealtime();
var fired = false;
realtime.OnMatchmakerMatched += _ => fired = true;
realtime.HandleMessage(raw);

Assert.That(fired, Is.True,
"matchmaker.matched alias should still dispatch to OnMatchmakerMatched");
}

// ---- helpers ----

static Dictionary<string, string> _fixtureCache;

static string LoadFixture(string wireType)
{
if (_fixtureCache == null)
{
_fixtureCache = Resources.LoadAll<TextAsset>("Fixtures")
.ToDictionary(t => t.name, t => t.text);
}
return _fixtureCache.TryGetValue(wireType, out var raw) ? raw : null;
}

static void Subscribe(AsobiRealtime realtime, string eventName, Action onFire)
{
// Reflectively attach a handler to the named event so the test
// stays data-driven. Supports the two delegate shapes used by
// AsobiRealtime: Action and Action<string>.
var ev = typeof(AsobiRealtime).GetEvent(eventName);
Assert.That(ev, Is.Not.Null, $"AsobiRealtime has no event named {eventName}");

var handlerType = ev.EventHandlerType;

Delegate handler;
if (handlerType == typeof(Action))
{
handler = onFire;
}
else if (handlerType == typeof(Action<string>))
{
Action<string> wrapped = _ => onFire();
handler = wrapped;
}
else
{
throw new InvalidOperationException(
$"event {eventName} has unsupported delegate type {handlerType}");
}

ev.AddEventHandler(realtime, handler);
}
}
}
11 changes: 11 additions & 0 deletions Tests/Runtime/DispatchTests.cs.meta

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

10 changes: 9 additions & 1 deletion Tests/Runtime/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# Unity smoke test
# Unity tests

## DispatchTests

`DispatchTests.cs` is a pure-unit dispatch test. Feeds every canonical server-emitted message envelope (vendored under `Resources/Fixtures/` from `asobi/priv/protocol/fixtures`) through `AsobiRealtime.HandleMessage` and asserts the matching event fires. Catches doc-vs-server drift before any user reports a silent failure.

It runs in PlayMode (where the `Tests/Runtime` asmdef lives) but uses `[Test]` (not `[UnityTest]`) and does not require a backend or a scene.

## SmokeTest

`SmokeTest.cs` exercises the 3 canonical scenarios (auth + WS, matchmaker → match.matched, input → state) against [asobi-test-harness](https://github.com/widgrensit/asobi-test-harness). It's a Unity Test Framework `UnityTest`.

Expand Down
8 changes: 8 additions & 0 deletions Tests/Runtime/Resources.meta

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

8 changes: 8 additions & 0 deletions Tests/Runtime/Resources/Fixtures.meta

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

1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/chat.joined.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"chat.joined","cid":"7","payload":{"channel_id":"01j8x000000000000000000005"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/chat.left.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"chat.left","cid":"8","payload":{"channel_id":"01j8x000000000000000000005"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/chat.message.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"chat.message","payload":{"channel_id":"01j8x000000000000000000005","sender_id":"01j8x000000000000000000000","content":"hello","ts":1730000000000}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/dm.message.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"dm.message","payload":{"sender_id":"01j8x000000000000000000002","content":"hi","ts":1730000000000}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/dm.sent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"dm.sent","cid":"9","payload":{"recipient_id":"01j8x000000000000000000002","ts":1730000000000}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/error.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"error","payload":{"reason":"invalid_message"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.finished.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.finished","payload":{"match_id":"01j8x000000000000000000001","result":{"winner":"01j8x000000000000000000000"}}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.joined.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.joined","cid":"3","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000"]}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.left.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.left","cid":"4","payload":{"success":true}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.matched.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.matched","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000","01j8x000000000000000000002"]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.matchmaker_expired","payload":{"ticket_id":"01j8x000000000000000000003"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.matchmaker_failed","payload":{"reason":"no_game_module"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.state","payload":{"tick":7,"players":{"01j8x000000000000000000000":{"x":120,"y":80}}}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.vote_result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_result","payload":{"vote_id":"01j8x000000000000000000004","winner":"a"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.vote_start.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_start","payload":{"vote_id":"01j8x000000000000000000004","template":"map_pick","options":[{"id":"a","label":"Arena"}]}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.vote_tally.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_tally","payload":{"vote_id":"01j8x000000000000000000004","tally":{"a":1}}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/match.vote_vetoed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"match.vote_vetoed","payload":{"vote_id":"01j8x000000000000000000004"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/matchmaker.queued.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"matchmaker.queued","cid":"5","payload":{"ticket_id":"01j8x000000000000000000003","status":"pending"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/matchmaker.removed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"matchmaker.removed","cid":"6","payload":{"success":true}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/notification.new.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"notification.new","payload":{"id":"01j8x000000000000000000006","kind":"friend_request","from":"01j8x000000000000000000002"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/presence.updated.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"presence.updated","cid":"10","payload":{"status":"online"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/session.connected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"session.connected","cid":"1","payload":{"player_id":"01j8x000000000000000000000"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/session.heartbeat.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"session.heartbeat","cid":"2","payload":{"ts":1730000000000}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/vote.cast_ok.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"vote.cast_ok","cid":"11","payload":{"success":true}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/vote.veto_ok.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"vote.veto_ok","cid":"12","payload":{"success":true}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.finished.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.finished","payload":{"world_id":"01j8x000000000000000000007","result":{"winner":"01j8x000000000000000000000"}}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.joined.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.joined","cid":"14","payload":{"world_id":"01j8x000000000000000000007","mode":"village"}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.left.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.left","cid":"15","payload":{"success":true}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.list","cid":"13","payload":{"worlds":[{"id":"01j8x000000000000000000007","mode":"village","capacity":16,"size":3}]}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.phase_changed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.phase_changed","payload":{"phase":"event","started_at":1730000000000}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.terrain.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.terrain","payload":{"coords":[0,0],"data":""}}
1 change: 1 addition & 0 deletions Tests/Runtime/Resources/Fixtures/world.tick.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"world.tick","payload":{"tick":42,"updates":[{"id":"01j8x000000000000000000000","op":"a","x":120,"y":80}]}}
Loading