test: add browser-based unit tests for helpers, i18n, router

This commit is contained in:
2026-02-05 15:30:58 +01:00
parent bd7a259d72
commit 0b0185deb1
7 changed files with 658 additions and 1 deletions

View File

@@ -29,6 +29,9 @@ python3 -m http.server 8080
# Oder mit Live-Reload # Oder mit Live-Reload
npx live-server npx live-server
# Tests ausführen (im Browser)
# Server starten, dann http://localhost:8080/tests/ öffnen
# Git Push (Token in URL) # Git Push (Token in URL)
git push origin master git push origin master
``` ```
@@ -54,7 +57,6 @@ js/
│ ├── categories.js # Kategorien Service (Baum, Übersetzungen) │ ├── categories.js # Kategorien Service (Baum, Übersetzungen)
│ ├── locations.js # Standorte Service (Geo-Suche) │ ├── locations.js # Standorte Service (Geo-Suche)
│ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt) │ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt)
│ ├── chat.js # LocalStorage Chat (Legacy/Mock)
│ ├── crypto.js # NaCl Encryption │ ├── crypto.js # NaCl Encryption
│ ├── currency.js # XMR/Fiat Umrechnung │ ├── currency.js # XMR/Fiat Umrechnung
│ └── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify) │ └── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify)
@@ -81,6 +83,13 @@ css/
assets/ assets/
└── fonts/ # Self-hosted Fonts (Inter, Space Grotesk) └── 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/ locales/
├── de.json # Deutsch (Fallback) ├── de.json # Deutsch (Fallback)
├── en.json ├── en.json

View File

@@ -93,6 +93,18 @@ npx live-server
Öffne http://localhost:8080 Ö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 ### Projektstruktur
``` ```
@@ -129,6 +141,10 @@ dgray/
│ ├── de.json # Deutsch │ ├── de.json # Deutsch
│ ├── en.json # English │ ├── en.json # English
│ └── fr.json # Français │ └── fr.json # Français
├── tests/
│ ├── index.html # Test-Runner UI
│ ├── test-runner.js # Test-Framework
│ └── *.test.js # Unit Tests
└── assets/ └── assets/
└── fonts/ # Self-hosted Fonts └── fonts/ # Self-hosted Fonts
``` ```

157
tests/helpers.test.js Normal file
View File

@@ -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('<script>'), '&lt;script&gt;')
})
test('escapes quotes', () => {
assertEquals(escapeHTML('"hello"'), '&quot;hello&quot;')
})
test('escapes ampersand', () => {
assertEquals(escapeHTML('a & b'), 'a &amp; b')
})
test('escapes single quotes', () => {
assertEquals(escapeHTML("it's"), "it&#039;s")
})
test('handles null', () => {
assertEquals(escapeHTML(null), '')
})
test('handles undefined', () => {
assertEquals(escapeHTML(undefined), '')
})
test('handles numbers by converting to string', () => {
assertEquals(escapeHTML(123), '123')
})
test('handles complex XSS attempt', () => {
const xss = '<img src="x" onerror="alert(1)">'
assertTrue(!escapeHTML(xss).includes('<'))
})
})
describe('formatPrice', () => {
test('formats EUR correctly', () => {
assertEquals(formatPrice(99.5, 'EUR'), '€ 99.50')
})
test('formats USD correctly', () => {
assertEquals(formatPrice(99.5, 'USD'), '$ 99.50')
})
test('formats CHF correctly', () => {
assertEquals(formatPrice(99.5, 'CHF'), 'CHF 99.50')
})
test('formats XMR with 4 decimals', () => {
assertEquals(formatPrice(0.5, 'XMR'), '0.5000 ɱ')
})
test('handles null price', () => {
assertEquals(formatPrice(null, 'EUR'), '')
})
test('handles undefined price', () => {
assertEquals(formatPrice(undefined, 'EUR'), '')
})
test('defaults to EUR', () => {
assertEquals(formatPrice(10), '€ 10.00')
})
test('handles unknown currency', () => {
assertEquals(formatPrice(10, 'GBP'), 'GBP 10.00')
})
})
describe('formatRelativeTime', () => {
test('formats seconds ago', () => {
const date = new Date(Date.now() - 30000) // 30 seconds ago
const result = formatRelativeTime(date, 'en')
assertTrue(result.includes('second'))
})
test('formats minutes ago', () => {
const date = new Date(Date.now() - 5 * 60000) // 5 minutes ago
const result = formatRelativeTime(date, 'en')
assertTrue(result.includes('minute'))
})
test('formats hours ago', () => {
const date = new Date(Date.now() - 2 * 3600000) // 2 hours ago
const result = formatRelativeTime(date, 'en')
assertTrue(result.includes('hour'))
})
test('formats days ago', () => {
const date = new Date(Date.now() - 3 * 86400000) // 3 days ago
const result = formatRelativeTime(date, 'en')
assertTrue(result.includes('day'))
})
test('accepts string date', () => {
const date = new Date(Date.now() - 60000).toISOString()
const result = formatRelativeTime(date, 'en')
assertTrue(result.includes('minute'))
})
test('respects locale de', () => {
const date = new Date(Date.now() - 60000)
const result = formatRelativeTime(date, 'de')
assertTrue(result.includes('Minute'))
})
})
describe('truncate', () => {
test('truncates long strings', () => {
assertEquals(truncate('Hello World', 6), 'Hello…')
})
test('does not truncate short strings', () => {
assertEquals(truncate('Hi', 10), 'Hi')
})
test('handles exact length', () => {
assertEquals(truncate('Hello', 5), 'Hello')
})
test('handles null', () => {
assertEquals(truncate(null, 10), null)
})
test('handles empty string', () => {
assertEquals(truncate('', 10), '')
})
test('uses default maxLength of 100', () => {
const long = 'a'.repeat(150)
const result = truncate(long)
assertEquals(result.length, 100)
assertTrue(result.endsWith('…'))
})
})
describe('debounce', () => {
test('returns a function', () => {
const fn = debounce(() => {})
assertEquals(typeof fn, 'function')
})
test('delays execution', (done) => {
let called = false
const fn = debounce(() => { called = true }, 50)
fn()
assertEquals(called, false)
})
})

91
tests/i18n.test.js Normal file
View File

@@ -0,0 +1,91 @@
/**
* Tests for js/i18n.js
*/
import { test, describe, assertEquals, assertTrue } from './test-runner.js'
import { i18n, t } from '../js/i18n.js'
describe('i18n.getNestedValue', () => {
const obj = {
home: {
title: 'Welcome',
nested: {
deep: 'Deep Value'
}
},
simple: 'Simple Value'
}
test('gets top-level value', () => {
assertEquals(i18n.getNestedValue(obj, 'simple'), 'Simple Value')
})
test('gets nested value', () => {
assertEquals(i18n.getNestedValue(obj, 'home.title'), 'Welcome')
})
test('gets deeply nested value', () => {
assertEquals(i18n.getNestedValue(obj, 'home.nested.deep'), 'Deep Value')
})
test('returns undefined for missing key', () => {
assertEquals(i18n.getNestedValue(obj, 'missing'), undefined)
})
test('returns undefined for missing nested key', () => {
assertEquals(i18n.getNestedValue(obj, 'home.missing'), undefined)
})
})
describe('i18n.t (translation)', () => {
// Note: These tests require translations to be loaded
// They test the parameter interpolation logic
test('returns key if translation missing', () => {
const result = i18n.t('this.key.does.not.exist')
assertEquals(result, 'this.key.does.not.exist')
})
})
describe('i18n.getLocale', () => {
test('returns current locale', () => {
const locale = i18n.getLocale()
assertTrue(['de', 'en', 'fr'].includes(locale))
})
})
describe('i18n.getSupportedLocales', () => {
test('returns array of supported locales', () => {
const locales = i18n.getSupportedLocales()
assertTrue(Array.isArray(locales))
assertTrue(locales.includes('de'))
assertTrue(locales.includes('en'))
assertTrue(locales.includes('fr'))
})
})
describe('i18n.getLocaleDisplayName', () => {
test('returns Deutsch for de', () => {
assertEquals(i18n.getLocaleDisplayName('de'), 'Deutsch')
})
test('returns English for en', () => {
assertEquals(i18n.getLocaleDisplayName('en'), 'English')
})
test('returns Français for fr', () => {
assertEquals(i18n.getLocaleDisplayName('fr'), 'Français')
})
test('returns locale code for unknown locale', () => {
assertEquals(i18n.getLocaleDisplayName('xx'), 'xx')
})
})
describe('i18n.subscribe', () => {
test('returns unsubscribe function', () => {
const unsubscribe = i18n.subscribe(() => {})
assertEquals(typeof unsubscribe, 'function')
unsubscribe()
})
})

124
tests/index.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dgray.io - Tests</title>
<style>
:root {
--color-bg: #1a1a1a;
--color-text: #f0f0f0;
--color-passed: #4ade80;
--color-failed: #f87171;
--color-border: #333;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
padding: 2rem;
line-height: 1.6;
}
h1 {
margin-bottom: 1rem;
font-size: 1.5rem;
}
h2 {
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
.test-summary {
background: #222;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.test-summary .passed {
color: var(--color-passed);
}
.test-summary .failed {
color: var(--color-failed);
}
.test-list {
list-style: none;
}
.test-list li {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.test-list li.passed {
color: var(--color-passed);
}
.test-list li.failed {
color: var(--color-failed);
}
.test-list li small {
display: block;
color: #888;
font-family: monospace;
margin-top: 0.25rem;
}
#results {
max-width: 800px;
margin: 0 auto;
}
.loading {
color: #888;
}
</style>
</head>
<body>
<div id="results">
<h1>🧪 dgray.io Test Suite</h1>
<p class="loading">Running tests...</p>
</div>
<script type="module">
import { renderResults } from './test-runner.js'
const container = document.getElementById('results')
// Run all test files
async function runTests() {
try {
await import('./helpers.test.js')
await import('./i18n.test.js')
await import('./router.test.js')
// Clear loading message and render results
container.querySelector('.loading').remove()
renderResults(container)
} catch (error) {
container.innerHTML = `
<h1>🧪 dgray.io Test Suite</h1>
<p style="color: var(--color-failed)">
Error loading tests: ${error.message}
</p>
<pre style="color: #888; font-size: 0.8rem; margin-top: 1rem;">${error.stack}</pre>
`
}
}
runTests()
</script>
</body>
</html>

115
tests/router.test.js Normal file
View File

@@ -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
})
})

145
tests/test-runner.js Normal file
View File

@@ -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 = `
<h2>Test Results</h2>
<p class="passed">Passed: ${results.passed}</p>
<p class="failed">Failed: ${results.failed}</p>
`
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}<br><small>${t.error}</small>`
list.appendChild(li)
})
container.appendChild(summary)
container.appendChild(list)
}