Initial commit: Zabavna Matematika web and Android app.

Kids math practice (SL/EN) in a Vite React app, wrapped with Capacitor for Android (net.bit76.matematika). Includes session refactor, i18n, and text-free difficulty icons with translated overlays.
This commit is contained in:
2026-08-15 10:59:25 +02:00
commit 6e85917f73
147 changed files with 9190 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
import { chromium } from 'playwright'
function evalEq(text) {
const m = String(text || '').match(/(-?\d+)\s*([+\-×÷])\s*(-?\d+)/)
if (!m) return null
const a = Number(m[1])
const b = Number(m[3])
const op = m[2]
if (op === '+') return a + b
if (op === '-') return a - b
if (op === '×') return a * b
if (op === '÷') return a / b
return null
}
const browser = await chromium.launch({
headless: true,
channel: process.env.PW_CHANNEL || 'msedge'
})
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
await page.addInitScript(() => {
localStorage.setItem('matematika_lang', 'sl')
localStorage.setItem('matematika_settings', JSON.stringify({
name: 'Test',
stevilo_racunov: 2,
max_number: 20,
sestevanje: true,
odstevanje: false,
mnozenje: false,
deljenje: false,
za_mnozenje: [2],
za_deljenje: [2],
difficulty: 'easy'
}))
})
const url = process.env.SMOKE_URL || 'http://127.0.0.1:4173/'
await page.goto(url, { waitUntil: 'domcontentloaded' })
const continueBtn = page.getByRole('button', { name: 'Nadaljuj' })
await continueBtn.waitFor({ state: 'visible', timeout: 120000 })
await page.waitForFunction(() => {
const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Nadaljuj')
return btn && !btn.disabled
}, null, { timeout: 120000 })
await continueBtn.click()
await page.getByRole('button', { name: 'IGRAJ' }).first().click()
await page.getByRole('button', { name: 'IGRAJ' }).last().click()
await page.locator('.answer-tile').first().waitFor({ timeout: 15000 })
async function currentEq() {
return page.locator('.font-extrabold.leading-tight').innerText()
}
async function clickTile(expected) {
const tiles = page.locator('.answer-tile')
const n = await tiles.count()
for (let i = 0; i < n; i += 1) {
const label = (await tiles.nth(i).innerText()).replace(/[^\d-]/g, '')
if (Number(label) === expected) {
await tiles.nth(i).click()
return
}
}
throw new Error(`No tile for ${expected}`)
}
async function answerCurrent({ wrongFirst = false } = {}) {
const eq = await currentEq()
const expected = evalEq(eq)
if (expected === null) throw new Error(`Could not parse equation: ${eq}`)
if (wrongFirst) {
const tiles = page.locator('.answer-tile:not([disabled])')
const n = await tiles.count()
for (let i = 0; i < n; i += 1) {
const label = (await tiles.nth(i).innerText()).replace(/[^\d-]/g, '')
if (Number(label) !== expected) {
await tiles.nth(i).click()
await page.getByText(/Narobe/i).first().waitFor({ timeout: 3000 })
await page.waitForTimeout(400)
break
}
}
}
await clickTile(expected)
return eq
}
async function exitToSettings() {
await page.getByRole('button', { name: 'Izhod' }).click()
await page.locator('.fixed.inset-0').getByRole('button', { name: 'Izhod' }).click()
await page.getByRole('button', { name: 'IGRAJ' }).first().waitFor({ timeout: 8000 })
}
async function startFromSettings() {
await page.getByRole('button', { name: 'IGRAJ' }).first().click()
await page.getByRole('button', { name: 'IGRAJ' }).last().click()
}
const first = await answerCurrent()
await page.getByText(/Bravo/i).first().waitFor({ timeout: 5000 })
await page.waitForTimeout(2400)
const second = await answerCurrent()
await page.getByRole('button', { name: 'Začni ponovno' }).waitFor({ timeout: 8000 })
await page.getByRole('button', { name: 'Začni ponovno' }).click()
await page.locator('.answer-tile').first().waitFor({ timeout: 8000 })
const afterRestart = await currentEq()
if (!/[=+]/.test(afterRestart)) {
throw new Error(`Restart did not show a new question: ${afterRestart}`)
}
if (await page.getByRole('button', { name: 'Začni ponovno' }).isVisible().catch(() => false)) {
throw new Error('Restart immediately showed game over')
}
const easyTiles = await page.locator('.answer-tile').count()
if (easyTiles !== 4) throw new Error(`Easy should have 4 tiles, got ${easyTiles}`)
await exitToSettings()
await page.getByRole('button', { name: 'Srednje' }).click()
await startFromSettings()
await page.locator('.answer-tile').first().waitFor({ timeout: 15000 })
const mediumTiles = await page.locator('.answer-tile').count()
if (mediumTiles !== 6) throw new Error(`Medium should have 6 tiles, got ${mediumTiles}`)
await answerCurrent({ wrongFirst: true })
await page.getByText(/Bravo/i).first().waitFor({ timeout: 5000 })
await page.waitForTimeout(2400)
await answerCurrent()
await page.getByRole('button', { name: 'Začni ponovno' }).waitFor({ timeout: 8000 })
await page.getByRole('button', { name: 'Nastavitve' }).click()
await page.getByRole('button', { name: 'IGRAJ' }).first().waitFor({ timeout: 8000 })
await page.getByRole('button', { name: 'Težko' }).click()
await startFromSettings()
await page.getByRole('button', { name: 'OK' }).waitFor({ timeout: 15000 })
if (await page.locator('.answer-tile').count()) {
throw new Error('Hard should not show choice tiles')
}
if (await page.getByRole('button', { name: '±' }).count()) {
throw new Error('± should be hidden when negatives are off')
}
if (await page.getByRole('button', { name: 'OK' }).isEnabled()) {
throw new Error('OK should stay disabled on empty keypad')
}
const hardEq = await currentEq()
const hardExpected = evalEq(hardEq)
if (hardExpected === null) throw new Error(`Could not parse hard equation: ${hardEq}`)
const digits = String(Math.abs(hardExpected))
for (const d of digits) {
await page.getByRole('button', { name: d, exact: true }).click()
}
if (!(await page.getByRole('button', { name: 'OK' }).isEnabled())) {
throw new Error('OK should enable after entering digits')
}
await page.getByRole('button', { name: 'OK' }).click()
await page.getByText(/Bravo/i).first().waitFor({ timeout: 5000 })
await page.waitForTimeout(2400)
const hardEq2 = await currentEq()
const hardExpected2 = evalEq(hardEq2)
if (hardExpected2 === null) throw new Error(`Could not parse hard equation 2: ${hardEq2}`)
for (const d of String(Math.abs(hardExpected2))) {
await page.getByRole('button', { name: d, exact: true }).click()
}
await page.getByRole('button', { name: 'OK' }).click()
await page.getByRole('button', { name: 'Nastavitve' }).waitFor({ timeout: 8000 })
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Nastavitve' }).click()
await page.getByRole('button', { name: 'Lahko' }).click()
await startFromSettings()
await page.locator('.answer-tile').first().waitFor({ timeout: 15000 })
const mobileTiles = await page.locator('.answer-tile').count()
if (mobileTiles !== 4) throw new Error(`Mobile easy should have 4 tiles, got ${mobileTiles}`)
const tileBox = await page.locator('.answer-tile').first().boundingBox()
if (!tileBox || tileBox.width < 80 || tileBox.height < 40) {
throw new Error(`Mobile tiles too small: ${JSON.stringify(tileBox)}`)
}
if (tileBox.x + tileBox.width > 390) {
throw new Error('Mobile tile overflows the viewport')
}
const eqBox = await page.locator('.font-extrabold.leading-tight').boundingBox()
if (eqBox && eqBox.x + eqBox.width > 390) {
throw new Error('Equation overflows the mobile viewport')
}
console.log('SMOKE_OK', { first, second, afterRestart, easyTiles, mediumTiles, hardEq, mobileTiles, tileBox })
await browser.close()