Files
wordpress-theme-aksrt/tests/widget-system/frontend-avatar-shape-contract.test.cjs
T
kate522 a3b708f9d4 fix(frontend): keep user center and user home avatar frames rounded
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.
2026-10-10 13:59:47 +08:00

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');
});