diff --git a/AGENTS.md b/AGENTS.md index 5cb8243..89b2a6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,9 @@ python3 -m http.server 8080 # Oder mit Live-Reload npx live-server +# Tests ausführen (im Browser) +# Server starten, dann http://localhost:8080/tests/ öffnen + # Git Push (Token in URL) git push origin master ``` @@ -54,7 +57,6 @@ js/ │ ├── categories.js # Kategorien Service (Baum, Übersetzungen) │ ├── locations.js # Standorte Service (Geo-Suche) │ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt) -│ ├── chat.js # LocalStorage Chat (Legacy/Mock) │ ├── crypto.js # NaCl Encryption │ ├── currency.js # XMR/Fiat Umrechnung │ └── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify) @@ -81,6 +83,13 @@ css/ assets/ └── fonts/ # Self-hosted Fonts (Inter, Space Grotesk) +tests/ +├── index.html # Test-Runner UI (im Browser öffnen) +├── test-runner.js # Minimaler Test-Framework +├── helpers.test.js # Unit Tests für helpers.js +├── i18n.test.js # Unit Tests für i18n.js +└── router.test.js # Unit Tests für router.js + locales/ ├── de.json # Deutsch (Fallback) ├── en.json diff --git a/README.md b/README.md index f503f65..c747c0a 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,18 @@ npx live-server Öffne http://localhost:8080 +### Tests ausführen + +```bash +# Server starten +python3 -m http.server 8080 + +# Im Browser öffnen +# http://localhost:8080/tests/ +``` + +Die Tests laufen im Browser und nutzen einen minimalen Test-Runner ohne externe Dependencies. + ### Projektstruktur ``` @@ -129,6 +141,10 @@ dgray/ │ ├── de.json # Deutsch │ ├── en.json # English │ └── fr.json # Français +├── tests/ +│ ├── index.html # Test-Runner UI +│ ├── test-runner.js # Test-Framework +│ └── *.test.js # Unit Tests └── assets/ └── fonts/ # Self-hosted Fonts ``` diff --git a/tests/helpers.test.js b/tests/helpers.test.js new file mode 100644 index 0000000..0bb1b91 --- /dev/null +++ b/tests/helpers.test.js @@ -0,0 +1,157 @@ +/** + * Tests for js/utils/helpers.js + */ + +import { test, describe, assertEquals, assertTrue } from './test-runner.js' +import { escapeHTML, formatPrice, formatRelativeTime, debounce, truncate } from '../js/utils/helpers.js' + +describe('escapeHTML', () => { + test('escapes < and >', () => { + assertEquals(escapeHTML(' + + diff --git a/tests/router.test.js b/tests/router.test.js new file mode 100644 index 0000000..e885f50 --- /dev/null +++ b/tests/router.test.js @@ -0,0 +1,115 @@ +/** + * Tests for js/router.js + */ + +import { test, describe, assertEquals, assertDeepEquals, assertTrue } from './test-runner.js' +import { router } from '../js/router.js' + +describe('router.parseHash', () => { + test('parses simple path', () => { + // Save original hash + const original = window.location.hash + + window.location.hash = '/home' + const result = router.parseHash() + assertEquals(result.path, '/home') + assertDeepEquals(result.params, {}) + + // Restore + window.location.hash = original + }) + + test('parses path with query params', () => { + const original = window.location.hash + + window.location.hash = '/search?q=test&page=2' + const result = router.parseHash() + assertEquals(result.path, '/search') + assertEquals(result.params.q, 'test') + assertEquals(result.params.page, '2') + + window.location.hash = original + }) + + test('returns / for empty hash', () => { + const original = window.location.hash + + window.location.hash = '' + const result = router.parseHash() + assertEquals(result.path, '/') + + window.location.hash = original + }) +}) + +describe('router.matchRoute', () => { + // Register test routes + router.register('/test', 'test-page') + router.register('/user/:id', 'user-page') + router.register('/post/:id/comment/:commentId', 'comment-page') + + test('matches exact route', () => { + const match = router.matchRoute('/test') + assertEquals(match.componentTag, 'test-page') + assertDeepEquals(match.params, {}) + }) + + test('matches route with single param', () => { + const match = router.matchRoute('/user/123') + assertEquals(match.componentTag, 'user-page') + assertEquals(match.params.id, '123') + }) + + test('matches route with multiple params', () => { + const match = router.matchRoute('/post/456/comment/789') + assertEquals(match.componentTag, 'comment-page') + assertEquals(match.params.id, '456') + assertEquals(match.params.commentId, '789') + }) + + test('returns null for unmatched route', () => { + const match = router.matchRoute('/unknown/path') + assertEquals(match, null) + }) + + test('does not match wrong segment count', () => { + const match = router.matchRoute('/user/123/extra') + assertEquals(match, null) + }) +}) + +describe('router.register', () => { + test('returns router for chaining', () => { + const result = router.register('/chain-test', 'chain-page') + assertEquals(result, router) + }) +}) + +describe('router.getCurrentRoute', () => { + test('returns null before navigation', () => { + // Note: May have a value if other tests triggered navigation + const route = router.getCurrentRoute() + assertTrue(route === null || typeof route === 'object') + }) +}) + +describe('router.navigate', () => { + test('sets hash', () => { + const original = window.location.hash + + router.navigate('/nav-test') + assertTrue(window.location.hash.includes('nav-test')) + + window.location.hash = original + }) + + test('includes query params', () => { + const original = window.location.hash + + router.navigate('/nav-test', { foo: 'bar', num: '42' }) + assertTrue(window.location.hash.includes('foo=bar')) + assertTrue(window.location.hash.includes('num=42')) + + window.location.hash = original + }) +}) diff --git a/tests/test-runner.js b/tests/test-runner.js new file mode 100644 index 0000000..80a35b2 --- /dev/null +++ b/tests/test-runner.js @@ -0,0 +1,145 @@ +/** + * Minimal Test Runner for Browser + * Run tests by opening tests/index.html in browser + */ + +const results = { + passed: 0, + failed: 0, + tests: [] +} + +/** + * Define a test + * @param {string} name - Test name + * @param {Function} fn - Test function + */ +export function test(name, fn) { + try { + fn() + results.passed++ + results.tests.push({ name, status: 'passed' }) + console.log(`✓ ${name}`) + } catch (error) { + results.failed++ + results.tests.push({ name, status: 'failed', error: error.message }) + console.error(`✗ ${name}`) + console.error(` ${error.message}`) + } +} + +/** + * Group tests + * @param {string} name - Group name + * @param {Function} fn - Function containing tests + */ +export function describe(name, fn) { + console.group(name) + fn() + console.groupEnd() +} + +/** + * Assert equality + * @param {*} actual + * @param {*} expected + * @param {string} [message] + */ +export function assertEquals(actual, expected, message = '') { + if (actual !== expected) { + throw new Error( + message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` + ) + } +} + +/** + * Assert truthy + * @param {*} value + * @param {string} [message] + */ +export function assertTrue(value, message = '') { + if (!value) { + throw new Error(message || `Expected truthy value, got ${value}`) + } +} + +/** + * Assert falsy + * @param {*} value + * @param {string} [message] + */ +export function assertFalse(value, message = '') { + if (value) { + throw new Error(message || `Expected falsy value, got ${value}`) + } +} + +/** + * Assert throws + * @param {Function} fn + * @param {string} [message] + */ +export function assertThrows(fn, message = '') { + try { + fn() + throw new Error(message || 'Expected function to throw') + } catch (e) { + if (e.message === (message || 'Expected function to throw')) { + throw e + } + } +} + +/** + * Assert deep equality for objects/arrays + * @param {*} actual + * @param {*} expected + * @param {string} [message] + */ +export function assertDeepEquals(actual, expected, message = '') { + const actualStr = JSON.stringify(actual) + const expectedStr = JSON.stringify(expected) + if (actualStr !== expectedStr) { + throw new Error( + message || `Expected ${expectedStr}, got ${actualStr}` + ) + } +} + +/** + * Get test results + * @returns {Object} + */ +export function getResults() { + return { ...results } +} + +/** + * Render results to DOM + * @param {HTMLElement} container + */ +export function renderResults(container) { + const summary = document.createElement('div') + summary.className = 'test-summary' + summary.innerHTML = ` +

Test Results

+

Passed: ${results.passed}

+

Failed: ${results.failed}

+ ` + + const list = document.createElement('ul') + list.className = 'test-list' + + results.tests.forEach(t => { + const li = document.createElement('li') + li.className = t.status + li.innerHTML = t.status === 'passed' + ? `✓ ${t.name}` + : `✗ ${t.name}
${t.error}` + list.appendChild(li) + }) + + container.appendChild(summary) + container.appendChild(list) +}