'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const root = path.resolve(__dirname, '..', '..'); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); // --radius-avatar is the single source of truth for frontend avatar shape, // except the two large hero frames on the user center and the public user // home, which intentionally keep their legacy rounded-square look. Route CSS // loads after the public bundle and ties the primitive at 0,1,1 specificity, // so each frame must declare its own radius to win the cascade. const primitiveSelectors = [ 'img.avatar,', 'img.avatar-mini,', 'img.avatar-md,', 'img.avatar-lg,', '.avatar-img img {', ]; test('the global avatar shape primitive renders every avatar image circular', () => { const css = read('assets/css/core/components-ext.css'); const start = css.indexOf('img.avatar,'); assert.notEqual(start, -1, 'missing the global avatar shape primitive'); const block = css.slice(start, css.indexOf('}', start)); for (const selector of primitiveSelectors) { assert.ok(block.includes(selector), `primitive is missing selector: ${selector}`); } assert.match(block, /border-radius:\s*var\(--radius-avatar,\s*50%\)/); assert.match(block, /object-fit:\s*cover/); // The primitive must follow the existing avatar size system in the same file. const sizeSystem = css.indexOf('.aksrt-avatar--xs {'); assert.ok(sizeSystem >= 0 && sizeSystem < start, 'primitive must sit after the avatar size system'); }); test('the user center and public user home keep their legacy rounded-square avatar frames', () => { const profile = read('assets/css/routes/profile/layout.css'); const userHome = read('assets/css/routes/user-home.css'); // Both frames must keep an explicit radius: the global primitive ties them at // 0,1,1 specificity, so only the later route rule keeps them square. assert.match(profile, /\.uc-header__avatar\s*\{[^}]*border-radius:\s*6px/s); assert.doesNotMatch(profile, /\.uc-header__avatar\s*\{[^}]*--radius-avatar/s); // img and the upload overlay both inherit the frame radius. assert.match(profile, /\.uc-header__avatar img\s*\{[^}]*border-radius:\s*inherit/s); assert.match(userHome, /\.aksrt-author-hero__avatar img\s*\{[^}]*border-radius:\s*var\(--radius\)/s); assert.doesNotMatch(userHome, /\.aksrt-author-hero__avatar img\s*\{[^}]*--radius-avatar/s); }); test('every other frontend avatar frame is circular', () => { const friends = read('assets/css/routes/friends.css'); assert.match(friends, /\.friend-card__avatar\s*\{[^}]*border-radius:\s*var\(--radius-avatar,\s*50%\)/s); assert.doesNotMatch(friends, /\.friend-card__avatar\s*\{[^}]*border-radius:\s*var\(--radius\)/s); }); test('the user card name row can shrink once the VIP icon joins the badge row', () => { const base = read('assets/css/frontend/base.css'); const start = base.indexOf('.user-card.author .user-name > name > .display-name,'); assert.notEqual(start, -1, 'missing the display-name shrink rule'); const block = base.slice(start, base.indexOf('}', start)); assert.ok(block.includes('.user-card.widget .user-name > name > .display-name'), 'shrink rule must cover the widget card too'); assert.match(block, /min-width:\s*0/); assert.match(block, /overflow:\s*hidden/); assert.match(block, /text-overflow:\s*ellipsis/); }); test('the VIP modal avatar carries the canonical avatar class', () => { const payVip = read('assets/js/pay-vip.js'); assert.match(payVip, /imageNode\(text\(member\.avatar_url\), "avatar"\)/); assert.doesNotMatch(payVip, /var avatar = node\("img"\)/); }); test('the VIP icon leaves the avatar corner and leads the badge row', () => { const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php'); const singleRender = read('inc/helpers/aksrt-single-render.php'); const signin = read('src/Credits/Signin.php'); for (const [file, source] of [['UserRenderer.php', userRenderer], ['aksrt-single-render.php', singleRender]]) { assert.match(source, /aksrt_get_avatar_box\([^)]*'avatar-img avatar-lg',\s*true,\s*false\)/, `${file} must drop the avatar corner badge`); assert.match(source, /'vip'\s+=>\s+true/, `${file} must render the VIP icon in the name row`); assert.doesNotMatch(source, /'vip'\s+=>\s+false/); } assert.match(signin, /aksrt_get_avatar_box\(\$user_id,\s*'avatar-img avatar-mini mr6',\s*false,\s*false\)/); assert.match(signin, /aksrt_get_user_ban_name_badge\(\$user_id\)/); assert.match(signin, /aksrt_get_vip_icon\(\$user_id\)/); assert.match(signin, /\$avatar_img \. esc_html\(\$user->display_name\) \. \$name_badge/); }); test('no frontend call site opts into the avatar corner VIP badge', () => { const offenders = []; const skip = new Set(['node_modules', 'vendor', '.git', 'tests', 'tools']); const walk = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (skip.has(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); continue; } if (!/\.(php|js)$/.test(entry.name) || entry.name.endsWith('.min.js')) continue; const source = fs.readFileSync(full, 'utf8'); const pattern = /aksrt_get_avatar_box\([^;]*?,\s*(?:true|false),\s*true\s*\)/g; for (const match of source.matchAll(pattern)) { offenders.push(`${path.relative(root, full).split(path.sep).join('/')}: ${match[0]}`); } } }; walk(root); assert.deepEqual(offenders, [], 'aksrt_get_avatar_box() must no longer mount a badge on the avatar'); }); test('the avatar helper keeps its corner badge contract for callers that still want it', () => { const avatar = read('inc/helpers/aksrt-avatar.php'); assert.match(avatar, /function aksrt_get_avatar_box\(\$id_or_email, string \$class = 'avatar-img', bool \$link = true, bool \$vip = true\)/); assert.match(avatar, /class="avatar-badge"/); }); test('the built public bundle carries the minified primitive', () => { const bundle = read('assets/css/build/public.min.css'); // esbuild keeps the fallback list spacing inside var() untouched. const minified = 'img.avatar,img.avatar-mini,img.avatar-md,img.avatar-lg,.avatar-img img{border-radius:var(--radius-avatar, 50%);object-fit:cover}'; assert.notEqual(bundle.indexOf(minified), -1, 'missing the minified avatar shape primitive'); });