Files
jiapu/tests/business-state.test.js
T
2026-08-03 16:49:38 +08:00

249 lines
9.9 KiB
JavaScript

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', '<img src=x onerror=alert(1)>');
assert.doesNotMatch(container.innerHTML, /<img/);
assert.match(container.innerHTML, /&lt;img/);
});
test('独立表单页不加载 jQuery 时仍可复用公共业务状态 owner', () => {
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} 仍保留旧空状态`);
});
});