From d558159173f1ad7f0dea86c56552b29e8d6b7b0a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 15 Sep 2026 10:06:46 +0700 Subject: [PATCH] fix(sang-eratosthenes): keep the grid usable for primes above 50 rippling was raised before the stagger loop but lowered only inside the final timeout, so a prime with no multiple left on the 100-cell grid left the flag raised and rejected every later click. Affected 10 of the 25 primes on the grid. --- .../grid-interaction.svelte.js | 8 +++ .../grid-interaction.test.js | 65 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/lib/lessons/sang-eratosthenes/grid-interaction.test.js diff --git a/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js b/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js index 385297a..4804bf3 100644 --- a/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js +++ b/src/lib/lessons/sang-eratosthenes/grid-interaction.svelte.js @@ -51,6 +51,14 @@ export function createGridState(buildAnnouncement) { markedPrimes = [...markedPrimes, n]; const multiples = multiplesOf(n, 100); + // Every prime above 50 has no multiple left on a 100-cell grid. There is + // nothing to stagger, and entering ripple mode would lock the grid for + // good, because only the final timeout clears the flag. + if (multiples.length === 0) { + announcement = buildAnnouncement(n, multiples); + return; + } + if (reducedMotion) { const next = new Map(crossings); for (const cell of multiples) { diff --git a/src/lib/lessons/sang-eratosthenes/grid-interaction.test.js b/src/lib/lessons/sang-eratosthenes/grid-interaction.test.js new file mode 100644 index 0000000..89e665d --- /dev/null +++ b/src/lib/lessons/sang-eratosthenes/grid-interaction.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { createGridState } from './grid-interaction.svelte.js'; + +/** Minimal announcement builder; the tests only care that it was called. */ +const build = (/** @type {number} */ p, /** @type {number[]} */ mults) => + `${p}: ${mults.length}`; + +describe('createGridState — marking primes', () => { + it('a prime with no multiple left on the grid does not lock it', () => { + const grid = createGridState(build); + + // 53 is prime and 2×53 = 106 is off the 100-cell grid, so there is + // nothing to cross out. + grid.handleCellActivate(53); + expect(grid.rippling).toBe(false); + expect(grid.markedPrimes).toContain(53); + expect(grid.announcement).toBe('53: 0'); + + // The grid must still accept the next prime. + grid.handleCellActivate(59); + expect(grid.markedPrimes).toContain(59); + }); + + it('every prime above 50 leaves the grid usable', () => { + const grid = createGridState(build); + for (const p of [53, 59, 61, 67, 71, 73, 79, 83, 89, 97]) { + grid.handleCellActivate(p); + expect(grid.rippling).toBe(false); + } + expect(grid.markedPrimes).toHaveLength(10); + }); + + it('a prime with multiples enters ripple mode and blocks further clicks', () => { + const grid = createGridState(build); + grid.handleCellActivate(7); // 14, 21, … are on the grid + expect(grid.rippling).toBe(true); + + grid.handleCellActivate(11); + expect(grid.markedPrimes).not.toContain(11); + }); + + it('a composite is rejected and marks no prime', () => { + const grid = createGridState(build); + grid.handleCellActivate(9); + expect(grid.markedPrimes).toHaveLength(0); + expect(grid.shakeIndex).toBe(8); + }); + + it('the same prime cannot be marked twice', () => { + const grid = createGridState(build); + grid.handleCellActivate(53); + grid.handleCellActivate(53); + expect(grid.markedPrimes).toEqual([53]); + }); + + it('reset clears marks and unlocks the grid', () => { + const grid = createGridState(build); + grid.handleCellActivate(7); + expect(grid.rippling).toBe(true); + grid.handleReset(); + expect(grid.rippling).toBe(false); + expect(grid.markedPrimes).toHaveLength(0); + expect(grid.announcement).toBe(''); + }); +});