CLose story 1.2

This commit is contained in:
2026-05-21 23:08:34 +02:00
commit 110b295a7b
75 changed files with 16065 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
/**
* tests/fixtures/pending-op.js
*
* PendingOp fixtures — frozen. Includes: valid, timeoutId null, expired issuedAt.
*/
export const PENDING_OP_FIXTURES = Object.freeze({
valid: Object.freeze({
opId: "op-001",
userId: "user-abc",
targetState: "hidden",
previousState: "active",
issuedAt: 1700000000000,
timeoutId: 42,
}),
// timeoutId: null — timeout not yet set
timeoutNull: Object.freeze({
opId: "op-002",
userId: "user-abc",
targetState: "active",
previousState: "hidden",
issuedAt: 1700000000000,
timeoutId: null,
}),
// expired issuedAt — very old timestamp (still valid per contract; age check is VisibilityManager's job)
expiredIssuedAt: Object.freeze({
opId: "op-003",
userId: "user-xyz",
targetState: "hidden",
previousState: "active",
issuedAt: 0,
timeoutId: null,
}),
// Invalid: empty opId
emptyOpId: Object.freeze({
opId: "",
userId: "user-abc",
targetState: "hidden",
previousState: "active",
issuedAt: 1700000000000,
timeoutId: null,
}),
});
+42
View File
@@ -0,0 +1,42 @@
/**
* tests/fixtures/scene-preset.js
*
* ScenePreset fixtures — frozen. Includes edge case: empty matrix {}.
*/
export const SCENE_PRESET_FIXTURES = Object.freeze({
valid: Object.freeze({
_version: 1,
name: "Combat Scene",
matrix: Object.freeze({ "user-001": "active", "user-002": "hidden" }),
createdAt: 1700000000000,
updatedAt: 1700000000000,
}),
// Edge case: empty matrix (all participants in default state)
emptyMatrix: Object.freeze({
_version: 1,
name: "Empty Preset",
matrix: Object.freeze({}),
createdAt: 1700000000000,
updatedAt: 1700000000001,
}),
// Invalid: missing name
missingName: Object.freeze({
_version: 1,
name: "",
matrix: Object.freeze({}),
createdAt: 1700000000000,
updatedAt: 1700000000000,
}),
// Invalid: wrong version
wrongVersion: Object.freeze({
_version: 2,
name: "Future Preset",
matrix: Object.freeze({}),
createdAt: 1700000000000,
updatedAt: 1700000000000,
}),
});
+79
View File
@@ -0,0 +1,79 @@
/**
* tests/fixtures/socket-payloads.js
*
* Socket payload fixtures — frozen; include valid and invalid variants.
* Used by socket-message contract tests and SocketHandler tests.
*
* All exports are Object.freeze'd — never mutate fixture data in tests.
*/
export const SOCKET_PAYLOADS = Object.freeze({
// ── Valid intent (GM → all clients) ──────────────────────────────────────
validIntent: Object.freeze({
event: "scrying-pool.visibility.set",
payload: Object.freeze({
opId: "op-001",
userId: "user-abc",
targetState: "hidden",
}),
}),
// ── Valid echo (broadcast ← GM) ───────────────────────────────────────────
validEcho: Object.freeze({
event: "scrying-pool.visibility.updated",
payload: Object.freeze({
opId: "op-001",
userId: "user-abc",
state: "hidden",
revision: 1,
}),
}),
// ── Malformed: missing opId ───────────────────────────────────────────────
missingOpId: Object.freeze({
event: "scrying-pool.visibility.set",
payload: Object.freeze({
userId: "user-abc",
targetState: "hidden",
// opId intentionally omitted
}),
}),
// ── Malformed: wrong enum value for targetState ───────────────────────────
invalidState: Object.freeze({
event: "scrying-pool.visibility.set",
payload: Object.freeze({
opId: "op-002",
userId: "user-abc",
targetState: "invisible", // not a valid VisibilityState
}),
}),
// ── Malformed: extra unknown keys in payload ──────────────────────────────
extraKeys: Object.freeze({
event: "scrying-pool.visibility.set",
payload: Object.freeze({
opId: "op-003",
userId: "user-abc",
targetState: "hidden",
extraField: "should-not-be-here",
}),
}),
// ── Malformed: unknown event name ─────────────────────────────────────────
unknownEvent: Object.freeze({
event: "sp:stateChange", // violates naming convention
payload: Object.freeze({ opId: "op-004", userId: "user-abc" }),
}),
// ── Malformed: echo message missing revision field ───────────────────────
missingRevision: Object.freeze({
event: "scrying-pool.visibility.updated",
payload: Object.freeze({
opId: "op-005",
userId: "user-abc",
state: "hidden",
// revision intentionally omitted
}),
}),
});
+33
View File
@@ -0,0 +1,33 @@
/**
* tests/fixtures/state-store-snapshots.js
*
* StateStore snapshot fixtures — frozen.
* Used by StateStore and VisibilityManager tests (Story 1.3+).
*/
export const STATE_STORE_SNAPSHOTS = Object.freeze({
empty: Object.freeze({ _version: 1, matrix: Object.freeze({}) }),
threeParticipants: Object.freeze({
_version: 1,
matrix: Object.freeze({
"user-001": "active",
"user-002": "hidden",
"user-003": "self-muted",
}),
}),
allStates: Object.freeze({
_version: 1,
matrix: Object.freeze({
"u-active": "active",
"u-hidden": "hidden",
"u-self-muted": "self-muted",
"u-offline": "offline",
"u-cam-lost": "cam-lost",
"u-reconnecting": "reconnecting",
"u-never-connected": "never-connected",
"u-ghost": "ghost",
}),
}),
});
+37
View File
@@ -0,0 +1,37 @@
/**
* tests/fixtures/visibility-states.js
*
* Visibility state fixtures — frozen.
*/
export const VISIBILITY_STATE_FIXTURES = Object.freeze({
validMatrix: Object.freeze({
_version: 1,
matrix: Object.freeze({
"user-001": "active",
"user-002": "hidden",
"user-003": "offline",
}),
}),
emptyMatrix: Object.freeze({
_version: 1,
matrix: Object.freeze({}),
}),
// Invalid: userId maps to null (not a valid state)
invalidNullState: Object.freeze({
_version: 1,
matrix: Object.freeze({
"user-001": null,
}),
}),
// Invalid: unknown state value
invalidUnknownState: Object.freeze({
_version: 1,
matrix: Object.freeze({
"user-001": "invisible",
}),
}),
});
+78
View File
@@ -0,0 +1,78 @@
// @ts-nocheck
/**
* tests/helpers/foundryAdapterMock.js
*
* Canonical FoundryAdapter mock factory.
* ALL tests in this project MUST use this factory — no ad-hoc stubs.
*
* Usage:
* import { createFoundryAdapterMock } from '../helpers/foundryAdapterMock.js'
* const adapter = createFoundryAdapterMock()
* const adapter = createFoundryAdapterMock({ settings: { get: () => 'custom' } })
*
* Surface contract mirrors FoundryAdapter (src/foundry/FoundryAdapter.js):
* settings, socket, users, scenes, notifications, webrtc, hooks
*/
/**
* Creates a mock FoundryAdapter with optional overrides.
*
* All methods are vi.fn() stubs by default; pass overrides to customise.
*
* @param {Partial<FoundryAdapterSurface>} [overrides={}]
* @returns {FoundryAdapterSurface}
*/
export function createFoundryAdapterMock(overrides = {}) {
const defaults = {
settings: {
register: () => {},
get: () => null,
set: () => Promise.resolve(),
...overrides.settings,
},
socket: {
emit: () => {},
on: () => {},
off: () => {},
...overrides.socket,
},
users: {
get: () => null,
all: () => [],
isGM: () => false,
current: () => overrides.users?.get?.("test-user") ?? null,
...overrides.users,
},
scenes: {
current: () => null,
get: () => null,
...overrides.scenes,
},
notifications: {
info: () => {},
warn: () => {},
error: () => {},
...overrides.notifications,
},
/**
* WebRTC track-disabling surface, or null when OQ-1 resolved to css-fallback.
*
* Default is null (CSS fallback path). FoundryVTT v14 spike (Story 1.2) confirmed
* that track.enabled = false does not stop inbound RTP bandwidth, so the probe
* always returns 'css-fallback' and this.webrtc remains null in production.
*
* To simulate the track-disable path in tests, override with:
* createFoundryAdapterMock({ webrtc: { disableTrack: vi.fn(), enableTrack: vi.fn() } })
*/
webrtc: overrides.webrtc !== undefined ? overrides.webrtc : null,
hooks: {
on: () => {},
once: () => {},
off: () => {},
callAll: () => {},
...overrides.hooks,
},
};
return defaults;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import {
createPendingOp,
isValidPendingOp,
} from "../../../src/contracts/pending-op.js";
import { PENDING_OP_FIXTURES } from "../../fixtures/pending-op.js";
describe("pending-op contract", () => {
describe("createPendingOp()", () => {
it("creates a pending op with required fields", () => {
const op = createPendingOp("op-1", "user-1", "hidden", "active");
expect(op.opId).toBe("op-1");
expect(op.userId).toBe("user-1");
expect(op.targetState).toBe("hidden");
expect(op.previousState).toBe("active");
expect(typeof op.issuedAt).toBe("number");
expect(Number.isFinite(op.issuedAt)).toBe(true);
expect(op.issuedAt).toBeGreaterThan(0);
expect(op.timeoutId).toBeNull();
});
});
describe("isValidPendingOp()", () => {
it("accepts valid fixture", () => {
expect(() => isValidPendingOp(PENDING_OP_FIXTURES.valid)).not.toThrow();
});
it("accepts timeoutId: null", () => {
expect(() => isValidPendingOp(PENDING_OP_FIXTURES.timeoutNull)).not.toThrow();
});
it("accepts expired issuedAt (age check is caller's job)", () => {
expect(() => isValidPendingOp(PENDING_OP_FIXTURES.expiredIssuedAt)).not.toThrow();
});
it("throws on empty opId", () => {
expect(() => isValidPendingOp(PENDING_OP_FIXTURES.emptyOpId)).toThrow(TypeError);
});
it("throws on non-finite issuedAt", () => {
const bad = { ...PENDING_OP_FIXTURES.valid, issuedAt: NaN };
expect(() => isValidPendingOp(bad)).toThrow(TypeError);
});
it("throws on negative issuedAt", () => {
const bad = { ...PENDING_OP_FIXTURES.valid, issuedAt: -1 };
expect(() => isValidPendingOp(bad)).toThrow(TypeError);
});
it("throws if not an object", () => {
expect(() => isValidPendingOp(null)).toThrow(TypeError);
});
it("throws on unknown keys", () => {
const bad = { ...PENDING_OP_FIXTURES.valid, extra: true };
expect(() => isValidPendingOp(bad)).toThrow(TypeError);
});
it("throws on string timeoutId", () => {
const bad = { ...PENDING_OP_FIXTURES.valid, timeoutId: "not-a-number" };
expect(() => isValidPendingOp(bad)).toThrow(TypeError);
});
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import {
createScenePreset,
isValidScenePreset,
} from "../../../src/contracts/scene-preset.js";
import { SCENE_PRESET_FIXTURES } from "../../fixtures/scene-preset.js";
describe("scene-preset contract", () => {
describe("createScenePreset()", () => {
it("creates a preset with required fields", () => {
const p = createScenePreset("My Preset", { "user-1": "active" });
expect(p.name).toBe("My Preset");
expect(p.matrix).toEqual({ "user-1": "active" });
expect(typeof p.createdAt).toBe("number");
expect(typeof p.updatedAt).toBe("number");
expect(p.createdAt).toBeGreaterThan(0);
});
it("creates a preset with empty matrix", () => {
const p = createScenePreset("Empty", {});
expect(p.matrix).toEqual({});
});
it("returns a shallow copy of the matrix", () => {
const input = { "user-1": "active" };
const p = createScenePreset("Test", input);
input["user-1"] = "hidden";
expect(p.matrix["user-1"]).toBe("active"); // not mutated
});
});
describe("isValidScenePreset()", () => {
it("accepts valid fixture", () => {
expect(() => isValidScenePreset(SCENE_PRESET_FIXTURES.valid)).not.toThrow();
});
it("accepts empty matrix edge case", () => {
expect(() => isValidScenePreset(SCENE_PRESET_FIXTURES.emptyMatrix)).not.toThrow();
});
it("throws on empty name", () => {
expect(() => isValidScenePreset(SCENE_PRESET_FIXTURES.missingName)).toThrow(TypeError);
});
it("throws on wrong _version", () => {
expect(() => isValidScenePreset(SCENE_PRESET_FIXTURES.wrongVersion)).toThrow(TypeError);
});
it("throws if not an object", () => {
expect(() => isValidScenePreset(null)).toThrow(TypeError);
});
it("throws on unknown keys", () => {
const bad = { ...SCENE_PRESET_FIXTURES.valid, extra: true };
expect(() => isValidScenePreset(bad)).toThrow(TypeError);
});
it("throws on non-finite createdAt", () => {
const bad = { ...SCENE_PRESET_FIXTURES.valid, createdAt: NaN };
expect(() => isValidScenePreset(bad)).toThrow(TypeError);
});
it("throws on negative createdAt", () => {
const bad = { ...SCENE_PRESET_FIXTURES.valid, createdAt: -1 };
expect(() => isValidScenePreset(bad)).toThrow(TypeError);
});
it("throws on NaN updatedAt", () => {
const bad = { ...SCENE_PRESET_FIXTURES.valid, updatedAt: NaN };
expect(() => isValidScenePreset(bad)).toThrow(TypeError);
});
it("throws on Infinity updatedAt", () => {
const bad = { ...SCENE_PRESET_FIXTURES.valid, updatedAt: Infinity };
expect(() => isValidScenePreset(bad)).toThrow(TypeError);
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest";
import {
createSocketIntentMessage,
createSocketEchoMessage,
isValidSocketMessage,
SOCKET_EVENTS,
MAX_PAYLOAD_BYTES,
} from "../../../src/contracts/socket-message.js";
import { SOCKET_PAYLOADS } from "../../fixtures/socket-payloads.js";
describe("socket-message contract", () => {
describe("createSocketIntentMessage()", () => {
it("creates a valid intent message", () => {
const msg = createSocketIntentMessage("op-1", "user-1", "hidden");
expect(msg.event).toBe(SOCKET_EVENTS.VISIBILITY_SET);
const p = /** @type {any} */ (msg.payload);
expect(p.opId).toBe("op-1");
expect(p.userId).toBe("user-1");
expect(p.targetState).toBe("hidden");
});
});
describe("createSocketEchoMessage()", () => {
it("creates a valid echo message", () => {
const msg = createSocketEchoMessage("op-1", "user-1", "hidden", 1);
expect(msg.event).toBe(SOCKET_EVENTS.VISIBILITY_UPDATED);
const p = /** @type {any} */ (msg.payload);
expect(p.opId).toBe("op-1");
expect(p.state).toBe("hidden");
expect(p.revision).toBe(1);
});
});
describe("isValidSocketMessage()", () => {
it("accepts a valid intent from fixture", () => {
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.validIntent)).not.toThrow();
});
it("accepts a valid echo from fixture", () => {
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.validEcho)).not.toThrow();
});
it("throws on missing opId", () => {
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.missingOpId)).toThrow(TypeError);
});
it("throws on unknown event", () => {
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.unknownEvent)).toThrow(TypeError);
});
it("throws on extra payload keys (intent)", () => {
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.extraKeys)).toThrow(TypeError);
});
it("throws if not an object", () => {
expect(() => isValidSocketMessage(null)).toThrow(TypeError);
expect(() => isValidSocketMessage("string")).toThrow(TypeError);
});
it("throws on unknown top-level keys", () => {
expect(() =>
isValidSocketMessage({ event: SOCKET_EVENTS.VISIBILITY_SET, payload: { opId: "x", userId: "y", targetState: "active" }, extra: true })
).toThrow(TypeError);
});
it("throws on empty event string", () => {
expect(() =>
isValidSocketMessage({ event: "", payload: {} })
).toThrow(TypeError);
});
it("throws on non-finite revision in echo", () => {
expect(() =>
isValidSocketMessage({
event: SOCKET_EVENTS.VISIBILITY_UPDATED,
payload: { opId: "op-1", userId: "u-1", state: "active", revision: NaN },
})
).toThrow(TypeError);
});
it("throws on negative revision in echo", () => {
expect(() =>
isValidSocketMessage({
event: SOCKET_EVENTS.VISIBILITY_UPDATED,
payload: { opId: "op-1", userId: "u-1", state: "active", revision: -1 },
})
).toThrow(TypeError);
});
it("throws on missing revision in echo", () => {
expect(() =>
isValidSocketMessage(SOCKET_PAYLOADS.missingRevision)
).toThrow(TypeError);
});
});
describe("SOCKET_EVENTS", () => {
it("is frozen", () => {
expect(Object.isFrozen(SOCKET_EVENTS)).toBe(true);
});
it("uses scrying-pool. prefix for all events", () => {
for (const event of Object.values(SOCKET_EVENTS)) {
expect(event.startsWith("scrying-pool.")).toBe(true);
}
});
});
describe("MAX_PAYLOAD_BYTES", () => {
it("is 4096", () => {
expect(MAX_PAYLOAD_BYTES).toBe(4096);
});
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import {
createVisibilityMatrix,
isValidVisibilityMatrix,
VISIBILITY_STATES,
VISIBILITY_MATRIX_VERSION,
} from "../../../src/contracts/visibility-matrix.js";
describe("visibility-matrix contract", () => {
describe("createVisibilityMatrix()", () => {
it("creates a matrix with default empty state", () => {
const m = createVisibilityMatrix();
expect(m._version).toBe(VISIBILITY_MATRIX_VERSION);
expect(m.matrix).toEqual({});
});
it("creates a matrix with provided entries", () => {
const m = createVisibilityMatrix({ "user-1": "active", "user-2": "hidden" });
expect(m.matrix["user-1"]).toBe("active");
expect(m.matrix["user-2"]).toBe("hidden");
});
it("returns a shallow copy of the provided matrix", () => {
const input = /** @type {{ "user-1": import("../../../src/contracts/visibility-matrix.js").VisibilityState }} */ ({ "user-1": "active" });
const m = createVisibilityMatrix(input);
input["user-1"] = "hidden";
expect(m.matrix["user-1"]).toBe("active"); // not mutated
});
});
describe("isValidVisibilityMatrix()", () => {
it("accepts a valid matrix with all known states", () => {
for (const state of VISIBILITY_STATES) {
const data = { _version: 1, matrix: { "user-1": state } };
expect(() => isValidVisibilityMatrix(data)).not.toThrow();
}
});
it("accepts an empty matrix", () => {
expect(() => isValidVisibilityMatrix({ _version: 1, matrix: {} })).not.toThrow();
});
it("throws if not an object", () => {
expect(() => isValidVisibilityMatrix(null)).toThrow(TypeError);
expect(() => isValidVisibilityMatrix("string")).toThrow(TypeError);
});
it("throws on unknown top-level keys", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: {}, extra: true })
).toThrow(TypeError);
});
it("throws on wrong _version", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 2, matrix: {} })
).toThrow(TypeError);
});
it("throws if matrix is not a plain object", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: null })
).toThrow(TypeError);
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: ["active"] })
).toThrow(TypeError);
});
it("throws on empty userId key", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: { "": "active" } })
).toThrow(TypeError);
});
it("throws on invalid state value", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: { "user-1": "invisible" } })
).toThrow(TypeError);
});
it("throws on null state value", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: { "user-1": null } })
).toThrow(TypeError);
});
it("throws on non-string state value (Symbol)", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: { "user-1": Symbol("test") } })
).toThrow(TypeError);
});
it("throws on non-string state value (object)", () => {
expect(() =>
isValidVisibilityMatrix({ _version: 1, matrix: { "user-1": { value: "active" } } })
).toThrow(TypeError);
});
});
describe("VISIBILITY_STATES", () => {
it("contains exactly 8 states", () => {
expect(VISIBILITY_STATES).toHaveLength(8);
});
it("is frozen", () => {
expect(Object.isFrozen(VISIBILITY_STATES)).toBe(true);
});
it("contains the 8 expected state values", () => {
const expected = [
"active", "hidden", "self-muted", "offline",
"cam-lost", "reconnecting", "never-connected", "ghost",
];
expect([...VISIBILITY_STATES].sort()).toEqual(expected.sort());
});
});
});
+224
View File
@@ -0,0 +1,224 @@
// @ts-nocheck
/**
* tests/unit/foundry/FoundryAdapter.test.js
*
* Story 1.2 — WebRTC spike: FoundryAdapter probe tests.
*
* OQ-1 spike result: css-fallback (FoundryVTT v14, 2026-05-21)
* track.enabled = false does NOT stop inbound WebRTC bandwidth.
* Probe returns 'css-fallback' when AVClient is available, 'unsupported' otherwise.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FoundryAdapter } from '../../../src/foundry/FoundryAdapter.js';
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Returns a minimal mock game.webrtc object whose client exposes getMediaStreamForUser.
* @param {MediaStream|null} [stream=null]
* @returns {{ client: { getMediaStreamForUser: import('vitest').MockedFunction<() => MediaStream|null> } }}
*/
function makeGameWebrtc(stream = null) {
return {
client: {
getMediaStreamForUser: vi.fn().mockReturnValue(stream),
},
};
}
/**
* Returns a minimal MediaStream mock with one video track.
* @param {boolean} [enabled=true]
* @returns {{ getVideoTracks: import('vitest').MockedFunction<() => object[]>, _track: { enabled: boolean, kind: string } }}
*/
function makeStream(enabled = true) {
const track = { enabled, kind: 'video' };
return {
getVideoTracks: vi.fn().mockReturnValue([track]),
_track: track,
};
}
// ─── probeCapability ─────────────────────────────────────────────────────────
describe('FoundryAdapter.probeCapability', () => {
it('returns "unsupported" when gameWebrtc is null', () => {
expect(FoundryAdapter.probeCapability(null)).toBe('unsupported');
});
it('returns "unsupported" when gameWebrtc is undefined', () => {
expect(FoundryAdapter.probeCapability(undefined)).toBe('unsupported');
});
it('returns "unsupported" when gameWebrtc has no client', () => {
expect(FoundryAdapter.probeCapability({})).toBe('unsupported');
});
it('returns "unsupported" when client lacks getMediaStreamForUser', () => {
expect(FoundryAdapter.probeCapability({ client: {} })).toBe('unsupported');
});
it('returns "css-fallback" when client has getMediaStreamForUser (OQ-1 spike result)', () => {
// track.enabled = false does NOT stop bandwidth → probe returns css-fallback, not track-disable
const gameWebrtc = makeGameWebrtc();
expect(FoundryAdapter.probeCapability(gameWebrtc)).toBe('css-fallback');
});
});
// ─── buildWebRTCSurface ───────────────────────────────────────────────────────
describe('FoundryAdapter.buildWebRTCSurface', () => {
let consoleWarnSpy;
let consoleErrorSpy;
beforeEach(() => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('disableTrack', () => {
it('sets track.enabled = false on all video tracks for the user', () => {
const stream = makeStream(true);
const gameWebrtc = makeGameWebrtc(stream);
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
surface.disableTrack('user-123');
expect(gameWebrtc.client.getMediaStreamForUser).toHaveBeenCalledWith('user-123');
expect(stream._track.enabled).toBe(false);
});
it('logs [ScryingPool] warning when no video tracks found (stream has none)', () => {
const emptyStream = { getVideoTracks: vi.fn().mockReturnValue([]) };
const gameWebrtc = makeGameWebrtc(emptyStream);
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
surface.disableTrack('user-456');
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[ScryingPool] disableTrack: no video tracks found for',
'user-456',
);
});
it('logs [ScryingPool] warning when getMediaStreamForUser returns null', () => {
const gameWebrtc = makeGameWebrtc(null);
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
surface.disableTrack('user-789');
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[ScryingPool] disableTrack: no video tracks found for',
'user-789',
);
});
it('catches errors and logs [ScryingPool] error without throwing', () => {
const gameWebrtc = {
client: {
getMediaStreamForUser: vi.fn().mockImplementation(() => {
throw new Error('AV backend unavailable');
}),
},
};
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
expect(() => surface.disableTrack('user-err')).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[ScryingPool] disableTrack failed:',
expect.any(Error),
);
});
});
describe('enableTrack', () => {
it('sets track.enabled = true on all video tracks for the user', () => {
const stream = makeStream(false);
const gameWebrtc = makeGameWebrtc(stream);
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
surface.enableTrack('user-123');
expect(gameWebrtc.client.getMediaStreamForUser).toHaveBeenCalledWith('user-123');
expect(stream._track.enabled).toBe(true);
});
it('logs [ScryingPool] warning when no video tracks found', () => {
const emptyStream = { getVideoTracks: vi.fn().mockReturnValue([]) };
const gameWebrtc = makeGameWebrtc(emptyStream);
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
surface.enableTrack('user-456');
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[ScryingPool] enableTrack: no video tracks found for',
'user-456',
);
});
it('catches errors and logs [ScryingPool] error without throwing', () => {
const gameWebrtc = {
client: {
getMediaStreamForUser: vi.fn().mockImplementation(() => {
throw new Error('AV backend unavailable');
}),
},
};
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
expect(() => surface.enableTrack('user-err')).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[ScryingPool] enableTrack failed:',
expect.any(Error),
);
});
});
});
// ─── Constructor + interface shape parity ─────────────────────────────────────
describe('FoundryAdapter constructor', () => {
it('is side-effect-free — instantiates without accessing game.*', () => {
expect(() => new FoundryAdapter()).not.toThrow();
});
it('has webrtc = null by default (css-fallback path)', () => {
const adapter = new FoundryAdapter();
expect(adapter.webrtc).toBeNull();
});
it('exposes SETTINGS_NS and SETTING_WEBRTC_MODE static constants', () => {
expect(typeof FoundryAdapter.SETTINGS_NS).toBe('string');
expect(FoundryAdapter.SETTINGS_NS).toBe('scrying-pool');
expect(typeof FoundryAdapter.SETTING_WEBRTC_MODE).toBe('string');
expect(FoundryAdapter.SETTING_WEBRTC_MODE).toBe('webrtcMode');
});
});
describe('Interface shape parity with canonical mock', () => {
it('canonical mock webrtc default matches FoundryAdapter default (null)', () => {
const mock = createFoundryAdapterMock();
const adapter = new FoundryAdapter();
expect(mock.webrtc).toBe(adapter.webrtc); // both null
});
it('canonical mock can override webrtc to simulate track-disable surface', () => {
const mockSurface = { disableTrack: vi.fn(), enableTrack: vi.fn() };
const mock = createFoundryAdapterMock({ webrtc: mockSurface });
expect(mock.webrtc).toBe(mockSurface);
expect(typeof mock.webrtc.disableTrack).toBe('function');
expect(typeof mock.webrtc.enableTrack).toBe('function');
});
it('buildWebRTCSurface returns object with disableTrack and enableTrack', () => {
const surface = FoundryAdapter.buildWebRTCSurface(makeGameWebrtc());
expect(typeof surface.disableTrack).toBe('function');
expect(typeof surface.enableTrack).toBe('function');
});
});