Files
2026-09-03 08:38:17 +08:00

83 lines
4.0 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { parse, compileTemplate } from 'vue/compiler-sfc';
import ts from 'typescript';
const root = fileURLToPath(new URL('../src/', import.meta.url));
const source = readFileSync(join(root, 'utils/icon-mask.ts'), 'utf8');
const javascript = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 },
}).outputText;
const { ICON_PATHS, createIconMask } = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString('base64')}`);
const component = parse(readFileSync(join(root, 'components/AppIcon.vue'), 'utf8')).descriptor;
function nodes(node) {
return [node, ...(node.children ?? []).flatMap(nodes)];
}
function vueFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((item) => {
const file = join(dir, item.name);
return item.isDirectory() ? vueFiles(file) : file.endsWith('.vue') ? [file] : [];
});
}
function decodeMask(mask) {
assert.ok(mask.startsWith('url("data:image/svg+xml;charset=utf-8,'));
assert.ok(mask.endsWith('")'));
return decodeURIComponent(mask.slice(mask.indexOf(',') + 1, -2));
}
test('AppIcon renders a uni-app view, never unsupported raw svg/path elements', () => {
const elements = nodes(component.template.ast).filter((node) => node.type === 1);
assert.deepEqual(elements.map((node) => node.tag), ['view']);
const result = compileTemplate({ source: component.template.content, filename: 'AppIcon.vue', id: 'app-icon' });
assert.deepEqual(result.errors, []);
assert.doesNotMatch(result.code, /resolveComponent\(["'](?:svg|path)["']\)/);
assert.match(component.scriptSetup.content, /'-webkit-mask-image': mask/);
assert.match(component.scriptSetup.content, /'mask-image': mask/);
assert.match(component.styles[0].content, /background-color:\s*currentColor/);
assert.match(component.styles[0].content, /-webkit-mask-size:\s*100% 100%/);
});
test('every icon is self-contained with explicit SVG image styling', () => {
assert.ok(Object.keys(ICON_PATHS).length >= 35);
for (const [name, paths] of Object.entries(ICON_PATHS)) {
const svg = decodeMask(createIconMask(name));
assert.match(svg, /xmlns="http:\/\/www.w3.org\/2000\/svg"/);
assert.match(svg, /viewBox="0 0 24 24"/);
assert.match(svg, /fill="none" stroke="#000" stroke-width="1.8"/);
assert.equal((svg.match(/<path /g) || []).length, paths.length, name);
for (const path of paths) assert.ok(svg.includes(`d="${path}"`));
assert.doesNotMatch(svg, /(?:href|src)=|<script|currentColor/, name);
}
});
test('filled state, custom stroke widths, and unknown names stay usable', () => {
assert.match(decodeMask(createIconMask('heart', true, 2.4)), /fill="#000" stroke="#000" stroke-width="2.4"/);
assert.notEqual(createIconMask('heart'), createIconMask('heart', true));
assert.match(decodeMask(createIconMask('add', false, NaN)), /stroke-width="1.8"/);
assert.match(decodeMask(createIconMask('add', false, 999)), /stroke-width="8"/);
for (const name of ['unknown', '__proto__', 'constructor']) {
assert.equal(createIconMask(name), createIconMask('sparkles'));
}
});
test('all static AppIcon usages and bottom navigation names have real paths', () => {
let checked = 0;
for (const file of vueFiles(root)) {
const template = parse(readFileSync(file, 'utf8')).descriptor.template;
if (!template) continue;
for (const node of nodes(template.ast)) {
if (node.type !== 1 || node.tag !== 'AppIcon') continue;
const name = node.props.find((prop) => prop.type === 6 && prop.name === 'name')?.value?.content;
if (name) { assert.ok(Object.hasOwn(ICON_PATHS, name), `${file}: ${name}`); checked++; }
}
}
assert.ok(checked > 25);
for (const name of ['home', 'message', 'add', 'compass', 'user', 'search', 'pin', 'heart', 'back', 'more', 'smile', 'voice']) {
assert.ok(ICON_PATHS[name]?.length, name);
}
});