diff --git a/src/bundles/rune/src/__tests__/index.test.ts b/src/bundles/rune/src/__tests__/index.test.ts index 86726c442d..a2d0973b48 100644 --- a/src/bundles/rune/src/__tests__/index.test.ts +++ b/src/bundles/rune/src/__tests__/index.test.ts @@ -3,34 +3,48 @@ import { stringify } from 'js-slang/dist/utils/stringify'; import { describe, expect, it, test, vi } from 'vitest'; import RuneModulePlugin from '..'; import * as funcs from '../functions'; +import { RUNE_TAB_NAME } from '../protocol'; import type { Rune } from '../rune'; +function createRunePlugin(tabs: string[] = []) { + const sentMessages: unknown[] = []; + const channel = { + send: vi.fn(message => sentMessages.push(message)), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + close: vi.fn(), + name: 'rune-test-channel' + }; + const evaluator = { + hasDataInterface: true, + closure_make: vi.fn(async (sig, func, dependsOn) => ({ + type: DataType.CLOSURE, + value: { sig, dependsOn, func } + })), + opaque_make: vi.fn(async value => ({ + type: DataType.OPAQUE, + value + })), + opaque_get: vi.fn(async value => value.value) + }; + const tabLoader = { + tabs, + loadTab: vi.fn() + }; + const plugin = new RuneModulePlugin({} as any, [channel] as any, evaluator as any, tabLoader); + + return { + channel, + evaluator, + plugin, + sentMessages, + tabLoader + }; +} + describe(RuneModulePlugin, () => { test('exported methods stay bound when called by a Conductor closure', async () => { - const sentMessages: unknown[] = []; - const channel = { - send: vi.fn(message => sentMessages.push(message)), - subscribe: vi.fn(), - unsubscribe: vi.fn(), - close: vi.fn(), - name: 'rune-test-channel' - }; - const evaluator = { - hasDataInterface: true, - closure_make: vi.fn(async (sig, func, dependsOn) => ({ - type: DataType.CLOSURE, - value: { sig, dependsOn, func } - })), - opaque_make: vi.fn(async value => ({ - type: DataType.OPAQUE, - value - })), - opaque_get: vi.fn(async value => value.value) - }; - const plugin = new RuneModulePlugin({} as any, [channel] as any, evaluator as any, { - tabs: [], - loadTab: vi.fn() - }); + const { evaluator, plugin, sentMessages } = createRunePlugin(); await plugin.initialise(); @@ -55,6 +69,34 @@ describe(RuneModulePlugin, () => { }); expect('draw' in (sentMessages[0] as any).rune).toBe(false); }); + + test('initialise only exports primitive runes once', async () => { + const { evaluator, plugin } = createRunePlugin(); + + await plugin.initialise(); + + const exportedSymbols = plugin.exports.map(each => each.symbol); + const exportedValues = plugin.exports.map(each => each.value); + const closureMakeCalls = evaluator.closure_make.mock.calls.length; + const opaqueMakeCalls = evaluator.opaque_make.mock.calls.length; + + await plugin.initialise(); + + expect(plugin.exports.map(each => each.symbol)).toEqual(exportedSymbols); + expect(plugin.exports.map(each => each.value)).toEqual(exportedValues); + expect(evaluator.closure_make).toHaveBeenCalledTimes(closureMakeCalls); + expect(evaluator.opaque_make).toHaveBeenCalledTimes(opaqueMakeCalls); + }); + + test('loads the rune tab by name', async () => { + const { evaluator, plugin, tabLoader } = createRunePlugin(['Other Tab', RUNE_TAB_NAME]); + const runeValue = await evaluator.opaque_make(funcs.blank) as any; + + const result = await plugin.show(runeValue).next(); + + expect(result.done).toBe(true); + expect(tabLoader.loadTab).toHaveBeenCalledExactlyOnceWith(RUNE_TAB_NAME); + }); }); describe('Hollusion Rune tests', () => { diff --git a/src/bundles/rune/src/__tests__/protocol.test.ts b/src/bundles/rune/src/__tests__/protocol.test.ts new file mode 100644 index 0000000000..10f4b85005 --- /dev/null +++ b/src/bundles/rune/src/__tests__/protocol.test.ts @@ -0,0 +1,42 @@ +import { mat4 } from 'gl-matrix'; +import { describe, expect, test } from 'vitest'; +import { deserializeRune, serializeRune } from '../protocol'; +import { Rune } from '../rune'; + +describe('rune protocol', () => { + test('round-trips serialized rune structures', () => { + const transformMatrix = mat4.create(); + mat4.translate(transformMatrix, transformMatrix, [0.25, 0.5, 0]); + const childTransformMatrix = mat4.create(); + mat4.scale(childTransformMatrix, childTransformMatrix, [0.5, 0.5, 1]); + + const rune = Rune.of({ + vertices: new Float32Array([ + 0, 0, 0, 1, + 1, 0, 0, 1, + 0, 1, 0, 1 + ]), + colors: new Float32Array([ + 1, 0, 0, 1, + 0, 1, 0, 1, + 0, 0, 1, 1 + ]), + transformMatrix, + subRunes: [ + Rune.of({ + vertices: new Float32Array([ + -1, -1, 0, 1, + 0, 1, 0, 1, + 1, -1, 0, 1 + ]), + transformMatrix: childTransformMatrix, + hollusionDistance: 0.3 + }) + ], + hollusionDistance: 0.7 + }); + const serialized = serializeRune(rune); + + expect(serializeRune(deserializeRune(serialized))).toEqual(serialized); + }); +}); diff --git a/src/bundles/rune/src/index.ts b/src/bundles/rune/src/index.ts index e7a0425bf9..8f134e7e67 100644 --- a/src/bundles/rune/src/index.ts +++ b/src/bundles/rune/src/index.ts @@ -17,6 +17,7 @@ import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; import * as funcs from './functions'; import { RUNE_CHANNEL_ID, + RUNE_TAB_NAME, serializeRune, type RuneAnimationMessage, type RuneChannelMessage, @@ -79,6 +80,7 @@ export default class RuneModulePlugin extends BaseModulePlugin { private readonly __runeChannel: IChannel; private readonly __tabLoader: RuneTabLoader | undefined; private readonly __displayed: RuneDisplayMessage[] = []; + private __initialised = false; private __tabLoaded = false; /** @@ -182,12 +184,12 @@ export default class RuneModulePlugin extends BaseModulePlugin { evaluator: IInterfacableEvaluator, tabLoader: RuneTabLoader ) { - super(conduit, [runeChannel], evaluator); - if (!runeChannel) { throw new GeneralRuntimeError('Rune channel is required but was not provided.'); } + super(conduit, [runeChannel], evaluator); + this.__runeChannel = runeChannel as IChannel; this.__tabLoader = tabLoader; this.__runeChannel.subscribe(message => { @@ -198,6 +200,9 @@ export default class RuneModulePlugin extends BaseModulePlugin { } override async initialise() { + if (this.__initialised) return; + this.__initialised = true; + await super.initialise(); for (const name in funcs.RuneFunctions) { const value = funcs.RuneFunctions[name as keyof typeof funcs.RuneFunctions]; @@ -219,7 +224,7 @@ export default class RuneModulePlugin extends BaseModulePlugin { private __loadRuneTab(): boolean { if (this.__tabLoaded || this.__tabLoader === undefined) return true; - const tabName = this.__tabLoader.tabs[0]; + const tabName = this.__tabLoader.tabs.find(tab => tab === RUNE_TAB_NAME); if (tabName === undefined) return true; this.__tabLoader.loadTab(tabName); diff --git a/src/bundles/rune/src/protocol.ts b/src/bundles/rune/src/protocol.ts index c7d4d5a4f0..6df02c7cca 100644 --- a/src/bundles/rune/src/protocol.ts +++ b/src/bundles/rune/src/protocol.ts @@ -44,6 +44,13 @@ function serializeTexture(texture: Rune['texture']): string | null { return texture.src; } +function imageFromUrl(url: string): HTMLImageElement { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = url; + return image; +} + export function serializeRune(rune: Rune): SerializedRune { return { vertices: Array.from(rune.vertices), @@ -54,3 +61,14 @@ export function serializeRune(rune: Rune): SerializedRune { hollusionDistance: rune.hollusionDistance }; } + +export function deserializeRune(serialized: SerializedRune): Rune { + return Rune.of({ + vertices: new Float32Array(serialized.vertices), + colors: serialized.colors === null ? null : new Float32Array(serialized.colors), + transformMatrix: new Float32Array(serialized.transformMatrix) as Rune['transformMatrix'], + subRunes: serialized.subRunes.map(deserializeRune), + texture: serialized.textureUrl === null ? null : imageFromUrl(serialized.textureUrl), + hollusionDistance: serialized.hollusionDistance + }); +} diff --git a/src/tabs/Rune/src/index.tsx b/src/tabs/Rune/src/index.tsx index 13e9133433..75933bb165 100644 --- a/src/tabs/Rune/src/index.tsx +++ b/src/tabs/Rune/src/index.tsx @@ -2,9 +2,9 @@ import { DrawnAnaglyphRune, DrawnHollusionRune, isHollusionRune } from '@sourcea import { RUNE_CHANNEL_ID, RUNE_WEB_ID, + deserializeRune, type RuneChannelMessage, - type RuneDisplayMessage, - type SerializedRune + type RuneDisplayMessage } from '@sourceacademy/bundle-rune/protocol'; import { DrawnNormalRune, Rune } from '@sourceacademy/bundle-rune/rune'; import type { ITabService, Tab } from '@sourceacademy/common-tabs'; @@ -18,29 +18,10 @@ import AnimationCanvas from '@sourceacademy/modules-lib/tabs/AnimationCanvas'; import MultiItemDisplay from '@sourceacademy/modules-lib/tabs/MultiItemDisplay/index'; import WebGLCanvas from '@sourceacademy/modules-lib/tabs/WebGLCanvas'; import { glAnimation, type AnimFrame } from '@sourceacademy/modules-lib/types'; -import type { mat4 } from 'gl-matrix'; import { createElement, useMemo, useSyncExternalStore } from 'react'; import HollusionCanvas from './hollusion_canvas'; -function imageFromUrl(url: string): HTMLImageElement { - const image = new Image(); - image.crossOrigin = 'anonymous'; - image.src = url; - return image; -} - -function deserializeRune(serialized: SerializedRune): Rune { - return Rune.of({ - vertices: new Float32Array(serialized.vertices), - colors: serialized.colors === null ? null : new Float32Array(serialized.colors), - transformMatrix: new Float32Array(serialized.transformMatrix) as unknown as mat4, - subRunes: serialized.subRunes.map(deserializeRune), - texture: serialized.textureUrl === null ? null : imageFromUrl(serialized.textureUrl), - hollusionDistance: serialized.hollusionDistance - }); -} - class SerializedRuneAnimation extends glAnimation { constructor(private readonly message: Extract) { super(message.duration, message.fps);