Skip to content
Closed
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there instances where an error is encountered without this change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No observed user-facing runtime failure from this constructor ordering specifically. This one is a defensive/parity fix from the linked issue: with the old order, super(conduit, [runeChannel], evaluator) receives [undefined] before Rune gets a chance to throw its own "Rune channel is required" error. Moving the guard before super() keeps the invalid channel out of BaseModulePlugin entirely.


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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, I'd love to hear the motivation behind this PR! Do you have a code sample out of curiosity?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I just noticed the attached issue, let me get home and review this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick concrete repro for the initialise() part: create a RuneModulePlugin, call await plugin.initialise(), then call it again. Before this change, the second call re-runs super.initialise() and re-pushes the exported methods/primitive runes, so plugin.exports.map(each => each.symbol) grows duplicates. The added initialise only exports primitive runes once test pins that behavior. The tab lookup part is similarly low-risk cleanup: createRunePlugin(["Other Tab", RUNE_TAB_NAME]) should load RUNE_TAB_NAME, not whatever happens to be at tabs[0].


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