diff --git a/src/common/cse-machine/src/__tests__/common.test.ts b/src/common/cse-machine/src/__tests__/common.test.ts index 5d84c4b..f1782c3 100644 --- a/src/common/cse-machine/src/__tests__/common.test.ts +++ b/src/common/cse-machine/src/__tests__/common.test.ts @@ -1,16 +1,304 @@ import { describe, it, expect } from "vitest"; -import { CSE_MESSAGE_TYPE_SNAPSHOTS, type CseSnapshot } from "../index"; +import { + CSE_CHANNEL, + CSE_DIRECTORY_ID, + CSE_MESSAGE_TYPE_SNAPSHOTS, + RUNNER_ID, + WEB_ID, + type CseSnapshot, + type CseSnapshotMessage, + type CseSerializedValue, + type CseSerializedInstruction, + type CseSerializedEnvFrame, + type CseSerializedBinding, +} from "../index"; -describe("common-cse-machine", () => { - it("a snapshot is structured-clone-able", () => { +// ── Constants ───────────────────────────────────────────────────────────────── + +describe("constants", () => { + it("CSE_CHANNEL has the expected value", () => { + expect(CSE_CHANNEL).toBe("__cse"); + }); + + it("RUNNER_ID has the expected value", () => { + expect(RUNNER_ID).toBe("__runner_cse"); + }); + + it("WEB_ID has the expected value", () => { + expect(WEB_ID).toBe("__web_cse"); + }); + + it("CSE_DIRECTORY_ID has the expected value", () => { + expect(CSE_DIRECTORY_ID).toBe("cse-machine"); + }); + + it("CSE_MESSAGE_TYPE_SNAPSHOTS has the expected value", () => { + expect(CSE_MESSAGE_TYPE_SNAPSHOTS).toBe("snapshots"); + }); + + it("RUNNER_ID and WEB_ID are distinct", () => { + expect(RUNNER_ID).not.toBe(WEB_ID); + }); +}); + +// ── Structured-clone safety ─────────────────────────────────────────────────── + +describe("structured-clone safety", () => { + it("a minimal snapshot survives structuredClone", () => { const snap: CseSnapshot = { stepIndex: 0, - control: [{ displayText: "x + 1" }], - stash: [{ displayValue: "1", label: "number" }], + control: [], + stash: [], environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], - currentLine: 1, }; - const msg = { type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [snap], totalSteps: 1 }; + expect(structuredClone(snap)).toEqual(snap); + }); + + it("a fully-populated snapshot survives structuredClone", () => { + const snap: CseSnapshot = { + stepIndex: 2, + control: [ + { displayText: "call f", tag: "app", metadata: { instrType: "Application", numOfArgs: 1 } }, + ], + stash: [{ displayValue: "42", label: "number", tag: "int", metadata: { raw: 42 } }], + environments: [ + { + id: "e1", + name: "f", + parentId: "g", + bindings: [{ name: "x", value: { displayValue: "1", label: "number" }, isConst: true }], + heapObjects: [{ displayValue: "", label: "closure" }], + isActive: true, + isOnCallStack: true, + closureFrameId: "g", + }, + { id: "g", name: "global", parentId: null, bindings: [], isActive: false }, + ], + currentLine: 3, + }; + expect(structuredClone(snap)).toEqual(snap); + }); + + it("a CseSnapshotMessage with multiple snapshots survives structuredClone", () => { + const msg: CseSnapshotMessage = { + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots: [ + { stepIndex: 0, control: [], stash: [], environments: [], currentLine: 1 }, + { stepIndex: 1, control: [{ displayText: "pop" }], stash: [], environments: [] }, + ], + totalSteps: 2, + }; expect(structuredClone(msg)).toEqual(msg); }); }); + +// ── CseSnapshotMessage ──────────────────────────────────────────────────────── + +describe("CseSnapshotMessage", () => { + it("totalSteps matches snapshots.length for a batch", () => { + const snapshots: CseSnapshot[] = [ + { stepIndex: 0, control: [], stash: [], environments: [] }, + { stepIndex: 1, control: [], stash: [], environments: [] }, + { stepIndex: 2, control: [], stash: [], environments: [] }, + ]; + const msg: CseSnapshotMessage = { + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots, + totalSteps: snapshots.length, + }; + expect(msg.totalSteps).toBe(3); + expect(msg.snapshots.length).toBe(msg.totalSteps); + }); + + it("accepts an empty snapshots array", () => { + const msg: CseSnapshotMessage = { + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots: [], + totalSteps: 0, + }; + expect(msg.snapshots).toHaveLength(0); + expect(msg.totalSteps).toBe(0); + }); + + it("type discriminator is CSE_MESSAGE_TYPE_SNAPSHOTS", () => { + const msg: CseSnapshotMessage = { + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots: [], + totalSteps: 0, + }; + expect(msg.type).toBe(CSE_MESSAGE_TYPE_SNAPSHOTS); + }); +}); + +// ── CseSnapshot optional fields ─────────────────────────────────────────────── + +describe("CseSnapshot optional fields", () => { + it("currentLine is optional and may be omitted", () => { + const snap: CseSnapshot = { + stepIndex: 0, + control: [], + stash: [], + environments: [], + }; + expect(snap.currentLine).toBeUndefined(); + }); + + it("currentLine is preserved when set", () => { + const snap: CseSnapshot = { + stepIndex: 5, + control: [], + stash: [], + environments: [], + currentLine: 12, + }; + expect(snap.currentLine).toBe(12); + }); + + it("stepIndex is preserved across multiple steps", () => { + const indices = [0, 1, 2, 10, 99]; + const steps = indices.map(i => ({ + stepIndex: i, + control: [], + stash: [], + environments: [], + })); + steps.forEach((s, i) => expect(s.stepIndex).toBe(indices[i])); + }); +}); + +// ── CseSerializedValue ──────────────────────────────────────────────────────── + +describe("CseSerializedValue", () => { + it("accepts minimal value with displayValue and label only", () => { + const v: CseSerializedValue = { displayValue: "true", label: "bool" }; + expect(v.tag).toBeUndefined(); + expect(v.metadata).toBeUndefined(); + }); + + it("accepts value with all optional fields", () => { + const v: CseSerializedValue = { + displayValue: "", + label: "closure", + tag: "fn", + metadata: { closureFrameId: "e1", params: ["x", "y"] }, + }; + expect(v.displayValue).toBe(""); + expect(v.metadata).toEqual({ closureFrameId: "e1", params: ["x", "y"] }); + }); +}); + +// ── CseSerializedInstruction ────────────────────────────────────────────────── + +describe("CseSerializedInstruction", () => { + it("accepts minimal instruction with displayText only", () => { + const instr: CseSerializedInstruction = { displayText: "pop" }; + expect(instr.tag).toBeUndefined(); + expect(instr.metadata).toBeUndefined(); + }); + + it("accepts instruction with metadata for animation dispatch", () => { + const instr: CseSerializedInstruction = { + displayText: "call f", + metadata: { instrType: "Application", numOfArgs: 2, startLine: 4, endLine: 4 }, + }; + expect(instr.metadata).toEqual({ + instrType: "Application", + numOfArgs: 2, + startLine: 4, + endLine: 4, + }); + }); +}); + +// ── CseSerializedEnvFrame ───────────────────────────────────────────────────── + +describe("CseSerializedEnvFrame", () => { + it("root frame has null parentId", () => { + const frame: CseSerializedEnvFrame = { + id: "g", + name: "global", + parentId: null, + bindings: [], + isActive: true, + }; + expect(frame.parentId).toBeNull(); + }); + + it("child frame references parent by id", () => { + const frame: CseSerializedEnvFrame = { + id: "e1", + name: "f", + parentId: "g", + bindings: [], + isActive: true, + }; + expect(frame.parentId).toBe("g"); + }); + + it("optional fields are absent when not set", () => { + const frame: CseSerializedEnvFrame = { + id: "g", + name: "global", + parentId: null, + bindings: [], + isActive: false, + }; + expect(frame.heapObjects).toBeUndefined(); + expect(frame.isOnCallStack).toBeUndefined(); + expect(frame.closureFrameId).toBeUndefined(); + }); + + it("heapObjects carries anonymous closures not bound to a name", () => { + const frame: CseSerializedEnvFrame = { + id: "g", + name: "global", + parentId: null, + bindings: [], + isActive: true, + heapObjects: [{ displayValue: "", label: "closure" }], + }; + expect(frame.heapObjects).toHaveLength(1); + }); + + it("closureFrameId links a frame to its defining environment", () => { + const frame: CseSerializedEnvFrame = { + id: "e2", + name: "lambda", + parentId: "g", + bindings: [], + isActive: false, + closureFrameId: "g", + }; + expect(frame.closureFrameId).toBe("g"); + }); +}); + +// ── CseSerializedBinding ────────────────────────────────────────────────────── + +describe("CseSerializedBinding", () => { + it("isConst is optional and defaults to undefined", () => { + const b: CseSerializedBinding = { + name: "x", + value: { displayValue: "5", label: "number" }, + }; + expect(b.isConst).toBeUndefined(); + }); + + it("marks const bindings explicitly", () => { + const b: CseSerializedBinding = { + name: "PI", + value: { displayValue: "3.14159", label: "number" }, + isConst: true, + }; + expect(b.isConst).toBe(true); + }); + + it("marks mutable bindings explicitly", () => { + const b: CseSerializedBinding = { + name: "counter", + value: { displayValue: "0", label: "number" }, + isConst: false, + }; + expect(b.isConst).toBe(false); + }); +}); diff --git a/src/runner/cse-machine/src/__tests__/runner.test.ts b/src/runner/cse-machine/src/__tests__/runner.test.ts index 60ee35b..29e9ab6 100644 --- a/src/runner/cse-machine/src/__tests__/runner.test.ts +++ b/src/runner/cse-machine/src/__tests__/runner.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { CseMachinePlugin } from ".."; import { CSE_CHANNEL, @@ -8,26 +8,125 @@ import { } from "@sourceacademy/common-cse-machine"; import type { IChannel, IConduit } from "@sourceacademy/conductor/conduit"; -test("attaches to the cse channel and uses the runner id", () => { - expect(CseMachinePlugin.channelAttach).toEqual([CSE_CHANNEL]); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const makeChannel = () => { + const send = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { send } as unknown as IChannel & { send: typeof send }; +}; +const makePlugin = (ch = makeChannel()) => new CseMachinePlugin({} as IConduit, [ch]); + +const minimalSnapshot = (): CseSnapshot => ({ + stepIndex: 0, + control: [], + stash: [], + environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], }); -test("sendSnapshots forwards a snapshots message over the channel", () => { - const sent: unknown[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const channel = { send: (m: unknown) => sent.push(m) } as unknown as IChannel; - const plugin = new CseMachinePlugin({} as IConduit, [channel]); - - const snapshots: CseSnapshot[] = [ - { - stepIndex: 0, - control: [], - stash: [], - environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], - }, - ]; - plugin.sendSnapshots(snapshots); +// ── Identity / wiring ───────────────────────────────────────────────────────── + +describe("plugin identity", () => { + test("id is RUNNER_ID", () => { + expect(makePlugin().id).toBe(RUNNER_ID); + }); + + test("channelAttach declares the CSE channel", () => { + expect(CseMachinePlugin.channelAttach).toEqual([CSE_CHANNEL]); + }); - expect(plugin.id).toBe(RUNNER_ID); - expect(sent).toEqual([{ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots, totalSteps: 1 }]); + test("channelAttach contains exactly one channel", () => { + expect(CseMachinePlugin.channelAttach).toHaveLength(1); + }); +}); + +// ── Constructor ─────────────────────────────────────────────────────────────── + +describe("constructor", () => { + test("throws when no channel is provided", () => { + expect(() => new CseMachinePlugin({} as IConduit, [])).toThrow( + "CSE channel is required but was not provided.", + ); + }); + + test("does not throw when a channel is provided", () => { + expect(() => makePlugin()).not.toThrow(); + }); +}); + +// ── sendSnapshots ───────────────────────────────────────────────────────────── + +describe("sendSnapshots", () => { + test("sends a message with the correct type discriminator", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + plugin.sendSnapshots([minimalSnapshot()]); + expect(channel.send.mock.calls[0][0].type).toBe(CSE_MESSAGE_TYPE_SNAPSHOTS); + }); + + test("sends snapshots unchanged", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + const snapshots = [minimalSnapshot()]; + plugin.sendSnapshots(snapshots); + expect(channel.send.mock.calls[0][0].snapshots).toEqual(snapshots); + }); + + test("sets totalSteps to the length of the snapshots array", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + const snapshots = [minimalSnapshot(), { ...minimalSnapshot(), stepIndex: 1 }]; + plugin.sendSnapshots(snapshots); + expect(channel.send.mock.calls[0][0].totalSteps).toBe(2); + }); + + test("calls channel.send exactly once per sendSnapshots call", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + plugin.sendSnapshots([minimalSnapshot()]); + expect(channel.send).toHaveBeenCalledTimes(1); + }); + + test("can be called multiple times, sending once each time", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + plugin.sendSnapshots([minimalSnapshot()]); + plugin.sendSnapshots([minimalSnapshot()]); + expect(channel.send).toHaveBeenCalledTimes(2); + }); + + test("handles an empty snapshots array", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + plugin.sendSnapshots([]); + expect(channel.send.mock.calls[0][0]).toEqual({ + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots: [], + totalSteps: 0, + }); + }); + + test("handles a large batch of snapshots", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + const snapshots = Array.from({ length: 50 }, (_, i) => ({ + ...minimalSnapshot(), + stepIndex: i, + })); + plugin.sendSnapshots(snapshots); + expect(channel.send.mock.calls[0][0].totalSteps).toBe(50); + }); + + test("preserves snapshot fields including currentLine and metadata", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + const snap: CseSnapshot = { + stepIndex: 3, + control: [{ displayText: "call f", metadata: { instrType: "Application" } }], + stash: [{ displayValue: "42", label: "number" }], + environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], + currentLine: 7, + }; + plugin.sendSnapshots([snap]); + expect(channel.send.mock.calls[0][0].snapshots[0]).toEqual(snap); + }); }); diff --git a/src/web/cse-machine/src/__tests__/web.test.ts b/src/web/cse-machine/src/__tests__/web.test.ts index cd7ea32..e816288 100644 --- a/src/web/cse-machine/src/__tests__/web.test.ts +++ b/src/web/cse-machine/src/__tests__/web.test.ts @@ -1,4 +1,4 @@ -import { test, expect, vi } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { CseMachineHostPlugin } from ".."; import { CSE_CHANNEL, @@ -19,59 +19,180 @@ const makeChannel = () => { return channel as unknown as IChannel & { emit: (msg: unknown) => void }; }; -const makePlugin = (channel: IChannel, onReceive?: (s: CseSnapshot[]) => void) => { - const receive = onReceive ?? vi.fn(); +const makePlugin = (channel: ReturnType, onReceive = vi.fn()) => { class TestPlugin extends CseMachineHostPlugin { - receiveSnapshots = receive; + receiveSnapshots = onReceive; } - return { plugin: new TestPlugin({} as IConduit, [channel]), receive }; + const plugin = new TestPlugin({} as IConduit, [channel]); + return { plugin, receive: onReceive }; }; -const snapshot: CseSnapshot = { +const snapshot = (): CseSnapshot => ({ stepIndex: 0, control: [], stash: [], environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], -}; +}); -test("attaches to the cse channel and uses the web id", () => { - expect(CseMachineHostPlugin.channelAttach).toEqual([CSE_CHANNEL]); +const validMessage = (snapshots: CseSnapshot[] = [snapshot()]) => ({ + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots, + totalSteps: snapshots.length, }); -test("id is WEB_ID", () => { - const channel = makeChannel(); - const { plugin } = makePlugin(channel); - expect(plugin.id).toBe(WEB_ID); +// ── Identity / wiring ───────────────────────────────────────────────────────── + +describe("plugin identity", () => { + test("id is WEB_ID", () => { + const channel = makeChannel(); + const { plugin } = makePlugin(channel); + expect(plugin.id).toBe(WEB_ID); + }); + + test("channelAttach declares the CSE channel", () => { + expect(CseMachineHostPlugin.channelAttach).toEqual([CSE_CHANNEL]); + }); + + test("channelAttach contains exactly one channel", () => { + expect(CseMachineHostPlugin.channelAttach).toHaveLength(1); + }); }); -test("receiveSnapshots is called when a valid snapshots message arrives", () => { - const channel = makeChannel(); - const receive = vi.fn(); - makePlugin(channel, receive); +// ── Constructor ─────────────────────────────────────────────────────────────── - channel.emit({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [snapshot], totalSteps: 1 }); +describe("constructor", () => { + test("throws when no channel is provided", () => { + class TestPlugin extends CseMachineHostPlugin { + receiveSnapshots = vi.fn(); + } + expect(() => new TestPlugin({} as IConduit, [])).toThrow( + "CSE channel is required but was not provided.", + ); + }); - expect(receive).toHaveBeenCalledOnce(); - expect(receive).toHaveBeenCalledWith([snapshot]); + test("does not throw when a channel is provided", () => { + expect(() => makePlugin(makeChannel())).not.toThrow(); + }); }); -test("invalid messages are silently ignored", () => { - const channel = makeChannel(); - const receive = vi.fn(); - makePlugin(channel, receive); +// ── Valid messages ──────────────────────────────────────────────────────────── + +describe("valid messages", () => { + test("receiveSnapshots is called with the snapshot array", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(validMessage()); + expect(receive).toHaveBeenCalledOnce(); + expect(receive).toHaveBeenCalledWith([snapshot()]); + }); - channel.emit({ type: "unknown", snapshots: [snapshot] }); - channel.emit(null); - channel.emit({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: "bad" }); + test("receiveSnapshots receives multiple snapshots in order", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + const snaps = [snapshot(), { ...snapshot(), stepIndex: 1 }, { ...snapshot(), stepIndex: 2 }]; + channel.emit(validMessage(snaps)); + expect(receive).toHaveBeenCalledWith(snaps); + }); - expect(receive).not.toHaveBeenCalled(); + test("receiveSnapshots is called once per valid message", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(validMessage()); + channel.emit(validMessage()); + expect(receive).toHaveBeenCalledTimes(2); + }); + + test("handles an empty snapshots array", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(validMessage([])); + expect(receive).toHaveBeenCalledWith([]); + }); + + test("preserves snapshot fields passed through the channel", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + const snap: CseSnapshot = { + stepIndex: 4, + control: [{ displayText: "branch", metadata: { instrType: "Branch" } }], + stash: [{ displayValue: "false", label: "bool" }], + environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], + currentLine: 9, + }; + channel.emit(validMessage([snap])); + expect(receive).toHaveBeenCalledWith([snap]); + }); }); -test("constructor throws when cseChannel is not provided", () => { - class TestPlugin extends CseMachineHostPlugin { - receiveSnapshots = vi.fn(); - } - expect(() => new TestPlugin({} as IConduit, [])).toThrow( - "CSE channel is required but was not provided.", - ); +// ── Invalid / malformed messages ────────────────────────────────────────────── + +describe("invalid messages", () => { + test("unknown message type is silently ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit({ type: "unknown", snapshots: [snapshot()], totalSteps: 1 }); + expect(receive).not.toHaveBeenCalled(); + }); + + test("null message is silently ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(null); + expect(receive).not.toHaveBeenCalled(); + }); + + test("undefined message is silently ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(undefined); + expect(receive).not.toHaveBeenCalled(); + }); + + test("snapshots field is not an array — ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: "bad", totalSteps: 1 }); + expect(receive).not.toHaveBeenCalled(); + }); + + test("totalSteps mismatch — ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [snapshot()], totalSteps: 99 }); + expect(receive).not.toHaveBeenCalled(); + }); + + test("missing snapshots field — ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, totalSteps: 0 }); + expect(receive).not.toHaveBeenCalled(); + }); + + test("empty object — ignored", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit({}); + expect(receive).not.toHaveBeenCalled(); + }); + + test("invalid messages do not block subsequent valid ones", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(null); + channel.emit({ type: "bad" }); + channel.emit(validMessage()); + expect(receive).toHaveBeenCalledOnce(); + }); +}); + +// ── Re-exports ──────────────────────────────────────────────────────────────── + +describe("re-exports from common", () => { + test("CseSnapshot type is re-exported and usable", () => { + // If the import at the top of this file resolves, the re-export works. + // This test documents the contract rather than testing runtime behaviour. + const snap: CseSnapshot = snapshot(); + expect(snap).toBeDefined(); + }); });