aboutsummaryrefslogtreecommitdiff
path: root/tests/test4.spec.ts
blob: 3e6a3b57c9c46f7cabb55e156cb417d7ceb54be3 (plain) (blame)
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
import { test, expect, type Page } from '@playwright/test';

async function read(page: Page, label: string) {
  return Number((await page.getByLabel(label, { exact: true }).innerText()).replaceAll(',', ''));
}

test('six original test questions have interactive scenes and every answer part', async ({ page }) => {
  const errors: string[] = [];
  page.on('pageerror', e => errors.push(e.message));
  await page.goto('/');
  const ids = ['work', 'conservation', 'central', 'assembly', 'uniform', 'dipole'];
  const parts = [1, 1, 2, 2, 2, 3];
  await expect(page.getByRole('tab')).toHaveCount(6);
  for (let i = 0; i < ids.length; i++) {
    await page.getByRole('tab').nth(i).click();
    await expect(page).toHaveURL(new RegExp(`#test-${ids[i]}$`));
    await expect(page.locator('#test-question')).not.toBeEmpty();
    await expect(page.locator('#solution details')).toHaveCount(parts[i]);
    await page.locator('#solution summary').first().click();
    await expect(page.locator('#solution details[open] .derivation')).toBeVisible();
    await expect(page.locator('#measurements')).not.toContainText('Undefined');
    await expect(page.locator('canvas').first()).toBeVisible();
    // Check actual rendered pixels, including scenes constructed after hash navigation.
    await expect.poll(() => page.locator('#viewport > canvas').evaluate(canvas => new Promise<number>(resolve => {
      requestAnimationFrame(() => 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 colours = new Set<number>();
        for (let k = 0; k < data.length; k += 4) colours.add((data[k] << 16) | (data[k + 1] << 8) | data[k + 2]);
        resolve(colours.size);
      }));
    }))).toBeGreaterThan(12);
  }
  expect(errors).toEqual([]);
});

test('path changes preserve work while signed charge and angle change it', async ({ page }) => {
  await page.goto('/#test-work');
  const initial = await read(page, 'Field work a → b');
  await page.getByLabel('Path shape', { exact: true }).selectOption('curved');
  expect(await read(page, 'Field work a → b')).toBe(initial);
  await page.getByRole('slider', { name: 'Moving charge q₀', exact: true }).fill('-2');
  expect(await read(page, 'Field work a → b')).toBe(-initial);
  await page.goto('/#test-uniform');
  await page.getByRole('slider', { name: 'Displacement angle α', exact: true }).fill('90');
  expect(Math.abs(await read(page, 'Field work a → b'))).toBeLessThan(1e-10);
});

test('energy conservation, infinity and dipole equilibrium stay physically consistent', async ({ page }) => {
  await page.goto('/#test-conservation');
  const total = await read(page, 'Total K + U');
  await page.getByRole('slider', { name: 'Journey progress', exact: true }).fill('100');
  expect(await read(page, 'Total K + U')).toBe(total);
  expect(await read(page, 'Kinetic energy K')).toBeGreaterThan(0);
  await page.goto('/#test-central');
  await page.getByLabel('Destination b', { exact: true }).selectOption('infinity');
  expect(await read(page, 'Potential energy Uᵦ')).toBe(0);
  await expect(page.locator('#observation')).toContainText('infinity');
  await page.goto('/#test-dipole');
  await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('0');
  await page.getByRole('slider', { name: 'Rotation progress', exact: true }).fill('100');
  expect(await read(page, 'Potential energy U')).toBeLessThan(0);
  expect(await read(page, 'Torque τz')).toBe(0);
  await expect(page.locator('#observation')).toContainText('stable');
});

test('assembly adds q₀ without double counting and the prediction can be revealed', async ({ page }) => {
  await page.goto('/#test-assembly');
  await page.getByLabel('Assembly stage', { exact: true }).selectOption('3');
  const base = await read(page, 'Energy of current assembly');
  await page.getByLabel('Assembly stage', { exact: true }).selectOption('4');
  expect(await read(page, 'Energy of current assembly')).toBeCloseTo(base + await read(page, 'Additional energy ΔU₀'), 1);
  await expect(page.locator('#prediction-answer')).toBeHidden();
  await page.getByRole('button', { name: 'Reveal explanation' }).click();
  await expect(page.locator('#prediction-answer')).toBeVisible();
});

test('collection navigation retains settings, works with history, and fits mobile', async ({ page }) => {
  await page.goto('/#test-dipole');
  await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('180');
  await page.getByRole('link', { name: 'All experiments', exact: true }).click();
  await expect(page.getByRole('tab')).toHaveCount(8);
  await page.goBack();
  await expect(page.getByRole('slider', { name: 'Final angle θᵦ', exact: true })).toHaveValue('180');
  await page.getByRole('button', { name: 'Reset experiment' }).click();
  await expect(page.getByRole('slider', { name: 'Final angle θᵦ', exact: true })).toHaveValue('15');
  await page.setViewportSize({ width: 390, height: 844 });
  await expect(page.locator('canvas')).toBeVisible();
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});

test('next question returns a mobile learner to the new heading', async ({ page }) => {
  await page.setViewportSize({ width: 390, height: 844 });
  await page.goto('/#test-work');
  await page.getByRole('link', { name: 'Next question →', exact: true }).click();
  await expect(page).toHaveURL(/#test-conservation$/);
  await expect(page.getByRole('heading', { name: 'Where does the energy go?' })).toBeInViewport();
  await expect(page.locator('#lesson-title')).toBeFocused();
});

test('test answers and controls remain accessible without WebGL', 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('/#test-dipole');
  await expect(page.getByRole('alert')).toContainText('WebGL');
  await page.getByRole('slider', { name: 'Rotation progress', exact: true }).fill('100');
  expect(await read(page, 'Potential energy U')).toBeLessThan(0);
  await page.locator('#solution summary').last().click();
  await expect(page.locator('#solution details[open] math').first()).toBeAttached();
  await expect(page.locator('#solution details[open] .katex-error')).toHaveCount(0);
});