// Step 10.17: Leaderboard UI + nav links on all logged-in pages. const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { setup, teardown, cleanup } = require('../helpers'); const PUBLIC_DIR = path.resolve(__dirname, '../../public'); // ─── Helpers ───────────────────────────────────────────────────────────── function extractLinks(html) { const links = []; const linkRegex = /]*href=["']([^"']*)["'][^>]*>([^<]*)<\/a>/gi; let match; while ((match = linkRegex.exec(html)) !== null) { links.push({ href: match[1], text: match[2].trim() }); } return links; } async function registerAndLogin(baseUrl, username, password) { await fetch(`${baseUrl}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const res = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200); const json = await res.json(); assert.equal(json.ok, true); const setCookie = res.headers.get('set-cookie'); assert.ok(setCookie, 'Login should set a session cookie'); const cookie = setCookie.split(';')[0]; return cookie; } function authHeaders(cookie) { return { Cookie: cookie }; } // ─── DOM-free HTML structure tests ─────────────────────────────────────── describe('nav links in logged-in pages', () => { const navLinks = [ { href: '/game.html', text: 'Game' }, { href: '/shop.html', text: 'Shop' }, { href: '/leaderboard.html', text: 'Leaderboard' }, { href: '/api/auth/logout', text: 'Logout' }, ]; function checkNavLinks(filename) { const html = fs.readFileSync(path.join(PUBLIC_DIR, filename), 'utf-8'); const links = extractLinks(html); for (const expected of navLinks) { assert.ok( links.some((l) => l.href === expected.href && l.text === expected.text), `${filename} should link to ${expected.href}` ); } } it('game.html has nav links: Game, Shop, Leaderboard, Logout', () => { checkNavLinks('game.html'); }); it('shop.html has nav links: Game, Shop, Leaderboard, Logout', () => { checkNavLinks('shop.html'); }); it('create-hero.html has nav links: Game, Shop, Leaderboard, Logout', () => { checkNavLinks('create-hero.html'); }); it('leaderboard.html has nav links: Game, Shop, Leaderboard, Logout', () => { checkNavLinks('leaderboard.html'); }); }); describe('leaderboard page structure', () => { it('leaderboard.html has Level, Gold, Kills tabs', () => { const html = fs.readFileSync(path.join(PUBLIC_DIR, 'leaderboard.html'), 'utf-8'); assert.ok(html.includes('data-type="level"'), 'should have Level tab'); assert.ok(html.includes('data-type="gold"'), 'should have Gold tab'); assert.ok(html.includes('data-type="kills"'), 'should have Kills tab'); }); it('leaderboard.html has leaderboard table with rank/name/class/value columns', () => { const html = fs.readFileSync(path.join(PUBLIC_DIR, 'leaderboard.html'), 'utf-8'); assert.ok(html.includes('Rank'), 'should have Rank column'); assert.ok(html.includes('Name'), 'should have Name column'); assert.ok(html.includes('Class'), 'should have Class column'); assert.ok(html.includes('id="leaderboard-body"'), 'should have tbody container'); assert.ok(html.includes('id="leaderboard-table"'), 'should have table'); }); it('leaderboard.html loads leaderboard.js', () => { const html = fs.readFileSync(path.join(PUBLIC_DIR, 'leaderboard.html'), 'utf-8'); assert.ok(html.includes('src="/js/leaderboard.js"'), 'should load leaderboard.js'); }); }); describe('leaderboard.js code quality', () => { it('leaderboard.js does not use innerHTML', () => { const js = fs.readFileSync(path.join(PUBLIC_DIR, 'js/leaderboard.js'), 'utf-8'); assert.ok( !js.includes('innerHTML'), 'leaderboard.js must not use innerHTML' ); }); }); describe('leaderboard page API integration', () => { let baseUrl, cookie; before(async () => { baseUrl = await setup(); cookie = await registerAndLogin(baseUrl, 'navtest', 'navtest123'); // Create a character await fetch(`${baseUrl}/api/character`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ name: 'NavHero', class: 'warrior' }), }); }); after(async () => { await teardown(); cleanup(); }); it('leaderboard page returns 200 HTML', async () => { const res = await fetch(`${baseUrl}/leaderboard.html`, { headers: authHeaders(cookie), }); assert.equal(res.status, 200); assert.ok(res.headers.get('content-type')?.includes('text/html')); }); it('leaderboard page shows hero names linking to profile.html', async () => { // The hero names are rendered dynamically by JS, so we verify the // JS code generates the correct profile link format. const js = fs.readFileSync(path.join(PUBLIC_DIR, 'js/leaderboard.js'), 'utf-8'); assert.ok( js.includes('profile.html?name=') && js.includes('encodeURIComponent(entry.name)'), 'leaderboard.js should generate profile links with encodeURIComponent' ); }); it('leaderboard API returns correct data shape', async () => { const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=1`, { headers: authHeaders(cookie), }); assert.equal(res.status, 200); const json = await res.json(); assert.equal(json.ok, true); assert.ok(Array.isArray(json.data.entries), 'entries should be an array'); assert.ok(json.data.pagination, 'should have pagination'); assert.equal(typeof json.data.pagination.page, 'number'); assert.equal(typeof json.data.pagination.totalPages, 'number'); // Each entry should have rank, name, class, and the value field if (json.data.entries.length > 0) { const entry = json.data.entries[0]; assert.equal(typeof entry.rank, 'number'); assert.equal(typeof entry.name, 'string'); assert.equal(typeof entry.class, 'string'); assert.equal(typeof entry.level, 'number'); assert.equal(typeof entry.gold, 'number'); assert.equal(typeof entry.kills, 'number'); } }); });