Skip to content

Commit a38ec6f

Browse files
committed
persist sessions between switching, improve tests
1 parent c0c5593 commit a38ec6f

5 files changed

Lines changed: 172 additions & 17 deletions

File tree

apps/element-demo/src/routes/[element]/deliver/+page.svelte

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ const normalizeSession = (nextSession: any) => {
4141
return nextSession && typeof nextSession === 'object' ? nextSession : {};
4242
};
4343
44+
const extractSessionFromEventDetail = (detail: unknown) => {
45+
if (!detail || typeof detail !== 'object') {
46+
return null;
47+
}
48+
49+
const detailObj = detail as Record<string, unknown>;
50+
if ('session' in detailObj) {
51+
const sessionValue = detailObj.session;
52+
return sessionValue && typeof sessionValue === 'object' ? sessionValue : null;
53+
}
54+
55+
const keys = Object.keys(detailObj);
56+
const metadataOnlyKeys = new Set(['complete', 'component']);
57+
const isMetadataOnly = keys.length > 0 && keys.every((key) => metadataOnlyKeys.has(key));
58+
if (isMetadataOnly) {
59+
return null;
60+
}
61+
62+
return detailObj;
63+
};
64+
4465
// Apply session update callback for controller
4566
const applySessionUpdate = (patch: Record<string, unknown> | null | undefined) => {
4667
if (!patch || typeof patch !== 'object') {
@@ -181,8 +202,10 @@ $effect(() => {
181202
182203
// Handle session changes from the element
183204
function handleSessionChanged(event: CustomEvent) {
184-
const detail = event.detail as any;
185-
const newSession = detail?.session ?? detail;
205+
const newSession = extractSessionFromEventDetail(event.detail);
206+
if (!newSession) {
207+
return;
208+
}
186209
elementSession = newSession;
187210
updateSession(newSession);
188211
}
@@ -256,8 +279,8 @@ function handleBuildState(event: CustomEvent) {
256279
element-name={data.elementName}
257280
package-name={data.packageName}
258281
element-version={(data as LayoutData & { elementVersion?: string }).elementVersion || 'latest'}
259-
model={esmModelReady ? elementModel : undefined}
260-
session={esmModelReady ? elementSession : undefined}
282+
model={elementModel ?? undefined}
283+
session={elementSession ?? undefined}
261284
rebuildVersion={$iifeBuildRequestVersion}
262285
onsession-changed={handleSessionChanged}
263286
oncontroller-changed={handleIifeControllerChanged}

apps/element-demo/test/e2e/phase3-text-and-hardening.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,28 @@ test.describe('Phase 3: Text interactions and hardening', () => {
136136
const before = await waitForSessionMutation(page, {}, 5_000);
137137

138138
await switchMode(page, 'view');
139+
const checkedBeforeAttempt = await root
140+
.locator('input[type="radio"]:checked, input[type="checkbox"]:checked')
141+
.evaluateAll((nodes) => nodes.map((node) => (node as HTMLInputElement).value));
142+
expect(checkedBeforeAttempt.some((value) => value.includes('mercury'))).toBeTruthy();
143+
144+
const allInputsDisabled = await root
145+
.locator('input[type="radio"], input[type="checkbox"]')
146+
.evaluateAll((nodes) =>
147+
nodes.length > 0 && nodes.every((node) => (node as HTMLInputElement).disabled)
148+
);
149+
expect(allInputsDisabled).toBeTruthy();
150+
139151
if (await option2.isVisible().catch(() => false)) {
140152
await option2.click({ force: true });
141153
}
142154
const after = await waitForSessionMutation(page, before, 2_000);
143155
expect(JSON.stringify(after ?? {})).toBe(JSON.stringify(before ?? {}));
156+
157+
const checkedAfterAttempt = await root
158+
.locator('input[type="radio"]:checked, input[type="checkbox"]:checked')
159+
.evaluateAll((nodes) => nodes.map((node) => (node as HTMLInputElement).value));
160+
expect(checkedAfterAttempt.some((value) => value.includes('mercury'))).toBeTruthy();
144161
});
145162

146163
test('simple-cloze: evaluate mode exposes correct/feedback signal for wrong answer', async ({

apps/element-demo/test/e2e/simple-cloze-svelte.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,15 @@ test.describe('Simple Cloze (Svelte 5) - Author and Delivery', () => {
106106
const root = deliveryContainer(page);
107107
await expect(root).toBeVisible();
108108
const input = root.locator('input[type="text"], input').first();
109-
await input.fill('5');
109+
const responseValue = '5';
110+
await input.fill(responseValue);
110111
await page.waitForTimeout(500);
111112
const before = await getSessionState(page);
112113

113114
await switchRole(page, 'instructor');
114115
await switchMode(page, 'evaluate');
115116
await expect(root).toBeVisible();
117+
await expect(root.locator('input[type="text"], input').first()).toHaveValue(responseValue);
116118
const score = await getScore(page);
117119
expect(score === null || typeof score === 'number').toBeTruthy();
118120

@@ -121,6 +123,7 @@ test.describe('Simple Cloze (Svelte 5) - Author and Delivery', () => {
121123
expect(sourceModel?.element).toBe('simple-cloze');
122124
await switchTab(page, 'deliver');
123125
await switchMode(page, 'gather');
126+
await expect(root.locator('input[type="text"], input').first()).toHaveValue(responseValue);
124127
const after = await getSessionState(page);
125128
expect(JSON.stringify(after ?? {})).toBe(JSON.stringify(before ?? {}));
126129
});

apps/element-demo/test/e2e/unified-player-strategy.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, test, type Page } from '@playwright/test';
2+
import { switchMode, switchRole } from './test-helpers';
23

34
const ELEMENT = process.env.UNIFIED_PLAYER_E2E_ELEMENT?.trim() || 'multiple-choice';
45
const DEMO_ID =
@@ -211,6 +212,106 @@ test.describe('Unified element player strategy host', () => {
211212

212213
expect(eventResult.count).toBeGreaterThan(0);
213214
expect(eventResult.lastDetail?.session).toEqual({ value: ['A'] });
215+
// Metadata-only events (e.g. {complete, component}) must not overwrite forwarded session.
216+
expect(eventResult.lastDetail?.complete).toBeUndefined();
217+
expect(eventResult.lastDetail?.component).toBeUndefined();
218+
});
219+
220+
test('multiple-choice keeps user selection across mode/role switches', async ({ page }) => {
221+
test.setTimeout(120_000);
222+
223+
const multipleChoiceDemo = process.env.UNIFIED_PLAYER_E2E_MC_DEMO?.trim() || 'math-algebra-quadratic';
224+
await page.goto(
225+
`/multiple-choice/deliver?mode=gather&role=student&player=esm&demo=${multipleChoiceDemo}`
226+
);
227+
await page.waitForSelector('pie-element-player[view="delivery"]', { timeout: 45_000 });
228+
await waitForHostSettled(page);
229+
230+
const beforeSessionSignature = await page.evaluate(() => {
231+
const host = document.querySelector('pie-element-player') as any;
232+
return JSON.stringify(host?.session ?? {});
233+
});
234+
235+
await page.waitForSelector(
236+
'pie-element-player .demo-element-player input[type="radio"], pie-element-player .demo-element-player input[type="checkbox"]',
237+
{ timeout: 15_000 }
238+
);
239+
const interactionWorked = await page.evaluate(() => {
240+
const inputs = Array.from(
241+
document.querySelectorAll<HTMLInputElement>(
242+
'pie-element-player .demo-element-player input[type="radio"], pie-element-player .demo-element-player input[type="checkbox"]'
243+
)
244+
).filter((input) => !input.disabled);
245+
const target = inputs.find((input) => !input.checked) || inputs[0];
246+
if (!target) {
247+
return false;
248+
}
249+
const id = target.id;
250+
const label =
251+
id && document.querySelector(`pie-element-player .demo-element-player label[for="${id}"]`);
252+
if (label instanceof HTMLElement) {
253+
label.click();
254+
return true;
255+
}
256+
target.click();
257+
return true;
258+
});
259+
expect(interactionWorked).toBeTruthy();
260+
261+
await page.waitForFunction(
262+
(beforeSignature) => {
263+
const host = document.querySelector('pie-element-player') as any;
264+
const current = JSON.stringify(host?.session ?? {});
265+
return current !== beforeSignature;
266+
},
267+
beforeSessionSignature,
268+
{ timeout: 15_000 }
269+
);
270+
271+
const selectedSession = await page.evaluate(() => {
272+
const host = document.querySelector('pie-element-player') as any;
273+
const session = host?.session && typeof host.session === 'object' ? host.session : {};
274+
const value = (session as any).value;
275+
return {
276+
session,
277+
value: Array.isArray(value) ? value : [],
278+
};
279+
});
280+
expect(Array.isArray(selectedSession.value)).toBeTruthy();
281+
expect(selectedSession.value.length).toBeGreaterThan(0);
282+
283+
await switchMode(page, 'view');
284+
await switchRole(page, 'instructor');
285+
await waitForHostSettled(page);
286+
287+
const selectionVisibleReadOnly = await page.evaluate((value) => {
288+
if (!Array.isArray(value) || value.length === 0) {
289+
return false;
290+
}
291+
const inputs = Array.from(
292+
document.querySelectorAll<HTMLInputElement>(
293+
'pie-element-player .demo-element-player input[type="radio"], pie-element-player .demo-element-player input[type="checkbox"]'
294+
)
295+
);
296+
if (inputs.length === 0) {
297+
return false;
298+
}
299+
const checkedValues = inputs.filter((input) => input.checked).map((input) => input.value);
300+
const allDisabled = inputs.every((input) => input.disabled);
301+
const matchesSelection = value.every((entry) => checkedValues.includes(entry));
302+
return matchesSelection && allDisabled;
303+
}, selectedSession.value);
304+
expect(selectionVisibleReadOnly).toBeTruthy();
305+
306+
const sessionStillContainsSelection = await page.evaluate((value) => {
307+
const host = document.querySelector('pie-element-player') as any;
308+
const sessionValue = host?.session?.value;
309+
if (!Array.isArray(sessionValue) || !Array.isArray(value) || value.length === 0) {
310+
return false;
311+
}
312+
return value.every((entry) => sessionValue.includes(entry));
313+
}, selectedSession.value);
314+
expect(sessionStillContainsSelection).toBeTruthy();
214315
});
215316

216317
test('session remains stable across esm/iife strategy switches', async ({ page }) => {

packages/element-player/src/players/PieElementPlayer.svelte

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,17 @@ function hasExplicitResponseField(value: unknown): boolean {
145145
return false;
146146
}
147147
148+
function hasUsableSessionPayload(value: unknown): boolean {
149+
if (!value || typeof value !== 'object') {
150+
return false;
151+
}
152+
const detailObj = value as Record<string, unknown>;
153+
if ('session' in detailObj) {
154+
return true;
155+
}
156+
return hasResponseValue(detailObj) || hasExplicitResponseField(detailObj);
157+
}
158+
148159
function reconnectMathObserver() {
149160
if (mathObserver && container) {
150161
mathObserver.observe(container, {
@@ -204,37 +215,37 @@ function attachInstanceHandlers(viewMode: ElementPlayerView) {
204215
if (viewMode === 'delivery') {
205216
sessionHandler = (event: Event) => {
206217
if (suppressSessionEvents || isForwardingSessionEvent) {
218+
event.stopPropagation();
207219
return;
208220
}
209221
const customEvent = event as CustomEvent;
210222
const detail = customEvent.detail as any;
211223
const detailObj =
212224
detail && typeof detail === 'object' ? (detail as Record<string, unknown>) : null;
213-
if (!detailObj) {
214-
return;
215-
}
216-
if (
217-
!('session' in detailObj) &&
218-
!hasResponseValue(detailObj) &&
219-
!hasExplicitResponseField(detailObj)
220-
) {
221-
return;
222-
}
225+
const hasUsableDetail = hasUsableSessionPayload(detailObj);
226+
const liveSession = (elementInstance as any)?.session;
227+
const nextSession = detail?.session ?? liveSession;
223228
224-
const nextSession = detail?.session ?? (elementInstance as any).session ?? detail;
225229
if (nextSession === undefined) {
230+
event.stopPropagation();
231+
return;
232+
}
233+
if (!hasUsableDetail && liveSession === undefined) {
234+
event.stopPropagation();
226235
return;
227236
}
228237
const sessionSignature = createValueSignature(nextSession);
229238
if (sessionSignature === lastForwardedSessionSignature) {
239+
event.stopPropagation();
230240
return;
231241
}
232242
const forwardedDetail = {
233-
...detailObj,
243+
...(detailObj ?? {}),
234244
session: nextSession,
235245
};
236246
const detailSignature = createValueSignature(forwardedDetail);
237247
if (detailSignature === lastForwardedSessionDetailSignature) {
248+
event.stopPropagation();
238249
return;
239250
}
240251
lastForwardedSessionDetailSignature = detailSignature;

0 commit comments

Comments
 (0)