fix(@projects/@magic-civilization): 🐛 update climate test dropdown count

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-01 12:03:15 -04:00
parent 52746e9137
commit 0a4b705527
7 changed files with 96 additions and 589 deletions

View file

@ -240,7 +240,7 @@ test.describe('Climate simulator', () => {
await expect(dropdown).toBeVisible({ timeout: 3_000 })
const options = dropdown.getByRole('option')
await expect(options).toHaveCount(3)
await expect(options).toHaveCount(4)
// Close without switching — the dropdown uses a mousedown-outside listener,
// so clicking somewhere neutral (the scenario nav) dismisses it.

View file

@ -1,7 +1,6 @@
{
"status": "failed",
"failedTests": [
"620c61f0a0b21e7686ea-41dfc9f4053dc733176e",
"620c61f0a0b21e7686ea-00dd36638b59435b947b"
"620c61f0a0b21e7686ea-1ae6cca4a49867df6bb8"
]
}

View file

@ -1,442 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: simulator.spec.ts >> Climate simulator >> planet selector opens and shows all three planets
- Location: e2e/simulator.spec.ts:231:3
# Error details
```
Error: expect(locator).toHaveCount(expected) failed
Locator: getByRole('listbox', { name: 'Select planet' }).getByRole('option')
Expected: 3
Received: 4
Timeout: 10000ms
Call log:
- Expect "toHaveCount" with timeout 10000ms
- waiting for getByRole('listbox', { name: 'Select planet' }).getByRole('option')
13 × locator resolved to 4 elements
- unexpected value "4"
```
# Page snapshot
```yaml
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]:
- button "◉ Earth ▾" [expanded] [active] [ref=e8] [cursor=pointer]:
- generic [ref=e9]: ◉
- generic [ref=e10]: Earth
- generic [ref=e11]: ▾
- listbox "Select planet" [ref=e12]:
- option "◉ Earth" [selected] [ref=e13] [cursor=pointer]:
- generic [ref=e14]: ◉
- generic [ref=e15]: Earth
- option "⛰ Khazad Prime" [ref=e16] [cursor=pointer]:
- generic [ref=e17]: ⛰
- generic [ref=e18]: Khazad Prime
- option "◎ Mars" [ref=e19] [cursor=pointer]:
- generic [ref=e20]: ◎
- generic [ref=e21]: Mars
- option "◈ Venus" [ref=e22] [cursor=pointer]:
- generic [ref=e23]: ◈
- generic [ref=e24]: Venus
- tablist "Simulation category" [ref=e26]:
- tab "Environment" [selected] [ref=e27] [cursor=pointer]
- tab "Life" [ref=e28] [cursor=pointer]
- navigation "Climate scenario groups" [ref=e29]:
- button "Baseline Scenarios Baseline" [ref=e31] [cursor=pointer]:
- generic [ref=e33]:
- generic [ref=e34]: Baseline Scenarios
- generic [ref=e35]: Baseline
- generic [ref=e36]: ▾
- button "Eco Disaster" [ref=e38] [cursor=pointer]:
- generic [ref=e41]: Eco Disaster
- generic [ref=e42]: ▾
- button "ET Disaster" [ref=e44] [cursor=pointer]:
- generic [ref=e47]: ET Disaster
- generic [ref=e48]: ▾
- button "Lifeless Worlds" [ref=e50] [cursor=pointer]:
- generic [ref=e53]: Lifeless Worlds
- generic [ref=e54]: ▾
- paragraph [ref=e56]: Reference climate scenario — pure solar forcing, no overrides.
- generic [ref=e57]:
- generic "Climate simulation hex map" [ref=e58]
- generic [ref=e60]:
- button "Layers" [ref=e61] [cursor=pointer]:
- generic: Layers
- generic [ref=e62]:
- generic [ref=e63]:
- checkbox "🗺 Terrain" [checked] [ref=e64] [cursor=pointer]:
- generic [ref=e65]: 🗺
- generic: Terrain
- checkbox "🌡 Temperature" [ref=e67] [cursor=pointer]:
- generic [ref=e68]: 🌡
- generic: Temperature
- checkbox "💧 Moisture" [ref=e70] [cursor=pointer]:
- generic [ref=e71]: 💧
- generic: Moisture
- checkbox "💨 Wind" [ref=e73] [cursor=pointer]:
- generic [ref=e74]: 💨
- generic: Wind
- checkbox "⬡ Pressure" [ref=e76] [cursor=pointer]:
- generic [ref=e77]: ⬡
- generic: Pressure
- checkbox "🌊 Rivers" [ref=e79] [cursor=pointer]:
- generic [ref=e80]: 🌊
- generic: Rivers
- checkbox "Δ Delta" [ref=e82] [cursor=pointer]:
- generic [ref=e83]: Δ
- generic: Delta
- generic: View Center
- generic [ref=e86]:
- radio "🌍 Equator" [checked] [ref=e87] [cursor=pointer]:
- generic [ref=e88]: 🌍
- generic: Equator
- radio "⬆ N Pole" [ref=e90] [cursor=pointer]:
- generic [ref=e91]: ⬆
- generic: N Pole
- radio "⬇ S Pole" [ref=e93] [cursor=pointer]:
- generic [ref=e94]: ⬇
- generic: S Pole
- button "Legend Open biome reference" [ref=e98] [cursor=pointer]:
- generic: Legend
- button "Open biome reference" [ref=e100]: ⓘ
- generic [ref=e102]:
- button "Play simulation" [ref=e103] [cursor=pointer]: ▶
- button "1x" [ref=e104] [cursor=pointer]
- generic:
- text: "50"
- generic [ref=e105]: /50
- button "Enable loop" [ref=e106] [cursor=pointer]: ↻
- generic "Drag to resize map · Double-click to reset" [ref=e107]
- generic [ref=e109]:
- button "Events ▸" [ref=e111] [cursor=pointer]:
- generic [ref=e113]: Events
- generic [ref=e114]: ▸
- generic [ref=e115]:
- heading "Event Console" [level=3] [ref=e116]
- generic [ref=e117]:
- generic [ref=e118]:
- generic [ref=e120]: Asteroid Impact
- combobox [ref=e121] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- option "T6 — Continental"
- option "T7 — Sub-Global"
- option "T8 — Global"
- option "T9 — Extinction-Level"
- option "T10 — Doomsday"
- button "Fire" [ref=e122] [cursor=pointer]
- generic [ref=e123]:
- generic [ref=e125]: Solar Event
- combobox [ref=e126] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- option "T6 — Continental"
- option "T7 — Sub-Global"
- option "T8 — Global"
- option "T9 — Extinction-Level"
- option "T10 — Doomsday"
- button "Fire" [ref=e127] [cursor=pointer]
- generic [ref=e128]:
- generic [ref=e130]: Volcanic
- combobox [ref=e131] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- option "T6 — Continental"
- option "T7 — Sub-Global"
- option "T8 — Global"
- option "T9 — Extinction-Level"
- option "T10 — Doomsday"
- button "Fire" [ref=e132] [cursor=pointer]
- generic [ref=e133]:
- generic [ref=e135]: Marine
- combobox [ref=e136] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- option "T6 — Continental"
- option "T7 — Sub-Global"
- option "T8 — Global"
- option "T9 — Extinction-Level"
- option "T10 — Doomsday"
- button "Fire" [ref=e137] [cursor=pointer]
- generic [ref=e138]:
- generic [ref=e140]: Ecological
- combobox [ref=e141] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- option "T6 — Continental"
- option "T7 — Sub-Global"
- option "T8 — Global"
- option "T9 — Extinction-Level"
- option "T10 — Doomsday"
- button "Fire" [ref=e142] [cursor=pointer]
- generic [ref=e143]:
- generic [ref=e145]: Atmospheric
- combobox [ref=e146] [cursor=pointer]:
- option "T7 — Sub-Global" [selected]
- option "T8 — Global"
- option "T9 — Extinction-Level"
- option "T10 — Doomsday"
- button "Fire" [ref=e147] [cursor=pointer]
- generic [ref=e148]:
- generic [ref=e150]: Wildfire
- combobox [ref=e151] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- button "Fire" [ref=e152] [cursor=pointer]
- generic [ref=e153]:
- generic [ref=e155]: Plague
- combobox [ref=e156] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- button "Fire" [ref=e157] [cursor=pointer]
- generic [ref=e158]:
- generic [ref=e160]: Weather
- combobox [ref=e161] [cursor=pointer]:
- option "T1 — Minor" [selected]
- option "T2 — Moderate"
- option "T3 — Significant"
- option "T4 — Major"
- option "T5 — Severe"
- button "Fire" [ref=e162] [cursor=pointer]
- paragraph [ref=e163]: Events are injected at the current simulation end, then propagated 5 turns.
```
# Test source
```ts
143 | await expect(canvas).toBeVisible({ timeout: 5_000 })
144 |
145 | const controlBtn = sharedPage.getByRole('button', { name: /pause simulation|play simulation/i })
146 | await expect(controlBtn).toBeEnabled()
147 |
148 | await terrainRow.click()
149 | await expect(canvas).toBeVisible({ timeout: 5_000 })
150 | await expect(controlBtn).toBeEnabled()
151 |
152 | expect(consoleErrors, `Errors after terrain toggle:\n${consoleErrors.join('\n')}`).toHaveLength(0)
153 | })
154 |
155 | test('multiple layer toggles do not accumulate errors', async () => {
156 | const layers = ['Temperature', 'Moisture', 'Wind', 'Rivers']
157 |
158 | for (const label of layers) {
159 | const row = sharedPage.getByRole('checkbox').filter({ hasText: label })
160 | if (await row.isVisible()) {
161 | // force: true bypasses pointer-event interception from floating chart canvases
162 | await row.click({ force: true })
163 | await row.click({ force: true })
164 | }
165 | }
166 |
167 | expect(consoleErrors, `Errors after layer toggles:\n${consoleErrors.join('\n')}`).toHaveLength(0)
168 | })
169 |
170 | test('layers panel collapses and expands', async () => {
171 | // LayerPanel wraps in MapOverlayPanel(title="Layers", defaultExpanded)
172 | // MapOverlayPanel header includes the ▸ chevron char in text content, use substring
173 | const panelHeader = sharedPage.getByRole('button').filter({ hasText: 'Layers' })
174 | await expect(panelHeader).toBeVisible({ timeout: 5_000 })
175 |
176 | // Currently expanded (defaultExpanded=true) — collapse it
177 | const terrainRow = sharedPage.getByRole('checkbox').filter({ hasText: 'Terrain' })
178 | await expect(terrainRow).toBeVisible()
179 | await panelHeader.click()
180 | await expect(terrainRow).not.toBeVisible({ timeout: 3_000 })
181 |
182 | // Re-expand
183 | await panelHeader.click()
184 | await expect(terrainRow).toBeVisible({ timeout: 3_000 })
185 |
186 | expect(consoleErrors, `Errors after panel collapse:\n${consoleErrors.join('\n')}`).toHaveLength(0)
187 | })
188 |
189 | test('view center: N Pole and S Pole switch without errors', async () => {
190 | const canvas = sharedPage.locator('canvas').first()
191 |
192 | const nPoleBtn = sharedPage.getByRole('radio').filter({ hasText: 'N Pole' })
193 | await expect(nPoleBtn).toBeVisible({ timeout: 5_000 })
194 |
195 | // force: true bypasses pointer-event interception from the MoveableChart canvas
196 | await nPoleBtn.click({ force: true })
197 | await expect(canvas).toBeVisible({ timeout: 5_000 })
198 |
199 | const sPoleBtn = sharedPage.getByRole('radio').filter({ hasText: 'S Pole' })
200 | await sPoleBtn.click({ force: true })
201 | await expect(canvas).toBeVisible({ timeout: 5_000 })
202 |
203 | const equatorBtn = sharedPage.getByRole('radio').filter({ hasText: 'Equator' })
204 | await equatorBtn.click({ force: true })
205 | await expect(canvas).toBeVisible({ timeout: 5_000 })
206 |
207 | expect(consoleErrors, `Errors after view switch:\n${consoleErrors.join('\n')}`).toHaveLength(0)
208 | })
209 |
210 | // ── Category tabs ─────────────────────────────────────────────────────────
211 |
212 | test('Environment / Life category tabs switch layer presets', async () => {
213 | const tabList = sharedPage.getByRole('tablist', { name: 'Simulation category' })
214 | await expect(tabList).toBeVisible({ timeout: 5_000 })
215 |
216 | const lifeTab = tabList.getByRole('tab', { name: 'Life' })
217 | await lifeTab.click()
218 | // Life category enables Canopy layer (bit 9)
219 | await expect(sharedPage.getByRole('checkbox').filter({ hasText: 'Canopy' })).toBeVisible({ timeout: 3_000 })
220 |
221 | // Switch back — Environment only shows climate layers
222 | const envTab = tabList.getByRole('tab', { name: 'Environment' })
223 | await envTab.click()
224 | await expect(sharedPage.getByRole('checkbox').filter({ hasText: 'Terrain' })).toBeVisible({ timeout: 3_000 })
225 |
226 | expect(consoleErrors, `Errors after category switch:\n${consoleErrors.join('\n')}`).toHaveLength(0)
227 | })
228 |
229 | // ── Planet selector ────────────────────────────────────────────────────────
230 |
231 | test('planet selector opens and shows all three planets', async () => {
232 | // Planet button is the first button[aria-haspopup="listbox"] in the DOM —
233 | // before the ScenarioTabs group buttons which also carry the same attribute.
234 | const planetBtn = sharedPage.locator('button[aria-haspopup="listbox"]').first()
235 | await expect(planetBtn).toBeVisible({ timeout: 5_000 })
236 |
237 | await planetBtn.click()
238 |
239 | const dropdown = sharedPage.getByRole('listbox', { name: 'Select planet' })
240 | await expect(dropdown).toBeVisible({ timeout: 3_000 })
241 |
242 | const options = dropdown.getByRole('option')
> 243 | await expect(options).toHaveCount(3)
| ^ Error: expect(locator).toHaveCount(expected) failed
244 |
245 | // Close without switching — the dropdown uses a mousedown-outside listener,
246 | // so clicking somewhere neutral (the scenario nav) dismisses it.
247 | await sharedPage.getByRole('navigation', { name: 'Climate scenario groups' }).click()
248 | await expect(dropdown).not.toBeVisible({ timeout: 3_000 })
249 |
250 | expect(consoleErrors, `Errors after planet dropdown:\n${consoleErrors.join('\n')}`).toHaveLength(0)
251 | })
252 |
253 | // ── Scenario navigation ────────────────────────────────────────────────────
254 |
255 | test('scenario group nav is present with multiple groups', async () => {
256 | const nav = sharedPage.getByRole('navigation', { name: 'Climate scenario groups' })
257 | await expect(nav).toBeVisible({ timeout: 5_000 })
258 |
259 | const groupButtons = nav.getByRole('button')
260 | const count = await groupButtons.count()
261 | expect(count).toBeGreaterThanOrEqual(2)
262 | })
263 |
264 | test('opening a scenario group dropdown shows options', async () => {
265 | const nav = sharedPage.getByRole('navigation', { name: 'Climate scenario groups' })
266 | const firstGroupBtn = nav.getByRole('button').first()
267 |
268 | await firstGroupBtn.click()
269 |
270 | const dropdown = sharedPage.getByRole('listbox').first()
271 | await expect(dropdown).toBeVisible({ timeout: 3_000 })
272 |
273 | const options = dropdown.getByRole('option')
274 | const count = await options.count()
275 | expect(count).toBeGreaterThanOrEqual(1)
276 |
277 | await sharedPage.click('canvas')
278 | })
279 |
280 | // ── MoveableChart (terrain composition chart) ────────────────────────────
281 |
282 | test('moveable chart Biomes / Terrain / Elevation tabs all render without errors', async () => {
283 | const biomesTab = sharedPage.getByRole('button').filter({ hasText: /^Biomes$/ })
284 | const terrainTab = sharedPage.getByRole('button').filter({ hasText: /^Terrain$/ })
285 | const elevationTab = sharedPage.getByRole('button').filter({ hasText: /^Elevation$/ })
286 |
287 | await expect(biomesTab).toBeVisible({ timeout: 5_000 })
288 | await expect(terrainTab).toBeVisible()
289 | await expect(elevationTab).toBeVisible()
290 |
291 | await terrainTab.click({ force: true })
292 | await expect(terrainTab).toBeVisible()
293 |
294 | await elevationTab.click({ force: true })
295 | await expect(elevationTab).toBeVisible()
296 |
297 | await biomesTab.click({ force: true })
298 |
299 | expect(consoleErrors, `Errors after chart tab switch:\n${consoleErrors.join('\n')}`).toHaveLength(0)
300 | })
301 |
302 | // ── Legend panel ──────────────────────────────────────────────────────────
303 |
304 | test('legend panel is visible with terrain layer active', async () => {
305 | // TerrainLegend renders MapOverlayPanel(title="Legend") when terrain (bit 5) is on
306 | // MapOverlayPanel header includes the ▸ chevron — use substring match
307 | const legendHeader = sharedPage.getByRole('button').filter({ hasText: 'Legend' })
308 | await expect(legendHeader).toBeVisible({ timeout: 5_000 })
309 | })
310 |
311 | // ── Stats dashboard ────────────────────────────────────────────────────────
312 |
313 | test('stats dashboard scrubber is present and interactive', async () => {
314 | const scrubber = sharedPage.locator('input[type="range"]')
315 | await expect(scrubber).toBeVisible({ timeout: 5_000 })
316 | await expect(scrubber).toBeEnabled()
317 | })
318 |
319 | test('stats dashboard Extend button is present and enabled', async () => {
320 | const extendBtn = sharedPage.locator('button[title^="Extend by"]')
321 | await expect(extendBtn).toBeVisible({ timeout: 5_000 })
322 | // isSimulating=false after run completes, so the button is enabled
323 | await expect(extendBtn).toBeEnabled()
324 | })
325 |
326 | // ── Server mode ────────────────────────────────────────────────────────────
327 |
328 | test('server mode: status endpoint responds when sim-cache server is running', async () => {
329 | // This test is informational — it probes the status endpoint to detect
330 | // whether the dev server's sim-cache plugin is running. If not available
331 | // (e.g. in offline CI) the test is skipped rather than failed.
332 | const res = await sharedPage.request.get('/__sim-cache/base_no_magic/status?seed=42&turns=2000')
333 | .catch(() => null)
334 |
335 | if (!res || !res.ok()) {
336 | // Sim-cache server not available; worker mode is in use — nothing to assert.
337 | return
338 | }
339 |
340 | const body = await res.json() as { ready: boolean; totalTurns: number }
341 | expect(typeof body.ready).toBe('boolean')
342 | expect(typeof body.totalTurns).toBe('number')
343 | expect(body.totalTurns).toBeGreaterThan(0)
```

View file

@ -6,31 +6,16 @@
# Test info
- Name: simulator.spec.ts >> Climate simulator >> multiple layer toggles do not accumulate errors
- Location: e2e/simulator.spec.ts:155:3
- Name: simulator.spec.ts >> Climate simulator >> canvas is visible with non-zero dimensions
- Location: e2e/simulator.spec.ts:60:3
# Error details
```
Error: Errors after layer toggles:
[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)
[console.error] [SimCache] prefetch turn 50 failed: frame HTTP 500 for base_no_magic:50
[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)
[console.error] [SimCache] prefetch turn 51 failed: frame HTTP 500 for base_no_magic:51
[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)
[console.error] [SimCache] prefetch turn 50 failed: frame HTTP 500 for base_no_magic:50
[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)
[console.error] [SimCache] prefetch turn 50 failed: frame HTTP 500 for base_no_magic:50
[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)
[console.error] [SimCache] prefetch turn 52 failed: frame HTTP 500 for base_no_magic:52
[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)
[console.error] [SimCache] prefetch turn 51 failed: frame HTTP 500 for base_no_magic:51
Error: expect(received).toBeGreaterThan(expected)
expect(received).toHaveLength(expected)
Expected length: 0
Received length: 12
Received array: ["[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)", "[console.error] [SimCache] prefetch turn 50 failed: frame HTTP 500 for base_no_magic:50", "[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)", "[console.error] [SimCache] prefetch turn 51 failed: frame HTTP 500 for base_no_magic:51", "[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)", "[console.error] [SimCache] prefetch turn 50 failed: frame HTTP 500 for base_no_magic:50", "[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)", "[console.error] [SimCache] prefetch turn 50 failed: frame HTTP 500 for base_no_magic:50", "[console.error] Failed to load resource: the server responded with a status of 500 (Internal Server Error)", "[console.error] [SimCache] prefetch turn 52 failed: frame HTTP 500 for base_no_magic:52", …]
Expected: > 400
Received: 200
```
# Page snapshot
@ -107,7 +92,7 @@ Received array: ["[console.error] Failed to load resource: the server responded
- button "Pause simulation" [ref=e90] [cursor=pointer]: ⏸
- button "1x" [ref=e91] [cursor=pointer]
- generic:
- text: "34"
- text: "7"
- generic [ref=e92]: /50
- button "Enable loop" [ref=e93] [cursor=pointer]: ↻
- generic [ref=e95]:
@ -145,40 +130,40 @@ Received array: ["[console.error] Failed to load resource: the server responded
- textbox "Seed digit 6 of 6" [ref=e130]: "2"
- button "Random seed" [ref=e131] [cursor=pointer]: ⟳
- button "+500" [ref=e132] [cursor=pointer]
- slider "Turn scrubber" [ref=e134] [cursor=pointer]: "33"
- slider "Turn scrubber" [ref=e134] [cursor=pointer]: "6"
- generic [ref=e135]:
- generic [ref=e136]: ✦ The Answer
- generic [ref=e137]: "\"You already know the question.\""
- generic [ref=e140]: Temperate
- generic [ref=e140]: Cold
- generic [ref=e143]:
- generic [ref=e145]:
- generic "Average land temperature (0=frozen, 1=scorching). Driven by solar input, albedo, and orbital cycles." [ref=e146]: Temp
- generic [ref=e147]: "0.45"
- generic [ref=e148]: ▲ +0.02
- generic [ref=e147]: "0.44"
- generic [ref=e148]: ▲ +0.01
- generic [ref=e152]:
- generic "Global average wind speed (0=calm, 1=gale). Set by latitude bands + Coriolis at map gen. Dynamic pressure-driven updates (atmosphere.gd) not yet transpiled to TS." [ref=e153]: Wind
- generic [ref=e154]: "0.475"
- generic [ref=e155]: — 0
- generic [ref=e159]:
- generic "Average land moisture (0=bone dry, 1=saturated). Driven by ocean evaporation, wind transport, and precipitation." [ref=e160]: Moisture
- generic [ref=e161]: "0.55"
- generic [ref=e162]: ▼ -0.03
- generic [ref=e161]: "0.57"
- generic [ref=e162]: ▼ -0.01
- generic [ref=e166]:
- generic "Elevation threshold for water. Rises with warming, falls with cooling." [ref=e167]: Sea Lvl
- generic [ref=e168]: "0.000"
- generic [ref=e169]: — 0
- generic [ref=e173]:
- generic "Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it." [ref=e174]: Albedo
- generic [ref=e175]: "0.288"
- generic [ref=e176]: ▼ -0.016
- generic [ref=e175]: "0.289"
- generic [ref=e176]: ▼ -0.015
- generic [ref=e180]:
- generic "Per-turn temperature change. Positive = planet absorbing more heat than it radiates (warming). Negative = cooling." [ref=e181]: Net Energy
- generic [ref=e182]: "-0.0005"
- generic [ref=e182]: "+0.0003"
- generic [ref=e183]: — 0
- generic [ref=e187]:
- generic "Per-turn moisture change. Positive = planet gaining water (wetting). Negative = losing water (drying)." [ref=e188]: Net Hydro
- generic [ref=e189]: "-0.0000"
- generic [ref=e190]: 0
- generic [ref=e189]: "-0.0020"
- generic [ref=e190]: ▼ -0.0020
- generic [ref=e194]:
- generic "Global average sulfate aerosol opacity. Volcanic eruptions inject aerosols that cool and dry the atmosphere." [ref=e195]: Aerosol
- generic [ref=e196]: "0.0000"
@ -188,44 +173,44 @@ Received array: ["[console.error] Failed to load resource: the server responded
- generic [ref=e203]:
- generic [ref=e204]:
- generic "Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it." [ref=e205]: Albedo
- generic [ref=e208]: "0.288"
- generic [ref=e208]: "0.289"
- generic [ref=e209]:
- generic "Average absorbed solar energy after albedo reflection. The net heat input driving temperature." [ref=e210]: Solar
- generic [ref=e213]: "0.302"
- generic [ref=e213]: "0.303"
- generic [ref=e214]:
- generic "Global average wind speed (0=calm, 1=gale). Set by latitude bands + Coriolis at map gen. Dynamic pressure-driven updates (atmosphere.gd) not yet transpiled to TS." [ref=e215]: Wind
- generic [ref=e218]: "0.475"
- generic [ref=e219]:
- generic "Average evapotranspiration across land. Moisture recycled by vegetation per turn." [ref=e220]: ET
- generic [ref=e223]: "0.0003"
- generic [ref=e223]: "0.0001"
- generic [ref=e224]:
- generic [ref=e225]:
- generic "Global average sulfate aerosol opacity. Volcanic eruptions inject aerosols that cool and dry the atmosphere." [ref=e226]: Aerosol
- generic [ref=e229]: "0.0000"
- generic [ref=e230]:
- generic "Per-turn temperature change. Positive = planet absorbing more heat than it radiates (warming). Negative = cooling." [ref=e231]: Net Energy
- generic [ref=e234]: "-0.0005"
- generic [ref=e234]: "+0.0003"
- generic [ref=e235]:
- generic "Per-turn moisture change. Positive = planet gaining water (wetting). Negative = losing water (drying)." [ref=e236]: Net Hydro
- generic [ref=e239]: "-0.0000"
- generic [ref=e239]: "-0.0020"
- generic [ref=e240]:
- generic "Average biome quality across land (1-5). Composite of flora health, fauna diversity, biome stability." [ref=e241]: Biome Qlty
- generic [ref=e244]: "2.05"
- generic [ref=e244]: "1.84"
- generic [ref=e246]:
- generic [ref=e247]: Atmosphere
- generic [ref=e248]:
- generic [ref=e250]:
- 'generic "Atmospheric oxygen fraction as percentage. 21% = modern Earth normal. Below 18%: thin air. Below 10%: life-threatening. Below 5%: lethal." [ref=e251]': O₂ %
- generic [ref=e252]: 34.6%
- generic [ref=e252]: 35.0%
- generic [ref=e257]:
- 'generic "Atmospheric CO₂ in parts per million (log10 scale). 420 = modern Earth. >1000: unbreathable. >50000: lethal." [ref=e258]': CO₂ ppm
- generic [ref=e259]: 6.0k
- generic [ref=e259]: "320"
- generic [ref=e264]:
- generic "Atmospheric methane in parts per billion. 1900 = modern Earth. Runaway at >50000 ppb triggers irreversible warming cascade." [ref=e265]: CH₄ ppb
- generic [ref=e266]: 15.3k
- generic [ref=e266]: 1.6k
- generic [ref=e271]:
- generic "Composite biosphere health index (0-100%). Weighted from O₂ score (35%), fish stock (20%), canopy (20%), fauna habitat (15%), reef health (10%), scaled by ocean state. Below 40% = cascading collapse risk." [ref=e272]: Biosphere
- generic [ref=e273]: 27.0%
- generic [ref=e273]: 45.0%
- button "Events ▸" [ref=e278] [cursor=pointer]:
- generic [ref=e280]: Events
- generic [ref=e281]: ▸
@ -343,6 +328,73 @@ Received array: ["[console.error] Failed to load resource: the server responded
# Test source
```ts
1 | import { test, expect } from '@playwright/test'
2 | import type { Page, BrowserContext } from '@playwright/test'
3 |
4 | /**
5 | * Climate simulator E2E tests.
6 | *
7 | * All tests share a single browser page to avoid paying the WASM cold-start
8 | * compilation cost on every test. The beforeAll navigates once and waits for
9 | * the simulation to complete (canvas visible). Individual tests then assert
10 | * against the live, running simulation.
11 | *
12 | * URL params:
13 | * ?totalTurns=50 — short run so the wait is bounded
14 | * &buffer=0 — skip prebuffer, canvas appears as soon as sim finishes
15 | * &noGui=true — strip the guide layout shell (sidebar, nav)
16 | * &skip=welcome — skip the race/gender welcome modal
17 | */
18 |
19 | const SIM_URL = '/climate/simulation?noGui=true&skip=welcome&totalTurns=50&buffer=0'
20 |
21 | let sharedPage: Page
22 | let sharedContext: BrowserContext
23 | const consoleErrors: string[] = []
24 |
25 | test.describe('Climate simulator', () => {
26 | test.beforeAll(async ({ browser }) => {
27 | // Must create a context explicitly — beforeAll doesn't inherit playwright.config `use`.
28 | sharedContext = await browser.newContext({
29 | baseURL: 'http://localhost:5802',
30 | viewport: { width: 1280, height: 800 },
31 | })
32 | sharedPage = await sharedContext.newPage()
33 |
34 | sharedPage.on('console', (msg) => {
35 | if (msg.type() === 'error') consoleErrors.push(`[console.error] ${msg.text()}`)
36 | })
37 | sharedPage.on('pageerror', (err) => {
38 | consoleErrors.push(`[pageerror] ${err.message}`)
39 | })
40 |
41 | await sharedPage.goto(SIM_URL)
42 |
43 | // Canvas appearing means bufferReady fired — world gen + sim run complete.
44 | // 120s covers WASM compilation + 50-turn run on a slow CI machine.
45 | await expect(sharedPage.locator('canvas').first()).toBeVisible({ timeout: 120_000 })
46 | })
47 |
48 | test.afterAll(async () => {
49 | await sharedContext.close()
50 | })
51 |
52 | // ── Startup ────────────────────────────────────────────────────────────────
53 |
54 | test('no console errors during startup and simulation', () => {
55 | expect(consoleErrors, `Errors:\n${consoleErrors.join('\n')}`).toHaveLength(0)
56 | })
57 |
58 | // ── Canvas ────────────────────────────────────────────────────────────────
59 |
60 | test('canvas is visible with non-zero dimensions', async () => {
61 | const canvas = sharedPage.locator('canvas').first()
62 | await expect(canvas).toBeVisible()
63 |
64 | const box = await canvas.boundingBox()
65 | expect(box, 'canvas has no bounding box').not.toBeNull()
> 66 | expect(box!.width).toBeGreaterThan(400)
| ^ Error: expect(received).toBeGreaterThan(expected)
67 | expect(box!.height).toBeGreaterThan(300)
68 | })
69 |
@ -443,106 +495,4 @@ Received array: ["[console.error] Failed to load resource: the server responded
164 | }
165 | }
166 |
> 167 | expect(consoleErrors, `Errors after layer toggles:\n${consoleErrors.join('\n')}`).toHaveLength(0)
| ^ Error: Errors after layer toggles:
168 | })
169 |
170 | test('layers panel collapses and expands', async () => {
171 | // LayerPanel wraps in MapOverlayPanel(title="Layers", defaultExpanded)
172 | // MapOverlayPanel header includes the ▸ chevron char in text content, use substring
173 | const panelHeader = sharedPage.getByRole('button').filter({ hasText: 'Layers' })
174 | await expect(panelHeader).toBeVisible({ timeout: 5_000 })
175 |
176 | // Currently expanded (defaultExpanded=true) — collapse it
177 | const terrainRow = sharedPage.getByRole('checkbox').filter({ hasText: 'Terrain' })
178 | await expect(terrainRow).toBeVisible()
179 | await panelHeader.click()
180 | await expect(terrainRow).not.toBeVisible({ timeout: 3_000 })
181 |
182 | // Re-expand
183 | await panelHeader.click()
184 | await expect(terrainRow).toBeVisible({ timeout: 3_000 })
185 |
186 | expect(consoleErrors, `Errors after panel collapse:\n${consoleErrors.join('\n')}`).toHaveLength(0)
187 | })
188 |
189 | test('view center: N Pole and S Pole switch without errors', async () => {
190 | const canvas = sharedPage.locator('canvas').first()
191 |
192 | const nPoleBtn = sharedPage.getByRole('radio').filter({ hasText: 'N Pole' })
193 | await expect(nPoleBtn).toBeVisible({ timeout: 5_000 })
194 |
195 | // force: true bypasses pointer-event interception from the MoveableChart canvas
196 | await nPoleBtn.click({ force: true })
197 | await expect(canvas).toBeVisible({ timeout: 5_000 })
198 |
199 | const sPoleBtn = sharedPage.getByRole('radio').filter({ hasText: 'S Pole' })
200 | await sPoleBtn.click({ force: true })
201 | await expect(canvas).toBeVisible({ timeout: 5_000 })
202 |
203 | const equatorBtn = sharedPage.getByRole('radio').filter({ hasText: 'Equator' })
204 | await equatorBtn.click({ force: true })
205 | await expect(canvas).toBeVisible({ timeout: 5_000 })
206 |
207 | expect(consoleErrors, `Errors after view switch:\n${consoleErrors.join('\n')}`).toHaveLength(0)
208 | })
209 |
210 | // ── Category tabs ─────────────────────────────────────────────────────────
211 |
212 | test('Environment / Life category tabs switch layer presets', async () => {
213 | const tabList = sharedPage.getByRole('tablist', { name: 'Simulation category' })
214 | await expect(tabList).toBeVisible({ timeout: 5_000 })
215 |
216 | const lifeTab = tabList.getByRole('tab', { name: 'Life' })
217 | await lifeTab.click()
218 | // Life category enables Canopy layer (bit 9)
219 | await expect(sharedPage.getByRole('checkbox').filter({ hasText: 'Canopy' })).toBeVisible({ timeout: 3_000 })
220 |
221 | // Switch back — Environment only shows climate layers
222 | const envTab = tabList.getByRole('tab', { name: 'Environment' })
223 | await envTab.click()
224 | await expect(sharedPage.getByRole('checkbox').filter({ hasText: 'Terrain' })).toBeVisible({ timeout: 3_000 })
225 |
226 | expect(consoleErrors, `Errors after category switch:\n${consoleErrors.join('\n')}`).toHaveLength(0)
227 | })
228 |
229 | // ── Planet selector ────────────────────────────────────────────────────────
230 |
231 | test('planet selector opens and shows all three planets', async () => {
232 | // Planet button is the first button[aria-haspopup="listbox"] in the DOM —
233 | // before the ScenarioTabs group buttons which also carry the same attribute.
234 | const planetBtn = sharedPage.locator('button[aria-haspopup="listbox"]').first()
235 | await expect(planetBtn).toBeVisible({ timeout: 5_000 })
236 |
237 | await planetBtn.click()
238 |
239 | const dropdown = sharedPage.getByRole('listbox', { name: 'Select planet' })
240 | await expect(dropdown).toBeVisible({ timeout: 3_000 })
241 |
242 | const options = dropdown.getByRole('option')
243 | await expect(options).toHaveCount(3)
244 |
245 | // Close without switching — the dropdown uses a mousedown-outside listener,
246 | // so clicking somewhere neutral (the scenario nav) dismisses it.
247 | await sharedPage.getByRole('navigation', { name: 'Climate scenario groups' }).click()
248 | await expect(dropdown).not.toBeVisible({ timeout: 3_000 })
249 |
250 | expect(consoleErrors, `Errors after planet dropdown:\n${consoleErrors.join('\n')}`).toHaveLength(0)
251 | })
252 |
253 | // ── Scenario navigation ────────────────────────────────────────────────────
254 |
255 | test('scenario group nav is present with multiple groups', async () => {
256 | const nav = sharedPage.getByRole('navigation', { name: 'Climate scenario groups' })
257 | await expect(nav).toBeVisible({ timeout: 5_000 })
258 |
259 | const groupButtons = nav.getByRole('button')
260 | const count = await groupButtons.count()
261 | expect(count).toBeGreaterThanOrEqual(2)
262 | })
263 |
264 | test('opening a scenario group dropdown shows options', async () => {
265 | const nav = sharedPage.getByRole('navigation', { name: 'Climate scenario groups' })
266 | const firstGroupBtn = nav.getByRole('button').first()
267 |
```