diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/e2e/wifi-setup.spec.mjs | 440 | ||||
| -rw-r--r-- | tests/integration/wifi_setup.mjs | 74 | ||||
| -rw-r--r-- | tests/unit/stubs/driver/gpio.h | 44 | ||||
| -rw-r--r-- | tests/unit/stubs/driver/i2c_master.h | 39 | ||||
| -rw-r--r-- | tests/unit/stubs/driver/i2c_types.h | 45 | ||||
| -rwxr-xr-x | tests/unit/test_keyboard | bin | 0 -> 35416 bytes | |||
| -rw-r--r-- | tests/unit/test_keyboard.c | 172 | ||||
| -rwxr-xr-x | tests/unit/test_touch | bin | 0 -> 30304 bytes | |||
| -rw-r--r-- | tests/unit/test_touch.c | 93 | ||||
| -rwxr-xr-x | tests/unit/test_wifi_setup | bin | 0 -> 33336 bytes | |||
| -rw-r--r-- | tests/unit/test_wifi_setup.c | 121 |
11 files changed, 1028 insertions, 0 deletions
diff --git a/tests/e2e/wifi-setup.spec.mjs b/tests/e2e/wifi-setup.spec.mjs new file mode 100644 index 0000000..31bc2cf --- /dev/null +++ b/tests/e2e/wifi-setup.spec.mjs | |||
| @@ -0,0 +1,440 @@ | |||
| 1 | import { test, expect } from '@playwright/test'; | ||
| 2 | |||
| 3 | const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const PORTAL_URL = `http://${PORTAL_IP}`; | ||
| 5 | |||
| 6 | const SETUP_HTML = `<!DOCTYPE html> | ||
| 7 | <html><head> | ||
| 8 | <meta charset='utf-8'> | ||
| 9 | <meta name='viewport' content='width=device-width, initial-scale=1'> | ||
| 10 | <title>TollGate Setup</title> | ||
| 11 | <style> | ||
| 12 | *{box-sizing:border-box;margin:0;padding:0} | ||
| 13 | body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; | ||
| 14 | background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center; | ||
| 15 | min-height:100vh;padding:20px} | ||
| 16 | .card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px; | ||
| 17 | max-width:400px;width:100%;text-align:center} | ||
| 18 | h1{font-size:24px;margin-bottom:8px;color:#f7931a} | ||
| 19 | .subtitle{color:#888;margin-bottom:20px;font-size:13px} | ||
| 20 | .networks{margin-top:16px;text-align:left} | ||
| 21 | .net-item{background:#252525;border:1px solid #333;border-radius:8px; | ||
| 22 | padding:12px;margin-bottom:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center} | ||
| 23 | .net-item:hover{border-color:#f7931a} | ||
| 24 | .net-item:active{background:#333} | ||
| 25 | .net-ssid{font-size:14px} | ||
| 26 | .net-rssi{font-size:11px;color:#888} | ||
| 27 | .net-lock{color:#f7931a;margin-right:4px} | ||
| 28 | .manual{margin-top:12px} | ||
| 29 | input{width:100%;background:#252525;border:1px solid #333;border-radius:8px; | ||
| 30 | color:#fff;padding:12px;font-size:14px;margin-bottom:8px;outline:none} | ||
| 31 | input:focus{border-color:#f7931a} | ||
| 32 | .btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px; | ||
| 33 | font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px} | ||
| 34 | .btn:hover{background:#e8850f} | ||
| 35 | .btn:disabled{background:#333;color:#666;cursor:not-allowed} | ||
| 36 | #status{margin-top:12px;padding:10px;border-radius:8px;display:none;font-size:13px} | ||
| 37 | #status.success{display:block;background:#1a472a;color:#4caf50} | ||
| 38 | #status.error{display:block;background:#471a1a;color:#f44336} | ||
| 39 | #status.processing{display:block;background:#1a3a47;color:#2196f3} | ||
| 40 | .refresh{background:none;border:1px solid #444;color:#aaa;border-radius:6px; | ||
| 41 | padding:6px 12px;font-size:12px;cursor:pointer;margin-top:4px} | ||
| 42 | .refresh:hover{border-color:#f7931a;color:#f7931a} | ||
| 43 | #manualForm{display:none;margin-top:12px} | ||
| 44 | </style> | ||
| 45 | </head><body> | ||
| 46 | <div class='card'> | ||
| 47 | <h1>TollGate Setup</h1> | ||
| 48 | <p class='subtitle'>Configure upstream WiFi</p> | ||
| 49 | <div id='scanStatus'>Scanning...</div> | ||
| 50 | <div class='networks' id='networkList'></div> | ||
| 51 | <button class='refresh' onclick='scanWifi()'>Rescan</button> | ||
| 52 | <button class='refresh' onclick='showManual()'>Manual entry</button> | ||
| 53 | <div id='manualForm'> | ||
| 54 | <input id='manualSsid' placeholder='SSID'> | ||
| 55 | <input id='manualPass' type='password' placeholder='Password'> | ||
| 56 | <button class='btn' onclick='connectManual()'>Connect</button> | ||
| 57 | </div> | ||
| 58 | <div id='passwordForm' style='display:none'> | ||
| 59 | <p style='margin:12px 0 8px;text-align:left' id='selectedNetwork'></p> | ||
| 60 | <input id='wifiPass' type='password' placeholder='WiFi password'> | ||
| 61 | <button class='btn' onclick='connectSelected()'>Connect</button> | ||
| 62 | </div> | ||
| 63 | <div id='status'></div> | ||
| 64 | </div> | ||
| 65 | <script> | ||
| 66 | const apIp='${PORTAL_IP}'; | ||
| 67 | let selectedSsid=''; | ||
| 68 | function showStatus(msg,type){const s=document.getElementById('status'); | ||
| 69 | s.textContent=msg;s.className=type;} | ||
| 70 | function scanWifi(){ | ||
| 71 | document.getElementById('scanStatus').textContent='Scanning...'; | ||
| 72 | document.getElementById('networkList').innerHTML=''; | ||
| 73 | fetch('/wifi/scan').then(r=>r.json()).then(aps=>{ | ||
| 74 | document.getElementById('scanStatus').textContent=aps.length+' networks found'; | ||
| 75 | const list=document.getElementById('networkList'); | ||
| 76 | aps.forEach(ap=>{ | ||
| 77 | const div=document.createElement('div'); | ||
| 78 | div.className='net-item'; | ||
| 79 | const lock=ap.secured?'<span class=net-lock>🔒</span>':''; | ||
| 80 | div.innerHTML='<span class=net-ssid>'+lock+ap.ssid+'</span><span class=net-rssi>'+ap.rssi+' dBm</span>'; | ||
| 81 | div.onclick=()=>selectNetwork(ap.ssid,ap.secured); | ||
| 82 | list.appendChild(div); | ||
| 83 | }); | ||
| 84 | }).catch(e=>{document.getElementById('scanStatus').textContent='Scan failed';}); | ||
| 85 | } | ||
| 86 | function selectNetwork(ssid,secured){ | ||
| 87 | selectedSsid=ssid; | ||
| 88 | document.getElementById('selectedNetwork').textContent='Connect to: '+ssid; | ||
| 89 | document.getElementById('passwordForm').style.display='block'; | ||
| 90 | document.getElementById('scanStatus').style.display='none'; | ||
| 91 | document.getElementById('networkList').style.display='none'; | ||
| 92 | document.querySelector('.refresh').style.display='none'; | ||
| 93 | if(!secured){connectSelected();} | ||
| 94 | } | ||
| 95 | function showManual(){ | ||
| 96 | document.getElementById('manualForm').style.display='block'; | ||
| 97 | } | ||
| 98 | function connectSelected(){ | ||
| 99 | const pass=document.getElementById('wifiPass').value; | ||
| 100 | doConnect(selectedSsid,pass); | ||
| 101 | } | ||
| 102 | function connectManual(){ | ||
| 103 | const ssid=document.getElementById('manualSsid').value.trim(); | ||
| 104 | const pass=document.getElementById('manualPass').value; | ||
| 105 | if(!ssid){showStatus('Enter SSID','error');return;} | ||
| 106 | doConnect(ssid,pass); | ||
| 107 | } | ||
| 108 | function doConnect(ssid,pass){ | ||
| 109 | showStatus('Connecting to '+ssid+'...','processing'); | ||
| 110 | fetch('/wifi/connect',{method:'POST',headers:{'Content-Type':'application/json'}, | ||
| 111 | body:JSON.stringify({ssid:ssid,password:pass})}) | ||
| 112 | .then(r=>r.json()).then(d=>{ | ||
| 113 | if(d.ok){showStatus('Connected! Device is restarting...','success');} | ||
| 114 | else{showStatus('Failed: '+(d.error||'unknown'),'error');} | ||
| 115 | }).catch(e=>{showStatus('Connection error','error');}); | ||
| 116 | } | ||
| 117 | scanWifi(); | ||
| 118 | </script> | ||
| 119 | </body></html>`; | ||
| 120 | |||
| 121 | const MOCK_AP_LIST = [ | ||
| 122 | { ssid: 'HomeNetwork', rssi: -42, secured: true }, | ||
| 123 | { ssid: 'CafeWiFi', rssi: -67, secured: true }, | ||
| 124 | { ssid: 'OpenPublic', rssi: -75, secured: false }, | ||
| 125 | { ssid: 'Neighbor5G', rssi: -81, secured: true }, | ||
| 126 | ]; | ||
| 127 | |||
| 128 | async function setupMockRoutes(page, overrides = {}) { | ||
| 129 | const scanResponse = overrides.scanResponse || MOCK_AP_LIST; | ||
| 130 | const connectHandler = overrides.connectHandler || (() => ({ ok: true })); | ||
| 131 | |||
| 132 | await page.route('**/setup', async route => { | ||
| 133 | await route.fulfill({ | ||
| 134 | status: 200, | ||
| 135 | contentType: 'text/html', | ||
| 136 | body: SETUP_HTML, | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | await page.route('**/wifi/scan', async route => { | ||
| 141 | await route.fulfill({ | ||
| 142 | status: 200, | ||
| 143 | contentType: 'application/json', | ||
| 144 | body: JSON.stringify(scanResponse), | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | await page.route('**/wifi/connect', async route => { | ||
| 149 | const request = route.request(); | ||
| 150 | const body = request.postDataJSON(); | ||
| 151 | const response = connectHandler(body); | ||
| 152 | await route.fulfill({ | ||
| 153 | status: 200, | ||
| 154 | contentType: 'application/json', | ||
| 155 | body: JSON.stringify(response), | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | |||
| 160 | async function loadSetupPage(page) { | ||
| 161 | await page.goto('http://tollgate.test/setup', { waitUntil: 'networkidle' }); | ||
| 162 | } | ||
| 163 | |||
| 164 | test.describe('WiFi Setup \u2014 Layer 1: API Endpoints (needs live board)', () => { | ||
| 165 | |||
| 166 | test('GET /setup redirects to portal on configured board', async ({ request }) => { | ||
| 167 | const resp = await request.fetch(`${PORTAL_URL}/setup`, { | ||
| 168 | maxRedirects: 0, | ||
| 169 | }); | ||
| 170 | expect(resp.status()).toBe(302); | ||
| 171 | const location = resp.headers()['location']; | ||
| 172 | expect(location).toContain(PORTAL_IP); | ||
| 173 | expect(location).toMatch(/\/$/); | ||
| 174 | }); | ||
| 175 | |||
| 176 | test('GET /wifi/scan returns JSON array with valid AP objects', async ({ request }) => { | ||
| 177 | const resp = await request.get(`${PORTAL_URL}/wifi/scan`); | ||
| 178 | expect(resp.status()).toBe(200); | ||
| 179 | const data = await resp.json(); | ||
| 180 | expect(Array.isArray(data)).toBe(true); | ||
| 181 | if (data.length > 0) { | ||
| 182 | const ap = data[0]; | ||
| 183 | expect(ap).toHaveProperty('ssid'); | ||
| 184 | expect(typeof ap.ssid).toBe('string'); | ||
| 185 | expect(ap).toHaveProperty('rssi'); | ||
| 186 | expect(typeof ap.rssi).toBe('number'); | ||
| 187 | expect(ap).toHaveProperty('secured'); | ||
| 188 | expect(typeof ap.secured).toBe('boolean'); | ||
| 189 | } | ||
| 190 | }); | ||
| 191 | |||
| 192 | test('GET /wifi/status returns connection state', async ({ request }) => { | ||
| 193 | const resp = await request.get(`${PORTAL_URL}/wifi/status`); | ||
| 194 | expect(resp.status()).toBe(200); | ||
| 195 | const data = await resp.json(); | ||
| 196 | expect(data).toHaveProperty('connected'); | ||
| 197 | expect(typeof data.connected).toBe('boolean'); | ||
| 198 | if (data.connected) { | ||
| 199 | expect(data).toHaveProperty('ip'); | ||
| 200 | expect(data.ip).toMatch(/\d+\.\d+\.\d+\.\d+/); | ||
| 201 | expect(data).toHaveProperty('ssid'); | ||
| 202 | } | ||
| 203 | }); | ||
| 204 | |||
| 205 | test('POST /wifi/connect rejects empty body', async ({ request }) => { | ||
| 206 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 207 | data: '', | ||
| 208 | headers: { 'Content-Type': 'application/json' }, | ||
| 209 | }); | ||
| 210 | const data = await resp.json(); | ||
| 211 | expect(data.ok).toBe(false); | ||
| 212 | }); | ||
| 213 | |||
| 214 | test('POST /wifi/connect rejects invalid JSON', async ({ request }) => { | ||
| 215 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 216 | data: 'not json at all', | ||
| 217 | headers: { 'Content-Type': 'application/json' }, | ||
| 218 | }); | ||
| 219 | const data = await resp.json(); | ||
| 220 | expect(data.ok).toBe(false); | ||
| 221 | expect(data.error).toBeDefined(); | ||
| 222 | }); | ||
| 223 | |||
| 224 | test('POST /wifi/connect rejects missing ssid', async ({ request }) => { | ||
| 225 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 226 | data: JSON.stringify({ password: 'testpass' }), | ||
| 227 | headers: { 'Content-Type': 'application/json' }, | ||
| 228 | }); | ||
| 229 | const data = await resp.json(); | ||
| 230 | expect(data.ok).toBe(false); | ||
| 231 | expect(data.error).toContain('ssid'); | ||
| 232 | }); | ||
| 233 | |||
| 234 | test('POST /wifi/connect with valid SSID returns ok or ECONNRESET', async ({ request }) => { | ||
| 235 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 236 | data: JSON.stringify({ ssid: 'TestSetupAP', password: 'testpass123' }), | ||
| 237 | headers: { 'Content-Type': 'application/json' }, | ||
| 238 | maxRedirects: 0, | ||
| 239 | timeout: 10000, | ||
| 240 | }).catch(() => null); | ||
| 241 | |||
| 242 | if (resp) { | ||
| 243 | const text = await resp.text(); | ||
| 244 | try { | ||
| 245 | const data = JSON.parse(text); | ||
| 246 | expect(data.ok).toBe(true); | ||
| 247 | } catch { | ||
| 248 | expect(resp.status()).toBeLessThan(500); | ||
| 249 | } | ||
| 250 | } | ||
| 251 | }); | ||
| 252 | }); | ||
| 253 | |||
| 254 | test.describe('WiFi Setup \u2014 Layer 1.5: Redirect (needs live board)', () => { | ||
| 255 | test('redirect Location header contains correct AP IP', async ({ request }) => { | ||
| 256 | const resp = await request.fetch(`${PORTAL_URL}/setup`, { | ||
| 257 | maxRedirects: 0, | ||
| 258 | }); | ||
| 259 | const location = resp.headers()['location']; | ||
| 260 | expect(location).toBe(`http://${PORTAL_IP}/`); | ||
| 261 | }); | ||
| 262 | }); | ||
| 263 | |||
| 264 | test.describe('WiFi Setup \u2014 Layer 2: HTML UI Interaction', () => { | ||
| 265 | |||
| 266 | test('page renders with title and subtitle', async ({ page }) => { | ||
| 267 | await setupMockRoutes(page); | ||
| 268 | await loadSetupPage(page); | ||
| 269 | await expect(page.locator('h1')).toHaveText('TollGate Setup'); | ||
| 270 | await expect(page.locator('.subtitle')).toHaveText('Configure upstream WiFi'); | ||
| 271 | }); | ||
| 272 | |||
| 273 | test('scan auto-triggers on load and shows network count', async ({ page }) => { | ||
| 274 | await setupMockRoutes(page); | ||
| 275 | await loadSetupPage(page); | ||
| 276 | await expect(page.locator('#scanStatus')).toHaveText(/4 networks found/, { timeout: 5000 }); | ||
| 277 | }); | ||
| 278 | |||
| 279 | test('network list shows SSID and RSSI for each AP', async ({ page }) => { | ||
| 280 | await setupMockRoutes(page); | ||
| 281 | await loadSetupPage(page); | ||
| 282 | await expect(page.locator('.net-item')).toHaveCount(4); | ||
| 283 | await expect(page.locator('.net-ssid').first()).toContainText('HomeNetwork'); | ||
| 284 | await expect(page.locator('.net-rssi').first()).toContainText('-42 dBm'); | ||
| 285 | }); | ||
| 286 | |||
| 287 | test('secured networks show lock icon', async ({ page }) => { | ||
| 288 | await setupMockRoutes(page); | ||
| 289 | await loadSetupPage(page); | ||
| 290 | const securedItems = page.locator('.net-item'); | ||
| 291 | const firstSecured = securedItems.first(); | ||
| 292 | await expect(firstSecured.locator('.net-lock')).toBeVisible(); | ||
| 293 | }); | ||
| 294 | |||
| 295 | test('open networks have no lock icon', async ({ page }) => { | ||
| 296 | await setupMockRoutes(page); | ||
| 297 | await loadSetupPage(page); | ||
| 298 | const openItem = page.locator('.net-item').nth(2); | ||
| 299 | await expect(openItem.locator('.net-lock')).toHaveCount(0); | ||
| 300 | await expect(openItem.locator('.net-ssid')).toContainText('OpenPublic'); | ||
| 301 | }); | ||
| 302 | |||
| 303 | test('clicking secured network shows password form and hides list', async ({ page }) => { | ||
| 304 | await setupMockRoutes(page); | ||
| 305 | await loadSetupPage(page); | ||
| 306 | await expect(page.locator('.net-item').first()).toBeVisible(); | ||
| 307 | await page.locator('.net-item').first().click(); | ||
| 308 | await expect(page.locator('#passwordForm')).toBeVisible(); | ||
| 309 | await expect(page.locator('#selectedNetwork')).toHaveText('Connect to: HomeNetwork'); | ||
| 310 | await expect(page.locator('#networkList')).toBeHidden(); | ||
| 311 | await expect(page.locator('#scanStatus')).toBeHidden(); | ||
| 312 | }); | ||
| 313 | |||
| 314 | test('clicking open network auto-connects without password form', async ({ page }) => { | ||
| 315 | let connectBody = null; | ||
| 316 | await setupMockRoutes(page, { | ||
| 317 | connectHandler: (body) => { | ||
| 318 | connectBody = body; | ||
| 319 | return { ok: true }; | ||
| 320 | }, | ||
| 321 | }); | ||
| 322 | await loadSetupPage(page); | ||
| 323 | const openItem = page.locator('.net-item').nth(2); | ||
| 324 | await openItem.click(); | ||
| 325 | await expect(page.locator('#status')).toHaveClass(/processing|success/, { timeout: 5000 }); | ||
| 326 | expect(connectBody).toBeTruthy(); | ||
| 327 | expect(connectBody.ssid).toBe('OpenPublic'); | ||
| 328 | }); | ||
| 329 | |||
| 330 | test('manual entry button toggles form visibility', async ({ page }) => { | ||
| 331 | await setupMockRoutes(page); | ||
| 332 | await loadSetupPage(page); | ||
| 333 | await expect(page.locator('#manualForm')).toBeHidden(); | ||
| 334 | await page.locator('button:has-text("Manual entry")').click(); | ||
| 335 | await expect(page.locator('#manualForm')).toBeVisible(); | ||
| 336 | await expect(page.locator('#manualSsid')).toBeVisible(); | ||
| 337 | await expect(page.locator('#manualPass')).toBeVisible(); | ||
| 338 | }); | ||
| 339 | |||
| 340 | test('manual connect with empty SSID shows error', async ({ page }) => { | ||
| 341 | await setupMockRoutes(page); | ||
| 342 | await loadSetupPage(page); | ||
| 343 | await page.locator('button:has-text("Manual entry")').click(); | ||
| 344 | await page.locator('#manualSsid').fill(''); | ||
| 345 | await page.locator('#manualForm .btn').click(); | ||
| 346 | await expect(page.locator('#status')).toHaveClass(/error/); | ||
| 347 | await expect(page.locator('#status')).toContainText('Enter SSID'); | ||
| 348 | }); | ||
| 349 | |||
| 350 | test('connect sends correct JSON body to /wifi/connect', async ({ page }) => { | ||
| 351 | let capturedBody = null; | ||
| 352 | await setupMockRoutes(page, { | ||
| 353 | connectHandler: (body) => { | ||
| 354 | capturedBody = body; | ||
| 355 | return { ok: true }; | ||
| 356 | }, | ||
| 357 | }); | ||
| 358 | await loadSetupPage(page); | ||
| 359 | await page.locator('.net-item').first().click(); | ||
| 360 | await page.locator('#wifiPass').fill('mysecretpass'); | ||
| 361 | await page.locator('#passwordForm .btn').click(); | ||
| 362 | await expect(page.locator('#status')).toHaveClass(/success|processing/, { timeout: 5000 }); | ||
| 363 | expect(capturedBody).toEqual({ ssid: 'HomeNetwork', password: 'mysecretpass' }); | ||
| 364 | }); | ||
| 365 | |||
| 366 | test('success response shows green status with Connected message', async ({ page }) => { | ||
| 367 | await setupMockRoutes(page, { | ||
| 368 | connectHandler: () => ({ ok: true }), | ||
| 369 | }); | ||
| 370 | await loadSetupPage(page); | ||
| 371 | await page.locator('.net-item').first().click(); | ||
| 372 | await page.locator('#wifiPass').fill('testpass'); | ||
| 373 | await page.locator('#passwordForm .btn').click(); | ||
| 374 | await expect(page.locator('#status')).toHaveClass(/success/, { timeout: 5000 }); | ||
| 375 | await expect(page.locator('#status')).toContainText('Connected!'); | ||
| 376 | }); | ||
| 377 | |||
| 378 | test('error response shows red status with failure reason', async ({ page }) => { | ||
| 379 | await setupMockRoutes(page, { | ||
| 380 | connectHandler: () => ({ ok: false, error: 'save failed' }), | ||
| 381 | }); | ||
| 382 | await loadSetupPage(page); | ||
| 383 | await page.locator('.net-item').first().click(); | ||
| 384 | await page.locator('#wifiPass').fill('wrongpass'); | ||
| 385 | await page.locator('#passwordForm .btn').click(); | ||
| 386 | await expect(page.locator('#status')).toHaveClass(/error/, { timeout: 5000 }); | ||
| 387 | await expect(page.locator('#status')).toContainText('Failed: save failed'); | ||
| 388 | }); | ||
| 389 | |||
| 390 | test('rescan button clears list and fetches fresh data', async ({ page }) => { | ||
| 391 | let scanCount = 0; | ||
| 392 | await page.route('**/setup', async route => { | ||
| 393 | await route.fulfill({ status: 200, contentType: 'text/html', body: SETUP_HTML }); | ||
| 394 | }); | ||
| 395 | await page.route('**/wifi/scan', async route => { | ||
| 396 | scanCount++; | ||
| 397 | const data = scanCount === 1 ? MOCK_AP_LIST : [ | ||
| 398 | { ssid: 'NewNetwork1', rssi: -30, secured: true }, | ||
| 399 | { ssid: 'NewNetwork2', rssi: -55, secured: false }, | ||
| 400 | ]; | ||
| 401 | await route.fulfill({ | ||
| 402 | status: 200, | ||
| 403 | contentType: 'application/json', | ||
| 404 | body: JSON.stringify(data), | ||
| 405 | }); | ||
| 406 | }); | ||
| 407 | await page.route('**/wifi/connect', async route => { | ||
| 408 | await route.fulfill({ | ||
| 409 | status: 200, | ||
| 410 | contentType: 'application/json', | ||
| 411 | body: JSON.stringify({ ok: true }), | ||
| 412 | }); | ||
| 413 | }); | ||
| 414 | await loadSetupPage(page); | ||
| 415 | await expect(page.locator('.net-item')).toHaveCount(4, { timeout: 5000 }); | ||
| 416 | expect(scanCount).toBe(1); | ||
| 417 | await page.locator('button:has-text("Rescan")').click(); | ||
| 418 | await expect(page.locator('.net-item')).toHaveCount(2, { timeout: 5000 }); | ||
| 419 | await expect(page.locator('.net-ssid').first()).toContainText('NewNetwork1'); | ||
| 420 | expect(scanCount).toBe(2); | ||
| 421 | }); | ||
| 422 | |||
| 423 | }); | ||
| 424 | |||
| 425 | test.describe('WiFi Setup \u2014 Layer 3: Full E2E (needs unconfigured board)', () => { | ||
| 426 | |||
| 427 | test.skip('full phone flow: scan \u2192 select \u2192 password \u2192 connect \u2192 status', async ({ page }) => { | ||
| 428 | await page.goto(`${PORTAL_URL}/setup`); | ||
| 429 | await expect(page.locator('h1')).toHaveText('TollGate Setup'); | ||
| 430 | await expect(page.locator('#scanStatus')).not.toHaveText('Scanning...', { timeout: 10000 }); | ||
| 431 | const networkCount = await page.locator('.net-item').count(); | ||
| 432 | expect(networkCount).toBeGreaterThan(0); | ||
| 433 | const firstSsid = await page.locator('.net-ssid').first().textContent(); | ||
| 434 | await page.locator('.net-item').first().click(); | ||
| 435 | await expect(page.locator('#passwordForm')).toBeVisible(); | ||
| 436 | await page.locator('#wifiPass').fill('test-password'); | ||
| 437 | await page.locator('#passwordForm .btn').click(); | ||
| 438 | await expect(page.locator('#status')).toHaveClass(/success|error|processing/, { timeout: 15000 }); | ||
| 439 | }); | ||
| 440 | }); | ||
diff --git a/tests/integration/wifi_setup.mjs b/tests/integration/wifi_setup.mjs new file mode 100644 index 0000000..a991ba5 --- /dev/null +++ b/tests/integration/wifi_setup.mjs | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | |||
| 5 | console.log(`\n=== WiFi Setup Integration Test ===`); | ||
| 6 | console.log(`Portal IP: ${IP}\n`); | ||
| 7 | |||
| 8 | let passed = 0, failed = 0; | ||
| 9 | function assert(cond, msg) { | ||
| 10 | if (cond) { console.log(` PASS: ${msg}`); passed++; } | ||
| 11 | else { console.log(` FAIL: ${msg}`); failed++; } | ||
| 12 | } | ||
| 13 | |||
| 14 | function run(cmd) { | ||
| 15 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 16 | catch { return null; } | ||
| 17 | } | ||
| 18 | |||
| 19 | function fetchJSON(path) { | ||
| 20 | const result = run(`curl -s --connect-timeout 5 http://${IP}${path}`); | ||
| 21 | if (!result) return null; | ||
| 22 | try { return JSON.parse(result); } | ||
| 23 | catch { return null; } | ||
| 24 | } | ||
| 25 | |||
| 26 | // 1. /setup page returns HTML (or redirects if already configured) | ||
| 27 | const setupPage = run(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 http://${IP}/setup`); | ||
| 28 | assert(setupPage === '200' || setupPage === '302', `/setup returns 200 or 302 (got ${setupPage})`); | ||
| 29 | |||
| 30 | // 2. /wifi/status endpoint works | ||
| 31 | const status = fetchJSON('/wifi/status'); | ||
| 32 | assert(status !== null, '/wifi/status returns JSON'); | ||
| 33 | assert(typeof status.connected === 'boolean', '/wifi/status has connected field'); | ||
| 34 | |||
| 35 | // 3. /wifi/scan endpoint returns array | ||
| 36 | console.log('\n (wifi/scan may take a few seconds...)'); | ||
| 37 | const scanResult = run(`curl -s --connect-timeout 15 --max-time 15 http://${IP}/wifi/scan`); | ||
| 38 | let scanData = null; | ||
| 39 | if (scanResult) { | ||
| 40 | try { scanData = JSON.parse(scanResult); } catch {} | ||
| 41 | } | ||
| 42 | assert(scanData !== null, '/wifi/scan returns JSON'); | ||
| 43 | if (scanData && Array.isArray(scanData)) { | ||
| 44 | assert(scanData.length >= 0, `/wifi/scan returns array (${scanData.length} APs)`); | ||
| 45 | if (scanData.length > 0) { | ||
| 46 | const ap = scanData[0]; | ||
| 47 | assert(ap.ssid !== undefined, 'AP has ssid field'); | ||
| 48 | assert(ap.rssi !== undefined, 'AP has rssi field'); | ||
| 49 | assert(ap.secured !== undefined, 'AP has secured field'); | ||
| 50 | console.log(` First AP: "${ap.ssid}" (${ap.rssi} dBm, ${ap.secured ? 'secured' : 'open'})`); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | // 4. /wifi/connect rejects invalid JSON | ||
| 55 | const badConnect = run(`curl -s -X POST -d 'not json' --connect-timeout 5 http://${IP}/wifi/connect`); | ||
| 56 | assert(badConnect !== null, '/wifi/connect responds to bad request'); | ||
| 57 | if (badConnect) { | ||
| 58 | try { | ||
| 59 | const err = JSON.parse(badConnect); | ||
| 60 | assert(err.ok === false, '/wifi/connect returns ok:false for bad request'); | ||
| 61 | } catch {} | ||
| 62 | } | ||
| 63 | |||
| 64 | // 5. /wifi/connect rejects missing ssid | ||
| 65 | const noSsid = run(`curl -s -X POST -H 'Content-Type: application/json' -d '{}' --connect-timeout 5 http://${IP}/wifi/connect`); | ||
| 66 | if (noSsid) { | ||
| 67 | try { | ||
| 68 | const err = JSON.parse(noSsid); | ||
| 69 | assert(err.ok === false && err.error, '/wifi/connect returns error for missing ssid'); | ||
| 70 | } catch {} | ||
| 71 | } | ||
| 72 | |||
| 73 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); | ||
| 74 | process.exit(failed > 0 ? 1 : 0); | ||
diff --git a/tests/unit/stubs/driver/gpio.h b/tests/unit/stubs/driver/gpio.h new file mode 100644 index 0000000..d8dda0a --- /dev/null +++ b/tests/unit/stubs/driver/gpio.h | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | #ifndef STUBS_DRIVER_GPIO_H | ||
| 2 | #define STUBS_DRIVER_GPIO_H | ||
| 3 | |||
| 4 | #include <stdint.h> | ||
| 5 | |||
| 6 | typedef enum { | ||
| 7 | GPIO_MODE_DISABLE = 0, | ||
| 8 | GPIO_MODE_INPUT, | ||
| 9 | GPIO_MODE_OUTPUT, | ||
| 10 | GPIO_MODE_OUTPUT_OD, | ||
| 11 | GPIO_MODE_INPUT_OUTPUT_OD, | ||
| 12 | GPIO_MODE_INPUT_OUTPUT, | ||
| 13 | } gpio_mode_t; | ||
| 14 | |||
| 15 | typedef enum { | ||
| 16 | GPIO_INTR_DISABLE = 0, | ||
| 17 | } gpio_int_type_t; | ||
| 18 | |||
| 19 | typedef enum { | ||
| 20 | GPIO_PULLUP_DISABLE = 0, | ||
| 21 | GPIO_PULLUP_ENABLE, | ||
| 22 | } gpio_pullup_t; | ||
| 23 | |||
| 24 | typedef enum { | ||
| 25 | GPIO_PULLDOWN_DISABLE = 0, | ||
| 26 | GPIO_PULLDOWN_ENABLE, | ||
| 27 | } gpio_pulldown_t; | ||
| 28 | |||
| 29 | typedef struct { | ||
| 30 | uint64_t pin_bit_mask; | ||
| 31 | gpio_mode_t mode; | ||
| 32 | gpio_pullup_t pull_up_en; | ||
| 33 | gpio_pulldown_t pull_down_en; | ||
| 34 | gpio_int_type_t intr_type; | ||
| 35 | } gpio_config_t; | ||
| 36 | |||
| 37 | static inline int gpio_config(const gpio_config_t *cfg) { (void)cfg; return 0; } | ||
| 38 | static inline int gpio_set_level(uint32_t gpio_num, uint32_t level) { (void)gpio_num; (void)level; return 0; } | ||
| 39 | |||
| 40 | #define GPIO_INTR_DISABLE 0 | ||
| 41 | #define GPIO_PULLUP_DISABLE 0 | ||
| 42 | #define GPIO_PULLDOWN_DISABLE 0 | ||
| 43 | |||
| 44 | #endif | ||
diff --git a/tests/unit/stubs/driver/i2c_master.h b/tests/unit/stubs/driver/i2c_master.h new file mode 100644 index 0000000..f49eaad --- /dev/null +++ b/tests/unit/stubs/driver/i2c_master.h | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | #ifndef STUBS_DRIVER_I2C_MASTER_H | ||
| 2 | #define STUBS_DRIVER_I2C_MASTER_H | ||
| 3 | |||
| 4 | #include "driver/i2c_types.h" | ||
| 5 | #include "esp_err.h" | ||
| 6 | #include <stdint.h> | ||
| 7 | #include <stddef.h> | ||
| 8 | |||
| 9 | static inline esp_err_t i2c_new_master_bus(const i2c_master_bus_config_t *cfg, i2c_master_bus_handle_t *ret) { | ||
| 10 | (void)cfg; (void)ret; | ||
| 11 | return ESP_OK; | ||
| 12 | } | ||
| 13 | |||
| 14 | static inline esp_err_t i2c_master_bus_add_device(i2c_master_bus_handle_t bus, const i2c_device_config_t *cfg, i2c_master_dev_handle_t *ret) { | ||
| 15 | (void)bus; (void)cfg; (void)ret; | ||
| 16 | return ESP_OK; | ||
| 17 | } | ||
| 18 | |||
| 19 | static inline esp_err_t i2c_master_transmit(i2c_master_dev_handle_t dev, const uint8_t *buf, size_t len, int timeout_ms) { | ||
| 20 | (void)dev; (void)buf; (void)len; (void)timeout_ms; | ||
| 21 | return ESP_OK; | ||
| 22 | } | ||
| 23 | |||
| 24 | static inline esp_err_t i2c_master_receive(i2c_master_dev_handle_t dev, uint8_t *buf, size_t len, int timeout_ms) { | ||
| 25 | (void)dev; (void)buf; (void)len; (void)timeout_ms; | ||
| 26 | return ESP_OK; | ||
| 27 | } | ||
| 28 | |||
| 29 | static inline esp_err_t i2c_master_bus_rm_device(i2c_master_dev_handle_t dev) { | ||
| 30 | (void)dev; | ||
| 31 | return ESP_OK; | ||
| 32 | } | ||
| 33 | |||
| 34 | static inline esp_err_t i2c_del_master_bus(i2c_master_bus_handle_t bus) { | ||
| 35 | (void)bus; | ||
| 36 | return ESP_OK; | ||
| 37 | } | ||
| 38 | |||
| 39 | #endif | ||
diff --git a/tests/unit/stubs/driver/i2c_types.h b/tests/unit/stubs/driver/i2c_types.h new file mode 100644 index 0000000..3590a8b --- /dev/null +++ b/tests/unit/stubs/driver/i2c_types.h | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | #ifndef STUBS_DRIVER_I2C_TYPES_H | ||
| 2 | #define STUBS_DRIVER_I2C_TYPES_H | ||
| 3 | |||
| 4 | #include <stdint.h> | ||
| 5 | #include <stddef.h> | ||
| 6 | |||
| 7 | typedef int i2c_port_num_t; | ||
| 8 | #define I2C_NUM_0 0 | ||
| 9 | |||
| 10 | typedef enum { | ||
| 11 | I2C_ADDR_BIT_LEN_7 = 0, | ||
| 12 | } i2c_addr_bit_len_t; | ||
| 13 | |||
| 14 | typedef enum { | ||
| 15 | I2C_CLK_SRC_DEFAULT = 0, | ||
| 16 | } i2c_clock_source_t; | ||
| 17 | |||
| 18 | typedef struct i2c_master_bus_t *i2c_master_bus_handle_t; | ||
| 19 | typedef struct i2c_master_dev_t *i2c_master_dev_handle_t; | ||
| 20 | |||
| 21 | typedef struct { | ||
| 22 | i2c_port_num_t i2c_port; | ||
| 23 | int sda_io_num; | ||
| 24 | int scl_io_num; | ||
| 25 | i2c_clock_source_t clk_source; | ||
| 26 | uint8_t glitch_ignore_cnt; | ||
| 27 | int intr_priority; | ||
| 28 | size_t trans_queue_depth; | ||
| 29 | struct { | ||
| 30 | uint32_t enable_internal_pullup : 1; | ||
| 31 | uint32_t allow_pd : 1; | ||
| 32 | } flags; | ||
| 33 | } i2c_master_bus_config_t; | ||
| 34 | |||
| 35 | typedef struct { | ||
| 36 | i2c_addr_bit_len_t dev_addr_length; | ||
| 37 | uint16_t device_address; | ||
| 38 | uint32_t scl_speed_hz; | ||
| 39 | uint32_t scl_wait_us; | ||
| 40 | struct { | ||
| 41 | uint32_t disable_ack_check : 1; | ||
| 42 | } flags; | ||
| 43 | } i2c_device_config_t; | ||
| 44 | |||
| 45 | #endif | ||
diff --git a/tests/unit/test_keyboard b/tests/unit/test_keyboard new file mode 100755 index 0000000..61cc9f5 --- /dev/null +++ b/tests/unit/test_keyboard | |||
| Binary files differ | |||
diff --git a/tests/unit/test_keyboard.c b/tests/unit/test_keyboard.c new file mode 100644 index 0000000..81ca328 --- /dev/null +++ b/tests/unit/test_keyboard.c | |||
| @@ -0,0 +1,172 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "../../main/keyboard.h" | ||
| 3 | #include <string.h> | ||
| 4 | |||
| 5 | int main(void) | ||
| 6 | { | ||
| 7 | printf("=== test_keyboard ===\n"); | ||
| 8 | |||
| 9 | const char *keys; | ||
| 10 | int count; | ||
| 11 | |||
| 12 | count = kb_get_row_keys(0, KB_ALPHA_LOWER, &keys); | ||
| 13 | ASSERT_EQ_INT(10, count, "Row 0 alpha lower has 10 keys"); | ||
| 14 | ASSERT_EQ_INT('q', keys[0], "Row 0 starts with 'q'"); | ||
| 15 | ASSERT_EQ_INT('p', keys[9], "Row 0 ends with 'p'"); | ||
| 16 | |||
| 17 | count = kb_get_row_keys(1, KB_ALPHA_LOWER, &keys); | ||
| 18 | ASSERT_EQ_INT(9, count, "Row 1 alpha lower has 9 keys"); | ||
| 19 | ASSERT_EQ_INT('a', keys[0], "Row 1 starts with 'a'"); | ||
| 20 | |||
| 21 | count = kb_get_row_keys(2, KB_ALPHA_LOWER, &keys); | ||
| 22 | ASSERT(count > 0, "Row 2 alpha lower has keys"); | ||
| 23 | ASSERT_EQ_INT('\001', keys[0], "Row 2 starts with SHIFT control char"); | ||
| 24 | |||
| 25 | count = kb_get_row_keys(0, KB_ALPHA_UPPER, &keys); | ||
| 26 | ASSERT_EQ_INT(10, count, "Row 0 alpha upper has 10 keys"); | ||
| 27 | ASSERT_EQ_INT('Q', keys[0], "Row 0 upper starts with 'Q'"); | ||
| 28 | |||
| 29 | count = kb_get_row_keys(0, KB_NUMSYM, &keys); | ||
| 30 | ASSERT_EQ_INT(10, count, "Row 0 numsym has 10 keys"); | ||
| 31 | ASSERT_EQ_INT('1', keys[0], "Row 0 numsym starts with '1'"); | ||
| 32 | ASSERT_EQ_INT('0', keys[9], "Row 0 numsym ends with '0'"); | ||
| 33 | |||
| 34 | count = kb_get_row_keys(-1, KB_ALPHA_LOWER, &keys); | ||
| 35 | ASSERT_EQ_INT(0, count, "Invalid row -1 returns 0"); | ||
| 36 | |||
| 37 | count = kb_get_row_keys(99, KB_ALPHA_LOWER, &keys); | ||
| 38 | ASSERT_EQ_INT(0, count, "Invalid row 99 returns 0"); | ||
| 39 | |||
| 40 | { | ||
| 41 | kb_result_t r = kb_hit_test(160, 10, KB_ALPHA_LOWER); | ||
| 42 | ASSERT(r.action == KB_ACTION_NONE, "Touch above keyboard = NONE"); | ||
| 43 | |||
| 44 | r = kb_hit_test(160, 70 + 4 * (36 + 2) + 10, KB_ALPHA_LOWER); | ||
| 45 | ASSERT(r.action == KB_ACTION_NONE, "Touch below keyboard = NONE"); | ||
| 46 | } | ||
| 47 | |||
| 48 | { | ||
| 49 | int margin_r0 = (320 - (10 * 28 + 9 * 2)) / 2; | ||
| 50 | int mid_x = margin_r0 + 28 / 2; | ||
| 51 | int mid_y = 70 + 36 / 2; | ||
| 52 | kb_result_t r = kb_hit_test(mid_x, mid_y, KB_ALPHA_LOWER); | ||
| 53 | ASSERT(r.action == KB_ACTION_CHAR, "Row 0 first key is a char"); | ||
| 54 | ASSERT_EQ_INT('q', r.ch, "Row 0 first key = 'q'"); | ||
| 55 | } | ||
| 56 | |||
| 57 | { | ||
| 58 | int margin_r0 = (320 - (10 * 28 + 9 * 2)) / 2; | ||
| 59 | int x = margin_r0 + 28 + 2 + 28 / 2; | ||
| 60 | int y = 70 + 36 / 2; | ||
| 61 | kb_result_t r = kb_hit_test(x, y, KB_ALPHA_LOWER); | ||
| 62 | ASSERT(r.action == KB_ACTION_CHAR, "Row 0 second key is a char"); | ||
| 63 | ASSERT_EQ_INT('w', r.ch, "Row 0 second key = 'w'"); | ||
| 64 | } | ||
| 65 | |||
| 66 | { | ||
| 67 | int margin_r1 = (320 - (9 * 28 + 8 * 2)) / 2; | ||
| 68 | int y_row1 = 70 + (36 + 2) + 36 / 2; | ||
| 69 | int x_row1 = margin_r1 + 28 / 2 + 28 / 2; | ||
| 70 | kb_result_t r = kb_hit_test(x_row1, y_row1, KB_ALPHA_LOWER); | ||
| 71 | ASSERT(r.action == KB_ACTION_CHAR, "Row 1 first key is a char"); | ||
| 72 | ASSERT_EQ_INT('a', r.ch, "Row 1 first key = 'a'"); | ||
| 73 | } | ||
| 74 | |||
| 75 | { | ||
| 76 | int margin_r2 = (320 - (9 * 28 + 8 * 2)) / 2 + 28; | ||
| 77 | int y_row2 = 70 + 2 * (36 + 2) + 36 / 2; | ||
| 78 | int x_row2 = margin_r2 + 28 / 2; | ||
| 79 | kb_result_t r = kb_hit_test(x_row2, y_row2, KB_ALPHA_LOWER); | ||
| 80 | ASSERT(r.action == KB_ACTION_SHIFT, "Row 2 first key = SHIFT"); | ||
| 81 | } | ||
| 82 | |||
| 83 | { | ||
| 84 | kb_state_t st; | ||
| 85 | kb_state_init(&st); | ||
| 86 | ASSERT_EQ_INT(0, st.cursor, "Initial cursor = 0"); | ||
| 87 | ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Initial layer = lower"); | ||
| 88 | ASSERT_EQ_STR("", st.input, "Initial input is empty"); | ||
| 89 | |||
| 90 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'h'}); | ||
| 91 | ASSERT_EQ_STR("h", st.input, "After typing 'h': input='h'"); | ||
| 92 | ASSERT_EQ_INT(1, st.cursor, "After typing 'h': cursor=1"); | ||
| 93 | |||
| 94 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'i'}); | ||
| 95 | ASSERT_EQ_STR("hi", st.input, "After typing 'i': input='hi'"); | ||
| 96 | |||
| 97 | kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); | ||
| 98 | ASSERT_EQ_STR("h", st.input, "After backspace: input='h'"); | ||
| 99 | ASSERT_EQ_INT(1, st.cursor, "After backspace: cursor=1"); | ||
| 100 | |||
| 101 | kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); | ||
| 102 | ASSERT_EQ_STR("", st.input, "After second backspace: empty"); | ||
| 103 | ASSERT_EQ_INT(0, st.cursor, "After second backspace: cursor=0"); | ||
| 104 | |||
| 105 | kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); | ||
| 106 | ASSERT_EQ_INT(0, st.cursor, "Backspace on empty stays at 0"); | ||
| 107 | } | ||
| 108 | |||
| 109 | { | ||
| 110 | kb_state_t st; | ||
| 111 | kb_state_init(&st); | ||
| 112 | |||
| 113 | kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0}); | ||
| 114 | ASSERT_EQ_INT(KB_ALPHA_UPPER, st.layer, "Shift: lower->upper"); | ||
| 115 | |||
| 116 | kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0}); | ||
| 117 | ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Shift: upper->lower"); | ||
| 118 | |||
| 119 | kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0}); | ||
| 120 | ASSERT_EQ_INT(KB_NUMSYM, st.layer, "Layer: lower->numsym"); | ||
| 121 | |||
| 122 | kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0}); | ||
| 123 | ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Layer: numsym->lower"); | ||
| 124 | } | ||
| 125 | |||
| 126 | { | ||
| 127 | kb_state_t st; | ||
| 128 | kb_state_init(&st); | ||
| 129 | |||
| 130 | for (int i = 0; i < KB_INPUT_MAX; i++) { | ||
| 131 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'a' + (i % 26)}); | ||
| 132 | } | ||
| 133 | ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Filled to max"); | ||
| 134 | ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "String length = max"); | ||
| 135 | |||
| 136 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'Z'}); | ||
| 137 | ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Overflow blocked"); | ||
| 138 | ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "Length unchanged after overflow"); | ||
| 139 | } | ||
| 140 | |||
| 141 | { | ||
| 142 | kb_state_t st; | ||
| 143 | kb_state_init(&st); | ||
| 144 | |||
| 145 | kb_apply(&st, (kb_result_t){KB_ACTION_SPACE, ' '}); | ||
| 146 | ASSERT_EQ_STR(" ", st.input, "Space adds space char"); | ||
| 147 | ASSERT_EQ_INT(1, st.cursor, "Space increments cursor"); | ||
| 148 | } | ||
| 149 | |||
| 150 | { | ||
| 151 | kb_state_t st; | ||
| 152 | kb_state_init(&st); | ||
| 153 | kb_result_t none = {KB_ACTION_NONE, 0}; | ||
| 154 | kb_apply(&st, none); | ||
| 155 | ASSERT_EQ_STR("", st.input, "NONE action does nothing"); | ||
| 156 | |||
| 157 | kb_apply(NULL, (kb_result_t){KB_ACTION_CHAR, 'x'}); | ||
| 158 | } | ||
| 159 | |||
| 160 | { | ||
| 161 | kb_state_t st; | ||
| 162 | kb_state_init(&st); | ||
| 163 | |||
| 164 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'P'}); | ||
| 165 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, '@'}); | ||
| 166 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'}); | ||
| 167 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'}); | ||
| 168 | ASSERT_EQ_STR("P@ss", st.input, "Password build: P@ss"); | ||
| 169 | } | ||
| 170 | |||
| 171 | TEST_SUMMARY(); | ||
| 172 | } | ||
diff --git a/tests/unit/test_touch b/tests/unit/test_touch new file mode 100755 index 0000000..43c8790 --- /dev/null +++ b/tests/unit/test_touch | |||
| Binary files differ | |||
diff --git a/tests/unit/test_touch.c b/tests/unit/test_touch.c new file mode 100644 index 0000000..13f04b5 --- /dev/null +++ b/tests/unit/test_touch.c | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "../../main/touch.h" | ||
| 3 | #include <string.h> | ||
| 4 | |||
| 5 | int main(void) | ||
| 6 | { | ||
| 7 | touch_point_t pt; | ||
| 8 | uint8_t data[8]; | ||
| 9 | |||
| 10 | printf("=== test_touch ===\n"); | ||
| 11 | |||
| 12 | memset(data, 0, sizeof(data)); | ||
| 13 | touch_parse_raw(data, &pt); | ||
| 14 | ASSERT(!pt.touched, "All-zero data = no touch"); | ||
| 15 | |||
| 16 | data[0] = 1; | ||
| 17 | data[1] = 1; | ||
| 18 | data[2] = 0; | ||
| 19 | data[3] = 0; | ||
| 20 | touch_parse_raw(data, &pt); | ||
| 21 | ASSERT(!pt.touched, "data[0]=1 = no touch (gesture byte nonzero)"); | ||
| 22 | |||
| 23 | data[0] = 0; | ||
| 24 | data[1] = 0; | ||
| 25 | touch_parse_raw(data, &pt); | ||
| 26 | ASSERT(!pt.touched, "data[1]=0 = no touch (touch count zero)"); | ||
| 27 | |||
| 28 | data[0] = 0; | ||
| 29 | data[1] = 1; | ||
| 30 | data[2] = 0x00; | ||
| 31 | data[3] = 0x64; | ||
| 32 | data[4] = 0x00; | ||
| 33 | data[5] = 0xC8; | ||
| 34 | data[6] = 0; | ||
| 35 | data[7] = 0; | ||
| 36 | touch_parse_raw(data, &pt); | ||
| 37 | ASSERT(pt.touched, "Valid touch: touched=true"); | ||
| 38 | ASSERT_EQ_INT(100, (int)pt.x, "Valid touch: x=100"); | ||
| 39 | ASSERT_EQ_INT(200, (int)pt.y, "Valid touch: y=200"); | ||
| 40 | |||
| 41 | data[0] = 0; | ||
| 42 | data[1] = 1; | ||
| 43 | data[2] = 0x0F; | ||
| 44 | data[3] = 0xFF; | ||
| 45 | data[4] = 0x0F; | ||
| 46 | data[5] = 0xFF; | ||
| 47 | touch_parse_raw(data, &pt); | ||
| 48 | ASSERT(pt.touched, "Max raw coords: touched=true"); | ||
| 49 | ASSERT_EQ_INT(TOUCH_MAX_X, (int)pt.x, "Max raw coords clamped to 319"); | ||
| 50 | ASSERT_EQ_INT(TOUCH_MAX_Y, (int)pt.y, "Max raw coords clamped to 479"); | ||
| 51 | |||
| 52 | data[0] = 0; | ||
| 53 | data[1] = 1; | ||
| 54 | data[2] = 0x05; | ||
| 55 | data[3] = 0x00; | ||
| 56 | data[4] = 0x08; | ||
| 57 | data[5] = 0x00; | ||
| 58 | touch_parse_raw(data, &pt); | ||
| 59 | ASSERT(pt.touched, "12-bit coords: touched=true"); | ||
| 60 | ASSERT_EQ_INT(TOUCH_MAX_X, (int)pt.x, "12-bit x: (0x05 << 8) | 0x00 = 1280, clamped to 319"); | ||
| 61 | |||
| 62 | data[0] = 0; | ||
| 63 | data[1] = 2; | ||
| 64 | touch_parse_raw(data, &pt); | ||
| 65 | ASSERT(!pt.touched, "data[1]=2 = too many touches, reject"); | ||
| 66 | |||
| 67 | touch_parse_raw(NULL, &pt); | ||
| 68 | ASSERT(!pt.touched, "NULL data = no touch"); | ||
| 69 | |||
| 70 | data[0] = 0; | ||
| 71 | data[1] = 1; | ||
| 72 | data[2] = 0x00; | ||
| 73 | data[3] = 0x00; | ||
| 74 | data[4] = 0x00; | ||
| 75 | data[5] = 0x00; | ||
| 76 | touch_parse_raw(data, &pt); | ||
| 77 | ASSERT(pt.touched, "Origin (0,0): touched=true"); | ||
| 78 | ASSERT_EQ_INT(0, (int)pt.x, "Origin: x=0"); | ||
| 79 | ASSERT_EQ_INT(0, (int)pt.y, "Origin: y=0"); | ||
| 80 | |||
| 81 | data[0] = 0; | ||
| 82 | data[1] = 1; | ||
| 83 | data[2] = 0x01; | ||
| 84 | data[3] = 0x3F; | ||
| 85 | data[4] = 0x01; | ||
| 86 | data[5] = 0xDF; | ||
| 87 | touch_parse_raw(data, &pt); | ||
| 88 | ASSERT(pt.touched, "Mid-screen: touched=true"); | ||
| 89 | ASSERT_EQ_INT(319, (int)pt.x, "Mid-screen: x=0x13F=319"); | ||
| 90 | ASSERT_EQ_INT(479, (int)pt.y, "Mid-screen: y=0x1DF=479"); | ||
| 91 | |||
| 92 | TEST_SUMMARY(); | ||
| 93 | } | ||
diff --git a/tests/unit/test_wifi_setup b/tests/unit/test_wifi_setup new file mode 100755 index 0000000..aa0e0b4 --- /dev/null +++ b/tests/unit/test_wifi_setup | |||
| Binary files differ | |||
diff --git a/tests/unit/test_wifi_setup.c b/tests/unit/test_wifi_setup.c new file mode 100644 index 0000000..5f1b8f0 --- /dev/null +++ b/tests/unit/test_wifi_setup.c | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "../../main/wifi_setup.h" | ||
| 3 | #include <string.h> | ||
| 4 | |||
| 5 | int main(void) | ||
| 6 | { | ||
| 7 | printf("=== test_wifi_setup ===\n"); | ||
| 8 | |||
| 9 | wifi_setup_t setup; | ||
| 10 | wifi_setup_init(&setup); | ||
| 11 | |||
| 12 | ASSERT_EQ_INT(SETUP_SCAN, (int)setup.state, "Init state = SCAN"); | ||
| 13 | ASSERT_EQ_INT(0, setup.ap_count, "Init ap_count = 0"); | ||
| 14 | ASSERT_EQ_INT(-1, setup.selected_ap, "Init selected_ap = -1"); | ||
| 15 | |||
| 16 | wifi_ap_info_t test_aps[3] = { | ||
| 17 | {"FastNet", -30, true}, | ||
| 18 | {"SlowNet", -70, true}, | ||
| 19 | {"OpenNet", -50, false}, | ||
| 20 | }; | ||
| 21 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 22 | |||
| 23 | ASSERT_EQ_INT(SETUP_LIST, (int)setup.state, "After set_aps: state = LIST"); | ||
| 24 | ASSERT_EQ_INT(3, setup.ap_count, "After set_aps: ap_count = 3"); | ||
| 25 | ASSERT_EQ_INT(3, wifi_setup_visible_count(&setup), "Visible count = 3"); | ||
| 26 | |||
| 27 | { | ||
| 28 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 0); | ||
| 29 | ASSERT(ap != NULL, "Visible AP 0 is not NULL"); | ||
| 30 | ASSERT_EQ_STR("FastNet", ap->ssid, "AP 0 = FastNet"); | ||
| 31 | ASSERT_EQ_INT(-30, ap->rssi, "AP 0 RSSI = -30"); | ||
| 32 | ASSERT(ap->secured, "AP 0 is secured"); | ||
| 33 | } | ||
| 34 | |||
| 35 | { | ||
| 36 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 2); | ||
| 37 | ASSERT(ap != NULL, "Visible AP 2 is not NULL"); | ||
| 38 | ASSERT_EQ_STR("OpenNet", ap->ssid, "AP 2 = OpenNet"); | ||
| 39 | ASSERT(!ap->secured, "AP 2 is open"); | ||
| 40 | } | ||
| 41 | |||
| 42 | { | ||
| 43 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 3); | ||
| 44 | ASSERT(ap == NULL, "Out of range returns NULL"); | ||
| 45 | } | ||
| 46 | |||
| 47 | setup_state_t s = wifi_setup_handle_select(&setup, 0); | ||
| 48 | ASSERT_EQ_INT(SETUP_PASSWORD, (int)s, "Select AP 0: state = PASSWORD"); | ||
| 49 | ASSERT_EQ_INT(0, setup.selected_ap, "Selected AP index = 0"); | ||
| 50 | ASSERT_EQ_STR("FastNet", setup.selected_ssid, "Selected SSID = FastNet"); | ||
| 51 | |||
| 52 | wifi_setup_handle_connect(&setup); | ||
| 53 | ASSERT_EQ_INT(SETUP_CONNECTING, (int)setup.state, "Connect: state = CONNECTING"); | ||
| 54 | |||
| 55 | s = wifi_setup_handle_connect_result(&setup, true, "192.168.1.42"); | ||
| 56 | ASSERT_EQ_INT(SETUP_SUCCESS, (int)s, "Connect success: state = SUCCESS"); | ||
| 57 | ASSERT_EQ_STR("192.168.1.42", setup.connect_ip, "Connect IP stored"); | ||
| 58 | |||
| 59 | wifi_setup_init(&setup); | ||
| 60 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 61 | wifi_setup_handle_select(&setup, 1); | ||
| 62 | wifi_setup_handle_connect(&setup); | ||
| 63 | |||
| 64 | s = wifi_setup_handle_connect_result(&setup, false, NULL); | ||
| 65 | ASSERT_EQ_INT(SETUP_FAILED, (int)s, "Connect fail: state = FAILED"); | ||
| 66 | ASSERT(setup.connect_failed_auth, "Failed auth flag set"); | ||
| 67 | |||
| 68 | s = wifi_setup_handle_retry(&setup); | ||
| 69 | ASSERT_EQ_INT(SETUP_PASSWORD, (int)s, "Retry: state = PASSWORD"); | ||
| 70 | ASSERT(!setup.connect_failed_auth, "Retry clears auth flag"); | ||
| 71 | |||
| 72 | wifi_setup_init(&setup); | ||
| 73 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 74 | wifi_setup_handle_select(&setup, 0); | ||
| 75 | wifi_setup_handle_connect(&setup); | ||
| 76 | wifi_setup_handle_connect_result(&setup, false, NULL); | ||
| 77 | |||
| 78 | s = wifi_setup_handle_change_network(&setup); | ||
| 79 | ASSERT_EQ_INT(SETUP_LIST, (int)s, "Change network: state = LIST"); | ||
| 80 | |||
| 81 | wifi_setup_init(&setup); | ||
| 82 | s = wifi_setup_handle_cancel(&setup); | ||
| 83 | ASSERT_EQ_INT(SETUP_CANCELLED, (int)s, "Cancel: state = CANCELLED"); | ||
| 84 | |||
| 85 | wifi_setup_init(&setup); | ||
| 86 | wifi_ap_info_t many_aps[12]; | ||
| 87 | for (int i = 0; i < 12; i++) { | ||
| 88 | snprintf(many_aps[i].ssid, sizeof(many_aps[i].ssid), "Net%d", i); | ||
| 89 | many_aps[i].rssi = -30 - i * 5; | ||
| 90 | many_aps[i].secured = true; | ||
| 91 | } | ||
| 92 | wifi_setup_set_aps(&setup, many_aps, 12); | ||
| 93 | ASSERT_EQ_INT(12, setup.ap_count, "12 APs stored"); | ||
| 94 | ASSERT_EQ_INT(8, wifi_setup_visible_count(&setup), "Only 8 visible"); | ||
| 95 | |||
| 96 | { | ||
| 97 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 7); | ||
| 98 | ASSERT(ap != NULL, "8th visible AP exists"); | ||
| 99 | ASSERT_EQ_STR("Net7", ap->ssid, "8th visible = Net7"); | ||
| 100 | } | ||
| 101 | |||
| 102 | wifi_setup_init(&setup); | ||
| 103 | s = wifi_setup_handle_select(&setup, 0); | ||
| 104 | ASSERT_EQ_INT(SETUP_SCAN, (int)s, "Select in SCAN state = no change"); | ||
| 105 | |||
| 106 | s = wifi_setup_handle_select(&setup, -1); | ||
| 107 | ASSERT_EQ_INT(SETUP_SCAN, (int)s, "Select invalid idx = no change"); | ||
| 108 | |||
| 109 | wifi_setup_init(&setup); | ||
| 110 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 111 | s = wifi_setup_handle_retry(&setup); | ||
| 112 | ASSERT_EQ_INT(SETUP_LIST, (int)s, "Retry in LIST state = no change"); | ||
| 113 | |||
| 114 | s = wifi_setup_handle_change_network(&setup); | ||
| 115 | ASSERT_EQ_INT(SETUP_LIST, (int)s, "Change network in LIST state = no change"); | ||
| 116 | |||
| 117 | ASSERT_EQ_INT(0, wifi_setup_visible_count(NULL), "NULL setup returns 0"); | ||
| 118 | ASSERT(NULL == wifi_setup_get_visible(NULL, 0), "NULL setup returns NULL AP"); | ||
| 119 | |||
| 120 | TEST_SUMMARY(); | ||
| 121 | } | ||