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
90 changes: 66 additions & 24 deletions src/bundles/rune/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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', () => {
Expand Down
42 changes: 42 additions & 0 deletions src/bundles/rune/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 8 additions & 3 deletions src/bundles/rune/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,6 +80,7 @@ export default class RuneModulePlugin extends BaseModulePlugin {
private readonly __runeChannel: IChannel<RuneChannelMessage>;
private readonly __tabLoader: RuneTabLoader | undefined;
private readonly __displayed: RuneDisplayMessage[] = [];
private __initialised = false;
private __tabLoaded = false;

/**
Expand Down Expand Up @@ -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<RuneChannelMessage>;
this.__tabLoader = tabLoader;
this.__runeChannel.subscribe(message => {
Expand All @@ -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];
Expand All @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/bundles/rune/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
});
}
23 changes: 2 additions & 21 deletions src/tabs/Rune/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RuneDisplayMessage, { type: 'animation' }>) {
super(message.duration, message.fps);
Expand Down
Loading