1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
|
import { test, expect, type Page } from '@playwright/test';
async function setRange(page: Page, label: string, value: number) {
await page.getByRole('slider', { name: label, exact: true }).fill(String(value));
}
async function read(page: Page, label: string) {
return Number((await page.getByLabel(label, { exact: true }).innerText()).replaceAll(',', ''));
}
test('all eight lessons render 3D content with measurements and explanations', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/#field');
const subjects = ['Electric charge, force and field', 'Electric flux and charge', 'Electric potential energy', 'Electric potential and gradient', 'Capacitance', 'Dielectric materials', 'Current', 'Semiconducting materials'];
const tabs = page.getByRole('tab');
await expect(tabs).toHaveCount(8);
for (let i = 0; i < subjects.length; i++) {
await tabs.nth(i).click();
await expect(page.getByRole('heading', { name: subjects[i], exact: true })).toBeVisible();
await expect(page.locator('canvas')).toBeVisible();
await expect(page.locator('#measurements')).not.toContainText('Undefined');
await expect(page.locator('#measurements .measurement')).toHaveCount(i === 5 ? 5 : i >= 4 ? 4 : 3);
await expect(page.locator('#challenge')).not.toBeEmpty();
// A rendered frame must contain geometry beyond the solid background.
const colours = await page.locator('canvas').evaluate(canvas => new Promise<number>(resolve => requestAnimationFrame(() => {
const small = document.createElement('canvas');
small.width = 80; small.height = 60;
const context = small.getContext('2d')!;
context.drawImage(canvas as HTMLCanvasElement, 0, 0, 80, 60);
const data = context.getImageData(0, 0, 80, 60).data;
const unique = new Set<number>();
for (let k = 0; k < data.length; k += 4) unique.add((data[k] << 16) | (data[k + 1] << 8) | data[k + 2]);
resolve(unique.size);
})));
expect(colours).toBeGreaterThan(12);
}
expect(errors).toEqual([]);
});
test('charge separation changes force by inverse square and reset restores defaults', async ({ page }) => {
await page.goto('/#field');
const force = await read(page, '|Force on right charge|');
await setRange(page, 'Charge separation', 6);
expect(await read(page, '|Force on right charge|')).toBeCloseTo(force / 4, 1);
await page.getByRole('button', { name: 'Reset experiment' }).click();
await expect(page.getByRole('slider', { name: 'Charge separation', exact: true })).toHaveValue('3');
await page.getByLabel('Charge signs', { exact: true }).selectOption('like');
await expect(page.getByLabel('Interaction', { exact: true })).toHaveText('Repulsive');
});
test('Gaussian flux distinguishes enclosed, outside and surface charges', async ({ page }) => {
await page.goto('/#flux');
expect(await read(page, 'Net electric flux')).toBeGreaterThan(200);
await setRange(page, 'Charge x position', 3);
expect(await read(page, 'Net electric flux')).toBe(0);
await setRange(page, 'Charge x position', 2);
await expect(page.getByLabel('Net electric flux', { exact: true })).toHaveText('Undefined');
await expect(page.getByLabel('Source location', { exact: true })).toHaveText('On surface');
});
test('capacitance scales with area and dielectric conditions preserve the correct quantity', async ({ page }) => {
await page.goto('/#capacitance');
const initial = await read(page, 'Capacitance');
await setRange(page, 'Plate area', 200);
expect(await read(page, 'Capacitance')).toBeCloseTo(2 * initial, 1);
await page.getByRole('tab', { name: /Dielectrics/ }).click();
const q = await read(page, 'Free charge |Q|');
await setRange(page, 'Relative permittivity κ', 8);
expect(await read(page, 'Free charge |Q|')).toBeCloseTo(2 * q, 1);
expect(await read(page, 'Plate voltage')).toBe(10);
await page.getByLabel('Electrical condition', { exact: true }).selectOption('isolated');
const isolatedCharge = await read(page, 'Free charge |Q|');
expect(await read(page, 'Plate voltage')).toBe(1.25);
await setRange(page, 'Relative permittivity κ', 4);
expect(await read(page, 'Free charge |Q|')).toBe(isolatedCharge);
expect(await read(page, 'Plate voltage')).toBe(2.5);
});
test('reversed voltage reverses drift and animation can be paused', async ({ page }) => {
await page.goto('/#current');
expect(await read(page, 'Conventional current')).toBeGreaterThan(0);
expect(await read(page, 'Electron drift, x')).toBeLessThan(0);
await setRange(page, 'Voltage across wire', -0.025);
expect(await read(page, 'Conventional current')).toBeLessThan(0);
expect(await read(page, 'Electron drift, x')).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Pause animation' }).click();
await expect(page.getByRole('button', { name: 'Resume animation' })).toBeVisible();
await setRange(page, 'Voltage across wire', 0);
expect(await read(page, 'Conventional current')).toBe(0);
});
test('doping selects majority carriers without modifying intrinsic silicon', async ({ page }) => {
await page.goto('/#semiconductor');
const doping = page.getByLabel('Silicon sample at 300 K', { exact: true });
const concentration = page.getByRole('slider', { name: /Dopant density/ });
await expect(concentration).toBeDisabled();
expect(await read(page, 'Electron density n')).toBe(await read(page, 'Hole density p'));
await doping.selectOption('n');
await expect(concentration).toBeEnabled();
expect(await read(page, 'Electron density n')).toBeGreaterThan(await read(page, 'Hole density p'));
expect(await read(page, 'Fermi shift EF − Ei')).toBeGreaterThan(0);
await doping.selectOption('p');
expect(await read(page, 'Hole density p')).toBeGreaterThan(await read(page, 'Electron density n'));
expect(await read(page, 'Fermi shift EF − Ei')).toBeLessThan(0);
});
test('lesson navigation preserves parameters, supports history, and fits a mobile viewport', async ({ page }) => {
await page.goto('/#energy');
await setRange(page, 'Moving charge', 3);
await page.getByRole('tab', { name: /Potential & gradient/ }).click();
await page.goBack();
await expect(page.getByRole('slider', { name: 'Moving charge', exact: true })).toHaveValue('3');
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('canvas')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test('WebGL failure leaves lessons and numeric experiments usable', async ({ page }) => {
await page.addInitScript(() => {
const original = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type: string, ...args: unknown[]) {
if (type.startsWith('webgl')) return null;
return original.call(this, type as '2d', ...args as []) as never;
} as typeof original;
});
await page.goto('/#capacitance');
await expect(page.getByRole('alert')).toContainText('WebGL');
expect(await read(page, 'Capacitance')).toBeGreaterThan(80);
await setRange(page, 'Plate area', 200);
expect(await read(page, 'Capacitance')).toBeGreaterThan(170);
});
test('energy sign, distance and field work agree', async ({ page }) => {
await page.goto('/#energy');
expect(await read(page, 'Interaction energy')).toBeCloseTo(-26.96, 2);
expect(await read(page, 'Work by field, from 6 m')).toBeCloseTo(17.98, 2);
await setRange(page, 'Distance from +3 nC source', 4);
expect(await read(page, 'Interaction energy')).toBeCloseTo(-13.48, 2);
await setRange(page, 'Moving charge', 2);
expect(await read(page, 'Interaction energy')).toBeCloseTo(13.48, 2);
expect(await read(page, 'Work by field, from 6 m')).toBeLessThan(0);
});
test('zero dipole potential can coexist with nonzero field', async ({ page }) => {
await page.goto('/#potential');
expect(await read(page, 'Potential at probe')).toBe(0);
expect(await read(page, 'Ex at probe')).toBeGreaterThan(0);
await setRange(page, 'Probe x position (z = 1 m)', 2);
expect(await read(page, 'Potential at probe')).toBeLessThan(0);
await page.getByLabel('Charge signs', { exact: true }).selectOption('like');
expect(await read(page, 'Potential at probe')).toBeGreaterThan(0);
});
|