The two large hero avatar frames go back to their legacy rounded-square look: the user center profile header keeps 6px and the public user home hero keeps var(--radius). Both declare an explicit radius because the global avatar primitive (img.avatar, 0,1,1) ties them on specificity and only the later route rule wins the cascade. Everything else stays: the primitive still circles the bare avatar-mini in the ban and report modals, the VIP modal avatar and the post-card meta avatar, the friends page avatar is circular, and the VIP icon still leads the badge row. The contract test now guards the two hero frames as rounded-square so the primitive cannot silently re-circularize them.
129 lines
6.3 KiB
JavaScript
129 lines
6.3 KiB
JavaScript
'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');
|
|
});
|