chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,333 @@
|
||||
import { test, expect } from '../fixtures/base';
|
||||
import { ClassroomPage } from '../pages/classroom.page';
|
||||
import { createSettingsStorage } from '../fixtures/test-data/settings';
|
||||
import { defaultTheme } from '../fixtures/test-data/scene-content';
|
||||
|
||||
const TEST_STAGE_ID = 'e2e-test-stage';
|
||||
|
||||
const SETTINGS_STORAGE = createSettingsStorage({ sidebarCollapsed: false });
|
||||
|
||||
/** Seed the canonical document store with a stage + 3 scenes. */
|
||||
async function seedDatabase(page: import('@playwright/test').Page) {
|
||||
// Inject settings before navigating so it's available immediately on load
|
||||
await page.addInitScript((settings) => {
|
||||
localStorage.setItem('maic:account:settings-storage', settings);
|
||||
localStorage.setItem('locale', 'en-US');
|
||||
}, SETTINGS_STORAGE);
|
||||
|
||||
// Establish the app origin before using IndexedDB. The learner home route is
|
||||
// intentionally not responsible for opening either persistence backend.
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
|
||||
const seedStageData = () =>
|
||||
page.evaluate(
|
||||
({ stageId, theme }) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open('maic-documents', 1);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('stages')) {
|
||||
db.createObjectStore('stages', { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('scenes')) {
|
||||
const scenes = db.createObjectStore('scenes', {
|
||||
keyPath: ['stageId', 'id'],
|
||||
});
|
||||
scenes.createIndex('by-stage', 'stageId');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('outlines')) {
|
||||
db.createObjectStore('outlines', { keyPath: 'stageId' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
try {
|
||||
const tx = db.transaction(['stages', 'scenes', 'outlines'], 'readwrite');
|
||||
const now = Date.now();
|
||||
|
||||
tx.objectStore('stages').put({
|
||||
id: stageId,
|
||||
name: '光合作用',
|
||||
description: '',
|
||||
language: 'zh-CN',
|
||||
style: 'professional',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
dslVersion: '0.1.0',
|
||||
});
|
||||
|
||||
// Scene content uses SlideContent shape: { type: 'slide', canvas: Slide }
|
||||
const makeSlideContent = (title: string, elId: string) => ({
|
||||
type: 'slide',
|
||||
canvas: {
|
||||
id: `slide-${elId}`,
|
||||
viewportSize: 1000,
|
||||
viewportRatio: 0.5625,
|
||||
theme,
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
id: `el-${elId}`,
|
||||
content: title,
|
||||
left: 50,
|
||||
top: 50,
|
||||
width: 900,
|
||||
height: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const scenes = [
|
||||
{
|
||||
id: 'scene-0',
|
||||
stageId,
|
||||
type: 'slide',
|
||||
title: '基本概念',
|
||||
order: 0,
|
||||
content: makeSlideContent('基本概念', '0'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'scene-1',
|
||||
stageId,
|
||||
type: 'slide',
|
||||
title: '光反应',
|
||||
order: 1,
|
||||
content: makeSlideContent('光反应', '1'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
stageId,
|
||||
type: 'slide',
|
||||
title: '暗反应',
|
||||
order: 2,
|
||||
content: makeSlideContent('暗反应', '2'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
for (const scene of scenes) {
|
||||
tx.objectStore('scenes').put(scene);
|
||||
}
|
||||
|
||||
tx.objectStore('outlines').put({
|
||||
stageId,
|
||||
outline: { outlines: [], createdAt: now, updatedAt: now },
|
||||
});
|
||||
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
} catch (error) {
|
||||
db.close();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
{ stageId: TEST_STAGE_ID, theme: defaultTheme },
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await seedStageData();
|
||||
break;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Execution context was destroyed') || attempt === 2) {
|
||||
throw error;
|
||||
}
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Classroom Interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedDatabase(page);
|
||||
});
|
||||
|
||||
test('loads classroom and switches scenes', async ({ page }) => {
|
||||
const classroom = new ClassroomPage(page);
|
||||
await classroom.goto(TEST_STAGE_ID);
|
||||
await classroom.waitForLoaded();
|
||||
|
||||
// Sidebar shows 3 scenes
|
||||
await expect(classroom.sidebarScenes).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// First scene title visible
|
||||
await expect(classroom.getSceneTitle(0)).toContainText('基本概念');
|
||||
|
||||
// Click second scene
|
||||
await classroom.clickScene(1);
|
||||
|
||||
// Verify second scene is now active — heading in the top bar shows the current scene name
|
||||
await expect(page.getByRole('heading', { name: '光反应' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('caps and restores the non-presentation roundtable draft height', async ({ page }) => {
|
||||
const classroom = new ClassroomPage(page);
|
||||
await classroom.goto(TEST_STAGE_ID);
|
||||
await classroom.waitForLoaded();
|
||||
|
||||
await page.keyboard.press('T');
|
||||
const textarea = page.getByPlaceholder('Type your message...', { exact: true });
|
||||
const inputStage = page.getByTestId('roundtable-non-presentation-input-stage');
|
||||
await expect(textarea).toBeVisible();
|
||||
|
||||
const readMetrics = () =>
|
||||
textarea.evaluate((element) => {
|
||||
const computedStyle = getComputedStyle(element);
|
||||
const containingPanel = element.closest<HTMLElement>(
|
||||
'[data-testid="roundtable-non-presentation-input-panel"]',
|
||||
);
|
||||
if (!containingPanel) {
|
||||
throw new Error('Could not find the non-presentation roundtable input panel');
|
||||
}
|
||||
|
||||
const overflowHiddenCard = containingPanel.closest<HTMLElement>(
|
||||
'[data-testid="roundtable-non-presentation-card"]',
|
||||
);
|
||||
if (!overflowHiddenCard) {
|
||||
throw new Error('Could not find the roundtable overflow-hidden card');
|
||||
}
|
||||
|
||||
const cardStyle = getComputedStyle(overflowHiddenCard);
|
||||
if (
|
||||
cardStyle.overflow !== 'hidden' ||
|
||||
cardStyle.overflowX !== 'hidden' ||
|
||||
cardStyle.overflowY !== 'hidden'
|
||||
) {
|
||||
throw new Error('The roundtable clipping card must hide overflow');
|
||||
}
|
||||
|
||||
const panelRect = containingPanel.getBoundingClientRect();
|
||||
const cardRect = overflowHiddenCard.getBoundingClientRect();
|
||||
return {
|
||||
computedHeight: computedStyle.height,
|
||||
computedMaxHeight: computedStyle.maxHeight,
|
||||
computedOverflowY: computedStyle.overflowY,
|
||||
computedFieldSizing: computedStyle.getPropertyValue('field-sizing'),
|
||||
inlineHeight: element.style.height,
|
||||
inlineFieldSizing: element.style.getPropertyValue('field-sizing'),
|
||||
boundingRectHeight: element.getBoundingClientRect().height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
containingPanelHeight: panelRect.height,
|
||||
panelTop: panelRect.top,
|
||||
cardTop: cardRect.top,
|
||||
cardHeight: cardRect.height,
|
||||
cardOverflow: cardStyle.overflow,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(inputStage).toHaveCSS('transform', 'none');
|
||||
const initialMetrics = await readMetrics();
|
||||
const longDraft = Array.from({ length: 24 }, (_, index) => `Line ${index + 1}`).join('\n');
|
||||
|
||||
await textarea.fill(longDraft);
|
||||
await expect.poll(async () => (await readMetrics()).inlineHeight).toBe('100px');
|
||||
const longDraftMetrics = await readMetrics();
|
||||
|
||||
await test.info().attach('roundtable textarea pre-post metrics', {
|
||||
body: JSON.stringify({ initialMetrics, longDraftMetrics }, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
const metricSummary = JSON.stringify({ initialMetrics, longDraftMetrics });
|
||||
expect(
|
||||
longDraftMetrics.panelTop,
|
||||
`Roundtable input panel must stay inside its overflow-hidden card: ${metricSummary}`,
|
||||
).toBeGreaterThanOrEqual(longDraftMetrics.cardTop);
|
||||
expect(longDraftMetrics.cardOverflow).toBe('hidden');
|
||||
expect(
|
||||
longDraftMetrics.computedFieldSizing,
|
||||
`Roundtable textarea metrics: ${metricSummary}`,
|
||||
).not.toBe('content');
|
||||
expect(
|
||||
longDraftMetrics.inlineFieldSizing,
|
||||
`Roundtable textarea metrics: ${metricSummary}`,
|
||||
).toBe('');
|
||||
expect(longDraftMetrics.inlineHeight).toBe('100px');
|
||||
expect(longDraftMetrics.computedMaxHeight).toBe('100px');
|
||||
expect(longDraftMetrics.computedOverflowY).toBe('auto');
|
||||
expect(longDraftMetrics.boundingRectHeight).toBeLessThanOrEqual(100);
|
||||
expect(longDraftMetrics.scrollHeight).toBeGreaterThan(longDraftMetrics.clientHeight);
|
||||
|
||||
await textarea.fill('Short line');
|
||||
await expect
|
||||
.poll(async () => (await readMetrics()).clientHeight)
|
||||
.toBeLessThan(longDraftMetrics.clientHeight);
|
||||
await expect.poll(async () => (await readMetrics()).inlineHeight).not.toBe('100px');
|
||||
|
||||
await textarea.fill(longDraft);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(textarea).toBeHidden();
|
||||
|
||||
await page.keyboard.press('T');
|
||||
await expect(textarea).toBeVisible();
|
||||
await expect(textarea).toHaveValue(longDraft);
|
||||
await expect.poll(async () => (await readMetrics()).inlineHeight).toBe('100px');
|
||||
});
|
||||
|
||||
test('keeps body spacing stable for header menus and hides operator settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
const classroom = new ClassroomPage(page);
|
||||
await classroom.goto(TEST_STAGE_ID);
|
||||
await classroom.waitForLoaded();
|
||||
|
||||
const initialBodySpacing = await page.evaluate(() => {
|
||||
const styles = getComputedStyle(document.body);
|
||||
return {
|
||||
paddingRight: styles.paddingRight,
|
||||
marginRight: styles.marginRight,
|
||||
};
|
||||
});
|
||||
|
||||
const expectBodyScrollState = async (locked: boolean) => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
locked: document.body.hasAttribute('data-scroll-locked'),
|
||||
paddingRight: getComputedStyle(document.body).paddingRight,
|
||||
marginRight: getComputedStyle(document.body).marginRight,
|
||||
})),
|
||||
)
|
||||
.toEqual({
|
||||
locked,
|
||||
paddingRight: initialBodySpacing.paddingRight,
|
||||
marginRight: initialBodySpacing.marginRight,
|
||||
});
|
||||
};
|
||||
|
||||
await page.getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'English' })).toBeVisible();
|
||||
await expectBodyScrollState(false);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem', { name: 'English' })).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Theme' }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Light' })).toBeVisible();
|
||||
await expectBodyScrollState(false);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem', { name: 'Light' })).toBeHidden();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Settings', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('dialog', { name: 'Settings' })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user