const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); function read(relativePath) { return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8'); } test('内容发布页在已选择家谱时不再显示“请先选择家谱”的静态误导提示', () => { const source = read('profile-content.html'); assert.doesNotMatch(source, /请先选择家谱,再进入对应模块/); }); test('会员入口直接定位到会员服务区,不改变通用服务中心语义', () => { const familyHome = read('profile-family-home.html'); const profile = read('profile.html'); const services = read('profile-services.html'); assert.match(familyHome, /

会员与订单<\/h3>/); assert.match(familyHome, /href="profile-services\.html#membership"[^>]*data-genealogy-context-link/); assert.match(profile, /href="profile-services\.html#membership">会员服务<\/strong>/); assert.match(services, /帮助与服务 - 个人中心 - 代代相传<\/title>/); assert.match(services, /<h1>帮助与服务<\/h1>/); assert.match(services, /<section class="module-panel" id="membership" tabindex="-1">\s*<h2>会员服务<\/h2>/); }); test('家谱工作台不混用英文模块眉题', () => { assert.doesNotMatch(read('profile-tree.html'), />Tree Mode</); assert.doesNotMatch(read('profile-family-admin.html'), />Family Admin</); }); test('世系维护先选择成员再展示关系操作,且字辈入口名称与目标一致', () => { const source = read('profile-tree.html'); const script = read('public/js/lineage-pages.js'); const selectorIndex = source.indexOf('data-lineage-person-options'); const actionIndex = source.indexOf('data-lineage-relation="parents"'); assert.ok(selectorIndex > 0 && selectorIndex < actionIndex, '成员选择器应位于关系操作之前'); assert.match(source, /href="profile-generation\.html"[^>]*data-genealogy-context-link[^>]*>\s*管理字辈\s*<\/a/); assert.doesNotMatch(source, />\s*管理世代\s*<\/a/); assert.match(script, /queryAll\('\[data-lineage-relation\]'\)[\s\S]*?control\.disabled = writeDisabled \|\| !selectedPersonId/); }); test('个人工作台使用紧凑首屏和高对比度导航', () => { const publicCss = read('public/css/public.css'); const moduleCss = read('public/css/profile-module.css'); assert.match(publicCss, /\.nav-links\s*\{[^}]*color:\s*#fffdf8;/); assert.match(publicCss, /\.nav-actions\s*\{[^}]*color:\s*#fffdf8;/); assert.match(publicCss, /\.nav-links a,\s*\.nav-actions a\s*\{[^}]*color:\s*inherit;/); assert.match(moduleCss, /\.page-profile-module \.nav\s*\{\s*height:\s*72px;/); assert.match(moduleCss, /\.page-profile-module \.section\s*\{\s*padding:\s*var\(--space-7\) 0;/); assert.match(moduleCss, /\.module-hero\s*\{[^}]*height:\s*168px;[^}]*min-height:\s*168px;/); }); test('手机菜单按 Escape 关闭后把焦点还给菜单按钮', () => { const source = read('public/js/profile-common.js'); assert.match(source, /event\.key === 'Escape'[\s\S]*?setMobileMenuOpen\(false\);[\s\S]*?\$\('\[data-profile-mobile-menu-toggle\]'\)\.first\(\)\.trigger\('focus'\)/); }); test('公共页面页脚的栏目链接都有真实跳转地址', () => { [ 'index.html', 'create-genealogy.html', 'about.html', 'app.html', 'help.html', 'genealogy.html', 'plaza.html', 'surname.html', 'culture.html', 'promo-album.html', 'promo-video.html' ].forEach((page) => { const source = read(page); const footerMatch = source.match(/<footer[\s\S]*?<\/footer>/); assert.ok(footerMatch, `${page} 缺少页脚`); assert.doesNotMatch( footerMatch[0], /<a(?:\s+class="[^"]*")?>\s*(?:数字家谱|家谱广场|应用下载|帮助中心)\s*<\/a>/, `${page} 页脚存在无 href 的伪链接` ); }); }); test('公共导航提供统一的登录态切换脚本', () => { const script = read('public/js/page-effects.js'); assert.match(script, /genealogy_auth_token/); assert.match(script, /profile\.html/); assert.match(script, /个人中心/); assert.match(script, /data-site-menu-toggle/); assert.match(script, /data-site-mobile-menu/); }); test('用户页面不暴露占位链接、假联系方式和开发阶段说明', () => { const htmlFiles = fs.readdirSync(path.resolve(__dirname, '..')) .filter((file) => file.endsWith('.html')); htmlFiles.forEach((file) => { const source = read(file); const visibleSource = source.replace(/<!--[\s\S]*?-->/g, ''); assert.doesNotMatch(source, /https?:\/\/example\.com/i, `${file} 仍包含占位外链`); assert.doesNotMatch(source, /400-000-0000|service@example\.com/i, `${file} 仍包含假联系方式`); assert.doesNotMatch(source, /实际项目可替换|后续可以接|前台先展示/, `${file} 仍包含开发阶段说明`); assert.doesNotMatch(source, /承接 APP|整合 APP|对应 PC|当前 PC|PC 的“|真实接口|接口为准/, `${file} 仍包含实现口吻`); assert.doesNotMatch(visibleSource, /YAML|OpenAPI|DTO|OSS ID|PC 反馈接口|PC 契约|资料接口/, `${file} 仍向用户暴露开发术语`); }); }); test('页面眉题统一使用中文产品语言', () => { const htmlFiles = fs.readdirSync(path.resolve(__dirname, '..')) .filter((file) => file.endsWith('.html')); htmlFiles.forEach((file) => { const source = read(file); const kickers = [...source.matchAll(/<div class="[^"]*(?:eyebrow|kicker)[^"]*">\s*([^<]+?)\s*<\/div>/g)] .map((match) => match[1].trim()); kickers.forEach((kicker) => { assert.doesNotMatch(kicker, /^[A-Za-z][A-Za-z ]*$/, `${file} 的眉题仍是纯英文:${kicker}`); }); }); }); test('所有静态站内链接都指向存在的页面和锚点', () => { const projectRoot = path.resolve(__dirname, '..'); const htmlFiles = fs.readdirSync(projectRoot).filter((file) => file.endsWith('.html')); htmlFiles.forEach((file) => { const source = read(file); const hrefs = [...source.matchAll(/\shref="([^"]+)"/g)].map((match) => match[1]); hrefs.forEach((href) => { if (/^(?:https?:|mailto:|tel:|javascript:)/i.test(href)) return; assert.notEqual(href, '#', `${file} 存在不能导航的空锚点`); const targetUrl = new URL(href, `http://jiapu.local/${file}`); const targetName = decodeURIComponent(targetUrl.pathname.replace(/^\//, '')) || 'index.html'; const targetPath = path.resolve(projectRoot, targetName); assert.ok(targetPath.startsWith(projectRoot + path.sep), `${file} 链接越出项目目录:${href}`); assert.ok(fs.existsSync(targetPath), `${file} 链接到不存在的页面:${href}`); if (targetUrl.hash) { const targetSource = fs.readFileSync(targetPath, 'utf8'); const anchor = decodeURIComponent(targetUrl.hash.slice(1)); assert.ok( targetSource.includes(`id="${anchor}"`) || targetSource.includes(`name="${anchor}"`), `${file} 链接到不存在的锚点:${href}` ); } }); }); }); test('个人中心双列表单从顶部对齐,带辅助按钮的字段不会拉高相邻输入框', () => { const source = read('public/css/profile-module.css'); assert.match(source, /\.editor-two\s*\{[^}]*align-items:\s*start;/); }); test('安全设置只在页头保留退出登录,注销区只处理注销流程', () => { const source = read('profile-security.html'); const logoutControls = source.match(/data-security-logout/g) || []; assert.equal(logoutControls.length, 1); assert.match(source, /class="module-panel security-danger"[\s\S]*?class="btn danger[^\"]*"[^>]*data-security-submit/); }); test('推广收益使用项目统一上传控件样式', () => { const source = read('profile-earnings.html'); assert.match(source, /id="payoutQrFile"[^>]*class="upload-input"/); }); test('全量浏览器审计会把缺失 Mock 当成失败,而不是截图后继续通过', () => { const source = read('tests/browser-click-smoke.mjs'); assert.match(source, /mockTransportErrors:\s*bodyText\.split\(String\.fromCharCode\(10\)\)[\s\S]*?includes\('browser mock missing:'\)/); assert.match(source, /type:\s*'browser-mock-missing'/); }); test('浏览器测试数据不再把广告图当作家谱内容图', () => { const source = read('tests/browser-click-smoke.mjs'); assert.doesNotMatch(source, /localImage\('ad-/); }); test('个人中心列表按钮去除浏览器默认描边,删除操作使用统一危险层级', () => { const moduleCss = read('public/css/profile-module.css'); const detailScripts = [ 'public/js/article-pages.js', 'public/js/album-pages.js', 'public/js/ceremony-admin-pages.js', 'public/js/growth-pages.js', 'public/js/merit-pages.js', 'public/js/memo-pages.js', 'public/js/relative-pages.js', 'public/js/video-pages.js' ]; assert.match(moduleCss, /\.row-actions button\.pill\s*\{[^}]*border:\s*0;[^}]*font:\s*inherit;[^}]*cursor:\s*pointer;/); detailScripts.forEach((file) => { assert.doesNotMatch(read(file), /class="btn ghost"[^>]*data-[a-z-]*delete/); }); assert.doesNotMatch(read('public/js/feed-pages.js'), /class="pill"[^>]*data-feed-(?:comment-)?delete/); }); test('手机端列表操作提供可靠触控尺寸', () => { const source = read('public/css/profile-module.css'); assert.match(source, /@media \(max-width: 720px\)[\s\S]*?\.row-actions \.pill\s*\{[^}]*min-height:\s*44px;/); }); test('同页记录详情会保留选中编号并把焦点移到详情标题', () => { const flows = [ ['profile-growth.html', 'public/js/growth-pages.js', 'data-growth-detail-heading', 'buildListUrl'], ['profile-memo.html', 'public/js/memo-pages.js', 'data-memo-detail-heading', 'buildListUrl'], ['profile-merit.html', 'public/js/merit-pages.js', 'data-merit-detail-heading', 'buildListUrl'], ['profile-relative.html', 'public/js/relative-pages.js', 'data-relative-detail-heading', 'buildListUrl'] ]; assert.match(read('public/js/profile-common.js'), /function revealDetail\([^)]+\)[\s\S]*?scrollIntoView[\s\S]*?focus/); flows.forEach(([page, script, heading, listUrlBuilder]) => { assert.match(read(page), new RegExp(`tabindex="-1" ${heading}`)); assert.match(read(script), new RegExp(`history\\.replaceState\\(null, '', ${listUrlBuilder}`)); assert.match(read(script), new RegExp(`ProfileUI\\.revealDetail\\(query\\('\\[${heading}\\]'\\)\\)`)); }); }); test('动态编辑页按新建和编辑状态使用一致文案', () => { const page = read('profile-feed-edit.html'); const script = read('public/js/feed-pages.js'); assert.match(page, /data-feed-editor-kicker>发布动态</); assert.match(page, /data-feed-editor-title>发布动态</); assert.match(script, /var editorTitle = isEditing \? '编辑动态' : '发布动态';/); assert.match(script, /button\.textContent = isEditing \? '保存修改' : '发布动态';/); }); test('贺礼邀请使用明确的关联活动入口', () => { const source = read('profile-gift.html'); assert.match(source, /<h2>关联活动<\/h2>/); assert.match(source, />管理关联活动<\/a>/); });