const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const projectRoot = path.join(__dirname, '..'); function loadProfileUI() { const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8'); const window = { jQuery(value) { if (typeof value === 'function') return undefined; return {}; } }; window.window = window; vm.runInNewContext(source, { window, URLSearchParams }); return window.ProfileUI; } function loadProfileStateWithoutJquery() { const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8'); const window = {}; window.window = window; vm.runInNewContext(source, { window, URLSearchParams }); return window.ProfileUI; } function loadProfileUIWithLinks(links, search) { const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8'); const window = { location: { search } }; function wrapLink(link) { return { is(selector) { return selector === '[data-genealogy-context-link]' && link.contextLink === true; }, attr(name, value) { if (value === undefined) return link[name]; link[name] = value; return this; } }; } function wrapLinks(items) { return { filter(callback) { return wrapLinks(items.filter((link) => callback.call(link))); }, each(callback) { items.forEach((link) => callback.call(link)); return this; } }; } window.jQuery = function (value) { if (typeof value === 'function') return undefined; if (typeof value === 'string') { assert.equal(value, '[data-genealogy-context-link], a[href]'); return wrapLinks(links); } return wrapLink(value); }; window.window = window; vm.runInNewContext(source, { window, URLSearchParams }); return window.ProfileUI; } test('公共业务状态统一输出加载、空数据、失败和无权限语义', () => { const ui = loadProfileUI(); assert.match(ui.renderApiState('loading', '正在加载'), /data-api-state="loading"[^>]+role="status"[^>]+aria-busy="true"/); assert.match(ui.renderApiState('empty', '暂无内容'), /data-api-state="empty"[^>]+role="status"/); assert.match(ui.renderApiState('error', '读取失败'), /data-api-state="error"[^>]+role="alert"/); assert.match(ui.renderApiState('forbidden', '没有权限'), /data-api-state="forbidden"[^>]+role="alert"/); assert.throws(() => ui.renderApiState('unknown', '未知状态'), /不支持的业务状态/); }); test('公共业务状态转义接口消息并由单一方法写入容器', () => { const ui = loadProfileUI(); const container = { innerHTML: '' }; ui.setApiState(container, 'error', ''); assert.doesNotMatch(container.innerHTML, / { const ui = loadProfileStateWithoutJquery(); assert.equal(typeof ui.renderApiState, 'function'); assert.equal(typeof ui.setApiState, 'function'); }); test('业务模块在初始化前先加载统一的家谱上下文与状态 owner', () => { const pages = { 'profile.html': ['profile-pages.js'], 'profile-data.html': ['upload-pages.js', 'region-pages.js', 'profile-pages.js'], 'profile-families.html': ['genealogy-entry-pages.js'], 'profile-feedback.html': ['feedback-pages.js'], 'profile-security.html': ['captcha-pages.js', 'security-pages.js'], 'profile-services.html': ['vip-pages.js'] }; Object.entries(pages).forEach(([page, modules]) => { const source = fs.readFileSync(path.join(projectRoot, page), 'utf8'); const commonIndex = source.indexOf('src="public/js/profile-common.js'); assert.ok(commonIndex >= 0, `${page} 未加载 ProfileUI`); modules.forEach((module) => { assert.ok(commonIndex < source.indexOf('src="public/js/' + module + '"'), `${page} 应先加载 ProfileUI 再加载 ${module}`); }); }); }); test('实际会刷新内容的表单状态容器统一具备可访问性语义', () => { const pages = { 'profile-data.html': ['profileAvatarStatus'], 'profile-album-edit.html': ['albumCoverStatus', 'data-album-form-status'], 'profile-album-detail.html': ['albumPhotoUploadStatus', 'data-album-photo-status'], 'profile-article-edit.html': ['articleCoverStatus'], 'profile-ceremony-detail.html': ['data-ceremony-gift-status', 'data-ceremony-invitee-status'], 'profile-families.html': ['data-genealogy-entry-status'], 'profile-family-home.html': ['data-family-home-status'], 'profile-feed-edit.html': ['feedMediaStatus', 'data-feed-form-status'], 'profile-gift-edit.html': ['giftCoverStatus', 'data-ceremony-form-status'], 'profile-gift.html': ['data-my-invitation-status'], 'profile-growth-edit.html': ['growthMediaStatus'], 'profile-memo-edit.html': ['memoMediaStatus'], 'profile-relative-edit.html': ['relativeMediaStatus'], 'profile-tree.html': ['data-lineage-page-status', 'data-lineage-binding-status', 'lineage-avatar-status'], 'profile-video-edit.html': ['video-file-status', 'video-cover-status', 'data-video-form-status'] }; Object.entries(pages).forEach(([page, markers]) => { const source = fs.readFileSync(path.join(projectRoot, page), 'utf8'); markers.forEach((marker) => { const attribute = marker.indexOf('data-') === 0 ? marker : 'id="' + marker + '"'; const tag = source.match(new RegExp('<[^>]*' + attribute + '[^>]*>')); assert.ok(tag, `${page} 缺少 ${marker} 状态容器`); assert.match(tag[0], /role="status"/, `${page} 的 ${marker} 缺少状态角色`); assert.match(tag[0], /aria-live="polite"/, `${page} 的 ${marker} 缺少实时反馈`); }); }); }); test('家谱业务页链接由公共上下文 owner 统一识别', () => { const ui = loadProfileUI(); const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8'); assert.equal(ui.isGenealogyContextHref('profile-article.html'), true); assert.equal(ui.isGenealogyContextHref('profile-video-edit.html?videoId=1'), true); assert.equal(ui.isGenealogyContextHref('profile-family-admin.html#members'), true); assert.equal(ui.isGenealogyContextHref('profile-data.html'), false); assert.equal(ui.isGenealogyContextHref('https://example.com/profile-article.html'), false); assert.match(source, /\[data-genealogy-context-link\], a\[href\]/); }); test('家谱业务页跳转自动透传上下文,未选家谱时回到家谱入口', () => { const links = [ { href: 'profile-article.html?articleId=1' }, { href: 'profile-data.html' }, { href: 'profile-data.html', contextLink: true } ]; const ui = loadProfileUIWithLinks(links, '?genealogyId=99'); ui.syncGenealogyContextLinks(); assert.equal(links[0].href, 'profile-article.html?articleId=1&genealogyId=99'); assert.equal(links[1].href, 'profile-data.html'); assert.equal(links[2].href, 'profile-data.html?genealogyId=99'); const noContextLinks = [{ href: 'profile-video.html?videoId=1' }]; const noContextUi = loadProfileUIWithLinks(noContextLinks, ''); noContextUi.syncGenealogyContextLinks(); assert.equal(noContextLinks[0].href, 'profile-families.html?next=' + encodeURIComponent('profile-video.html?videoId=1')); assert.equal( noContextUi.getGenealogyEntryUrl('profile-video-edit.html?videoId=1#cover'), 'profile-families.html?next=' + encodeURIComponent('profile-video-edit.html?videoId=1#cover') ); }); test('谱文列表、详情和编辑页接入公共业务状态 owner', () => { const script = fs.readFileSync(path.join(projectRoot, 'public/js/article-pages.js'), 'utf8'); const listPage = fs.readFileSync(path.join(projectRoot, 'profile-article.html'), 'utf8'); const editPage = fs.readFileSync(path.join(projectRoot, 'profile-article-edit.html'), 'utf8'); const css = fs.readFileSync(path.join(projectRoot, 'public/css/public.css'), 'utf8'); assert.match(script, /root\.ProfileUI\.renderApiState/); assert.match(script, /root\.ProfileUI\.setApiState/); assert.match(script, /'loading'[\s\S]+?'empty'[\s\S]+?'forbidden'[\s\S]+?'error'/); assert.match(listPage, /data-api-state="loading"/); assert.match(listPage, /data-api-state="empty"/); assert.match(editPage, /data-api-state="loading"/); assert.match(css, /\.api-state--error,[\s\S]+\.api-state--forbidden/); }); test('内容工作台的初始加载与待选详情占位使用统一状态标记', () => { const listPages = [ 'profile-feed.html', 'profile-generation.html', 'profile-growth.html', 'profile-relative.html', 'profile-merit.html', 'profile-memo.html' ]; const detailPages = [ 'profile-growth.html', 'profile-relative.html', 'profile-merit.html', 'profile-memo.html' ]; listPages.forEach((page) => { const source = fs.readFileSync(path.join(projectRoot, page), 'utf8'); assert.match(source, /api-state--loading[^>]*data-api-state="loading"/); assert.doesNotMatch(source, /api-empty/); }); detailPages.forEach((page) => { const source = fs.readFileSync(path.join(projectRoot, page), 'utf8'); assert.match(source, /api-state--empty[^>]*data-api-state="empty"/); }); }); test('内容记录详情在未选中记录时也使用统一空状态组件', () => { const detailRenderers = [ [require('../public/js/growth-pages.js').renderGrowthDetail, '成长记录'], [require('../public/js/relative-pages.js').renderRelativeDetail, '亲友记录'], [require('../public/js/memo-pages.js').renderMemoDetail, '备忘录'], [require('../public/js/merit-pages.js').renderMeritDetail, '功德记录'] ]; detailRenderers.forEach(([renderDetail, label]) => { const html = renderDetail(null); assert.match(html, /api-state--empty/, `${label} 未使用统一空状态`); assert.doesNotMatch(html, /api-empty/, `${label} 仍保留旧空状态`); }); });