feat: refine tiangong flows and integrate profile avatars

This commit is contained in:
zhouzhibo committed 2026-10-17 22:44:09 +08:00
1 parent 4608dbd5cf
commit 5be2ca6b50
65 files changed
+2099 -532

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
const fullProfile = { id: 9, nickname: '测试用户', gender: '女', birthday: '1998-06-18', birthHour: '辰时',
province: '北京市', city: '北京市', district: '朝阳区', remainingEdits: 4, avatarUrl: '', elementReport: null }
test('编辑页所有编译样式均限定页面,默认头像不复用我的元素水滴类', () => {
const runner = require.resolve('@tarojs/webpack5-runner')
const sass = require(require.resolve('sass', { paths: [runner] }))
const postcss = require(require.resolve('postcss', { paths: [runner] }))
const css = sass.renderSync({ file: path.join(root, 'src/pages/profile-edit/index.scss') }).css.toString()
let checked = 0
postcss.parse(css).walkRules(rule => {
rule.selectors.forEach(selector => {
assert.match(selector, /^\.edit-page(?:\s|[.:#>+~\[]|$)/, `编辑页样式泄漏: ${selector}`)
checked++
})
})
assert.ok(checked > 40)
assert.match(css, /\.edit-page \.edit-water-mascot/)
assert.doesNotMatch(css, /\.water-mascot\b/)
const source = fs.readFileSync(path.join(root, 'src/pages/profile-edit/index.tsx'), 'utf8')
assert.match(source, /className='edit-water-mascot'/)
assert.doesNotMatch(source, /className='water-mascot'/)
})
function setup(token = 'test-session') {
let profile = { ...fullProfile }
const storage = { token }
const calls = [], routes = []
const taro = { default: {
getStorageSync: key => storage[key],
setStorageSync: (key, value) => { storage[key] = value },
removeStorageSync: key => { delete storage[key] },
request: async options => {
calls.push(options)
if (options.method === 'PUT') profile = { ...profile, ...options.data, remainingEdits: profile.remainingEdits - 1 }
return { statusCode: 200, data: { code: 0, data: { ...profile } } }
},
uploadFile: async options => {
calls.push(options)
profile = { ...profile, avatarUrl: 'https://files.example.test/file/access?token=test-file-token' }
return { statusCode: 200, data: JSON.stringify({ code: 0, data: profile }) }
},
switchTab: async options => { routes.push(options.url) },
redirectTo: async options => { routes.push(options.url) }
} }
const cache = {}
function load(name) {
if (cache[name]) return cache[name]
const exports = {}
cache[name] = exports
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, `src/${name}.ts`), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 }
}).outputText, { exports, process: { env: { TARO_ENV: 'h5' } }, window: { location: { search: '' } }, URLSearchParams,
require: id => id === '@tarojs/taro' ? taro : load(path.posix.normalize(path.posix.join(path.posix.dirname(name), id))) })
return exports
}
return { service: load('services/request'), editing: load('utils/profileEditing'), account: load('services/account'),
taro: taro.default, storage, calls, routes }
}
test('无登录凭证不发送个人资料请求;401清除缓存并同步所有订阅页面', async () => {
const missing = setup('')
await assert.rejects(missing.service.api.profile(), /请先登录/)
assert.equal(missing.calls.length, 0)
const ctx = setup()
await ctx.service.api.profile()
const updates = []
const unsubscribe = ctx.service.subscribeProfile(profile => updates.push(profile))
ctx.taro.request = async () => ({ statusCode: 401, data: { code: 401, message: '请重新登录' } })
await assert.rejects(ctx.service.api.profile(true), /失效|登录/)
assert.equal(ctx.storage.token, undefined)
assert.equal(ctx.service.getCachedProfile(), null)
assert.equal(updates.at(-1), null)
unsubscribe()
})
test('头像经专用COS后端接口上传,Bearer字段正确,禁止H5跨域Cookie', async () => {
const ctx = setup()
const updates = []
ctx.service.subscribeProfile(profile => updates.push(profile))
const profile = await ctx.service.api.uploadAvatar('blob:local-avatar')
const upload = ctx.calls[0]
assert.equal(upload.url, 'http://127.0.0.1:8080/api/user/avatar')
assert.equal(upload.name, 'file')
assert.equal(upload.filePath, 'blob:local-avatar')
assert.equal(upload.header.Authorization, 'Bearer test-session')
assert.equal(upload.header['content-type'], undefined)
assert.equal(upload.withCredentials, false)
assert.equal(profile.avatarUrl, updates.at(-1).avatarUrl)
assert.equal(ctx.service.getCachedProfile().avatarUrl, profile.avatarUrl)
assert.equal((await ctx.service.api.profile()).avatarUrl, profile.avatarUrl)
assert.equal(ctx.calls.length, 1)
})
test('上传失败不覆盖已有头像;HTML 401也清理会话', async () => {
const ctx = setup()
await ctx.service.api.profile()
ctx.taro.uploadFile = async () => ({ statusCode: 400, data: JSON.stringify({ code: 400, message: '仅支持 JPG、PNG 或 WebP 头像' }) })
await assert.rejects(ctx.service.api.uploadAvatar('bad.gif'), /仅支持/)
assert.equal(ctx.service.getCachedProfile().avatarUrl, '')
ctx.taro.uploadFile = async () => ({ statusCode: 401, data: '<html>unauthorized</html>' })
await assert.rejects(ctx.service.api.uploadAvatar('test.png'), /失效|登录/)
assert.equal(ctx.storage.token, undefined)
})
test('新注册空资料保持空值,老用户直接保存token进入我的', async () => {
const ctx = setup('')
await ctx.account.finishAccountLogin({ token: 'new-session', user: { ...fullProfile, birthday: null, birthHour: null,
gender: '未知', province: null, city: null, district: null } })
assert.equal(ctx.storage.token, 'new-session')
assert.equal(ctx.service.getCachedProfile().birthday, '')
assert.equal(ctx.editing.toEditableProfile(ctx.service.getCachedProfile()).gender, '')
assert.equal(ctx.routes.at(-1), '/pages/profile-edit/index?mode=register')
await ctx.account.finishAccountLogin({ token: 'returning-session', user: fullProfile })
assert.equal(ctx.routes.at(-1), '/pages/my/index')
assert.equal(ctx.service.getCachedProfile().id, 9)
})
test('单独更新头像不调用资料PUT,且不受资料额度为0限制', async () => {
const ctx = setup()
let personalWrites = 0
const initial = { ...fullProfile, remainingEdits: 0 }
const result = await ctx.editing.saveProfileEdits(initial, ctx.editing.toEditableProfile(initial), 'avatar.png', {
uploadAvatar: async () => ({ ...initial, avatarUrl: '/file/access?token=fixture' }),
updateProfile: async () => { personalWrites++; return initial }
}, () => {})
assert.equal(personalWrites, 0)
assert.equal(result.profile.remainingEdits, 0)
assert.equal(result.changed, true)
})
test('头像上传失败不消费个人资料额度;先上传再保存且只PUT一次', async () => {
const ctx = setup()
const form = { ...ctx.editing.toEditableProfile(fullProfile), nickname: '新昵称' }
const order = []
const writer = {
uploadAvatar: async () => { order.push('upload'); throw new Error('COS不可用') },
updateProfile: async () => { order.push('put'); return { ...fullProfile, ...form, remainingEdits: 3 } }
}
await assert.rejects(ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, () => {}), /COS/)
assert.deepEqual(order, ['upload'])
order.length = 0
writer.uploadAvatar = async () => { order.push('upload'); return { ...fullProfile, avatarUrl: '/avatar.png' } }
await ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, () => order.push('sync'))
assert.deepEqual(order, ['upload', 'sync', 'put'])
})
test('头像成功而资料失败会明确提示;重试不再次上传文件', async () => {
const ctx = setup()
const form = { ...ctx.editing.toEditableProfile(fullProfile), nickname: '新昵称' }
let uploads = 0, writes = 0, uploaded
const writer = {
uploadAvatar: async () => { uploads++; return { ...fullProfile, avatarUrl: '/saved.png' } },
updateProfile: async () => { writes++; throw new Error('资料暂不可用') }
}
await assert.rejects(ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, profile => { uploaded = profile }), /头像已更新,个人资料未保存/)
writer.updateProfile = async () => { writes++; return { ...uploaded, ...form } }
await ctx.editing.saveProfileEdits(uploaded, form, '', writer, () => {})
assert.equal(uploads, 1); assert.equal(writes, 2)
})
test('不发送未更改的个人资料;校验非法日期、缺失信息和昵称', async () => {
const ctx = setup()
let writes = 0
const form = ctx.editing.toEditableProfile(fullProfile)
const unchanged = await ctx.editing.saveProfileEdits(fullProfile, form, '', { uploadAvatar: async () => fullProfile,
updateProfile: async () => { writes++; return fullProfile } }, () => {})
assert.equal(unchanged.changed, false); assert.equal(writes, 0)
for (const patch of [{ nickname: '' }, { gender: '' }, { birthday: '2026-02-30' }, { birthday: '2999-01-01' }]) {
assert.throws(() => ctx.editing.validateProfileForm({ ...form, ...patch }))
}
})
test('三个位置共用头像组件,编辑失败不回退假资料,不再使用dev-code登录', () => {
for (const page of ['home', 'my', 'profile-edit']) {
const source = fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8')
assert.match(source, /UserAvatar/)
assert.doesNotMatch(source, /setProfile\(previewProfile\)/)
}
for (const page of ['home', 'my']) assert.match(fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8'), /subscribeProfile/)
assert.doesNotMatch(fs.readFileSync(path.join(root, 'src/pages/login/index.tsx'), 'utf8'), /dev-code/)
assert.match(fs.readFileSync(path.join(root, 'src/components/UserAvatar.tsx'), 'utf8'), /api.profile\(true\)/)
})
+54
View File
@@ -0,0 +1,54 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const runner = require.resolve('@tarojs/webpack5-runner')
const postcss = require(require.resolve('postcss', { paths: [runner] }))
const parseSelector = require(require.resolve('postcss-selector-parser', { paths: [runner] }))
const dist = path.resolve(__dirname, '../dist')
function collect(folder) {
return fs.readdirSync(folder, { withFileTypes: true }).flatMap(entry => {
const file = path.join(folder, entry.name)
return entry.isDirectory() ? collect(file) : file.endsWith('.wxss') ? [file] : []
})
}
function validateWxss(css, label) {
postcss.parse(css).walkRules(rule => {
if (rule.parent.type === 'atrule' && /keyframes$/i.test(rule.parent.name)) return
parseSelector(selectors => selectors.walkUniversals(() => {
throw new Error(`${label}:WXSS 不支持通配选择器 ${rule.selector}`)
})).processSync(rule.selector)
parseSelector(selectors => selectors.walkPseudos(pseudo => {
if (/^:(?:first|last|nth|only)-(?:child|of-type)$/.test(pseudo.value)
&& (!pseudo.prev() || pseudo.prev().type === 'combinator')) {
throw new Error(`${label}:WXSS 结构伪类前需明确元素或类名 ${rule.selector}`)
}
})).processSync(rule.selector)
})
}
function main() {
if (!fs.existsSync(path.join(dist, 'app.wxss'))) throw new Error('请先构建微信小程序')
const files = collect(dist)
for (const file of files) validateWxss(fs.readFileSync(file, 'utf8'), path.relative(dist, file))
console.log(`WXSS 选择器兼容检查通过:${files.length} 个文件`)
const compiler = process.env.WECHAT_WXSS_COMPILER
|| '/Applications/wechatwebdevtools.app/Contents/Resources/app.asar.unpacked/node_modules/wcc-exec/wcsc'
if (!fs.existsSync(compiler)) {
if (process.env.WECHAT_WXSS_COMPILER) throw new Error('指定的微信 WXSS 编译器不存在')
console.warn('未找到微信原生 WXSS 编译器:本次仅完成静态检查,仍需开发者工具编译验收。可设置 WECHAT_WXSS_COMPILER。')
} else {
// The native compiler resolves @import from its supplied file collection.
const result = spawnSync(compiler, ['-lc', '-pc', String(files.length), ...files.map(file => `./${path.relative(dist, file)}`)], {
cwd: dist, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024
})
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`微信原生编译失败:${result.stderr.slice(0, 3000)}`)
console.log(`微信原生 WXSS 编译通过:${files.length} 个文件`)
}
}
module.exports = { validateWxss }
if (require.main === module) main()
+74
View File
@@ -0,0 +1,74 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
const cache = new Map()
function load(file) {
if (cache.has(file)) return cache.get(file)
const exports = {}
cache.set(file, exports)
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX }
}).outputText
vm.runInNewContext(code, { exports, require: id => {
if (id.endsWith('.scss')) return {}
if (id === '@tarojs/components') return { View: 'view', Text: 'text' }
if (id.startsWith('.')) return load(path.resolve(path.dirname(file), `${id}.ts`))
return require(id)
} })
return exports
}
const { getDayMasterIcon } = load(path.join(root, 'src/utils/elementIcon.ts'))
const { calculateFiveElements, analyzePillars } = load(path.join(root, 'src/utils/fiveElements.ts'))
const { FiveElementIcon } = load(path.join(root, 'src/components/FiveElementIcon.tsx'))
test('十天干的日主对应五种元素图标,不按最高占比或喜用选择', () => {
const days = ['甲子', '乙丑', '丙寅', '丁卯', '戊辰', '己巳', '庚午', '辛未', '壬申', '癸酉']
const expected = ['wood', 'wood', 'fire', 'fire', 'earth', 'earth', 'metal', 'metal', 'water', 'water']
days.forEach((day, index) => {
const report = analyzePillars(['甲寅', '乙卯', day, '壬子'])
const icon = getDayMasterIcon(report)
assert.equal(icon.kind, expected[index])
assert.equal(icon.label, report.masterElement)
assert.match(icon.color, /^#[0-9a-f]{6}$/)
const component = FiveElementIcon({ report })
assert.equal(component.props.className, `five-element-icon five-element-${expected[index]}`)
assert.equal(component.props['aria-label'], `日主五行:${report.masterElement}`)
})
const report = analyzePillars(['甲寅', '乙卯', '庚申', '甲寅'])
assert.equal([...report.scores].sort((a, b) => b.value - a.value)[0].name, '木')
assert.equal(getDayMasterIcon(report).kind, 'metal')
})
test('真实生日变动同步切换日主图标,数据缺失或非法不默认显示水', () => {
const kinds = new Set()
for (let day = 10; day < 20; day++) {
const report = calculateFiveElements(`1998-06-${day}`, '辰时')
kinds.add(getDayMasterIcon(report).kind)
}
assert.equal(kinds.size, 5)
const first = getDayMasterIcon(calculateFiveElements('1998-06-18', '辰时'))
const changed = getDayMasterIcon(calculateFiveElements('1998-06-20', '辰时'))
assert.notEqual(first.kind, changed.kind)
for (const report of [null, undefined, { masterElement: '未知' }, calculateFiveElements('', '')]) {
assert.equal(getDayMasterIcon(report).kind, 'unknown')
}
assert.equal(FiveElementIcon({ report: null }).props['aria-label'], '完善出生信息后显示日主五行')
})
test('我的元素复用当前报告,个人头像保持独立,图标样式不污染水滴默认头像', () => {
const page = fs.readFileSync(path.join(root, 'src/pages/my/index.tsx'), 'utf8')
assert.match(page, /<FiveElementIcon report=\{elementReport\} \/>/)
assert.match(page, /calculateFiveElements\(profile.birthday, profile.birthHour\)/)
assert.match(page, /\[profile\?\.birthday, profile\?\.birthHour\]/)
assert.match(page, /<UserAvatar src=\{profile.avatarUrl\}[^>]+fallback=\{<WaterMascot \/>\}/)
assert.doesNotMatch(page, /<WaterMascot compact/)
const sass = require(require.resolve('sass', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const postcss = require(require.resolve('postcss', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const css = sass.renderSync({ file: path.join(root, 'src/components/FiveElementIcon.scss') }).css.toString()
postcss.parse(css).walkRules(rule => rule.selectors.forEach(selector => assert.match(selector, /^\.five-element-/)))
assert.doesNotMatch(css, /\.water-mascot/)
})
+61 -7
View File
@@ -5,28 +5,36 @@ const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
function loadApi(env, search) {
function loadApi(env, search, initialToken = 'test-token') {
let requests = 0
let uploads = 0
let token = initialToken
let serverProfile = { id: 9, nickname: 'Original', birthday: '1998-06-18' }
const taro = { default: {
getStorageSync: () => '',
request: async () => { requests++; return { data: { code: 0, data: { id: 9 } } } },
uploadFile: async () => { uploads++; return { data: JSON.stringify({ code: 0, data: { id: 9 } }) } }
getStorageSync: () => token,
setStorageSync: (key, value) => { if (key === 'token') token = value },
removeStorageSync: () => { token = '' },
request: async (options) => {
requests++
if (options.method === 'PUT') serverProfile = { ...serverProfile, ...options.data }
return { statusCode: 200, data: { code: 0, data: { ...serverProfile } } }
},
uploadFile: async () => { uploads++; return { statusCode: 200, data: JSON.stringify({ code: 0, data: { id: 9 } }) } }
} }
const cache = {}
function load(name) {
if (cache[name]) return cache[name]
const source = fs.readFileSync(path.resolve(__dirname, `../src/services/${name}.ts`), 'utf8')
const source = fs.readFileSync(path.resolve(__dirname, `../src/${name}.ts`), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText
const exports = {}
cache[name] = exports
vm.runInNewContext(code, {
exports, process: { env: { TARO_ENV: env } }, window: { location: { search } }, URLSearchParams,
require: (id) => id === '@tarojs/taro' ? taro : load('frontendPreview')
require: (id) => id === '@tarojs/taro' ? taro : load(path.posix.normalize(path.posix.join(path.posix.dirname(name), id)))
})
return exports
}
return { api: load('request').api, counts: () => ({ requests, uploads }) }
return { api: load('services/request').api, getToken: () => token, counts: () => ({ requests, uploads }) }
}
test('显式H5预览使用示例资料,阻止后端读取、保存和上传', async () => {
@@ -49,3 +57,49 @@ test('普通H5和微信端不使用预览资料', async () => {
assert.deepEqual(counts(), { requests: 1, uploads: 0 })
}
})
test('首页与我的复用资料,修改生日后立即读取新值,编辑页可强制刷新', async () => {
const { api, counts } = loadApi('h5', '')
await Promise.all([api.profile(), api.profile()])
assert.equal(counts().requests, 1)
await api.updateProfile({ birthday: '2000-02-05' })
const updated = await api.profile()
assert.equal(updated.birthday, '2000-02-05')
assert.equal(counts().requests, 2)
updated.birthday = '1900-01-01'
assert.equal((await api.profile()).birthday, '2000-02-05')
await api.profile(true)
assert.equal(counts().requests, 3)
})
test('已登录头像演示无需token,保存同步且不注册、不上传真实后端,刷新重置', async () => {
const { api, counts, getToken } = loadApi('h5', '?avatarDemo=1', '')
assert.equal((await api.profile()).nickname, '演示用户')
assert.equal((await api.profile()).id, -1)
const uploaded = await api.uploadAvatar('blob:local-photo')
assert.equal(uploaded.avatarUrl, 'blob:local-photo')
assert.equal(uploaded.remainingEdits, 5)
assert.equal((await api.profile(true)).avatarUrl, 'blob:local-photo')
await api.updateProfile({ nickname: '我的演示' })
assert.equal((await api.profile()).nickname, '我的演示')
assert.equal((await api.profile()).remainingEdits, 4)
await assert.rejects(api.uploadAvatar('https://remote.example/photo.png'), /本地图片/)
await assert.rejects(api.wxLogin('fake'), /不请求真实后端/)
await assert.rejects(api.sendSms('13800000000'), /不请求真实后端/)
await assert.rejects(api.bindPhone('fake', '13800000000', '123456'), /不请求真实后端/)
assert.equal(getToken(), '')
assert.deepEqual(counts(), { requests: 0, uploads: 0 })
assert.equal((await loadApi('h5', '?avatarDemo=1').api.profile()).avatarUrl, '')
})
test('头像演示不进入微信端;不影响原只读preview和真实token', async () => {
const real = loadApi('weapp', '?avatarDemo=1')
assert.equal((await real.api.profile()).id, 9)
assert.equal(real.counts().requests, 1)
const preview = loadApi('h5', '?preview=1&avatarDemo=1')
assert.equal((await preview.api.profile()).id, 0)
await assert.rejects(preview.api.uploadAvatar('blob:local'), /纯前端预览/)
const demo = loadApi('h5', '?avatarDemo=1')
await demo.api.uploadAvatar('blob:local')
assert.equal(demo.getToken(), 'test-token')
})
+82
View File
@@ -0,0 +1,82 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
function load(file, requireImpl = require, globals = {}) {
const source = fs.readFileSync(path.join(root, file), 'utf8')
const exports = {}
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText,
{ exports, require: requireImpl, ...globals })
return exports
}
const { createResourceCache } = load('src/utils/resourceCache.ts')
test('三个主页面均注册为Tab,切换不使用push/redirect', () => {
let config
load('src/app.config.ts', require, { defineAppConfig: value => { config = value } })
assert.deepEqual(Array.from(config.tabBar.list, item => item.pagePath), ['pages/home/index', 'pages/fortune/index', 'pages/my/index'])
const bar = fs.readFileSync(path.join(root, 'src/components/AppTabBar.tsx'), 'utf8')
assert.doesNotMatch(bar, /navigateTo|redirectTo/)
})
test('连续点击只切换一次,完成或失败后均可继续点击', async () => {
const urls = [], notices = []
let complete
const { switchMainTab } = load('src/services/tabNavigation.ts', () => ({ default: {
switchTab: ({ url }) => { urls.push(url); return new Promise(resolve => { complete = resolve }) },
showToast: value => notices.push(value.title)
} }))
await switchMainTab('home', 'home')
assert.equal(urls.length, 0)
const first = switchMainTab('fortune', 'home')
await switchMainTab('my', 'home')
assert.equal(urls.length, 1)
complete(); await first
const next = switchMainTab('my', 'fortune')
complete(); await next
assert.deepEqual(urls, ['/pages/fortune/index', '/pages/my/index'])
const failing = load('src/services/tabNavigation.ts', () => ({ default: {
switchTab: () => Promise.reject(new Error('offline')), showToast: value => notices.push(value.title)
} }))
await failing.switchMainTab('my')
await failing.switchMainTab('fortune')
assert.equal(notices.length, 2)
})
test('资料缓存合并并发请求,过期保留快照并重取;失败可重试', async () => {
let now = 0, calls = 0
const cache = createResourceCache(30, () => now)
const loader = async () => { calls++; return { nickname: 'A' } }
const [a,b] = await Promise.all([cache.load('a', loader), cache.load('a', loader)])
assert.equal(a, b); assert.equal(calls, 1)
await cache.load('a', loader); assert.equal(calls, 1)
now = 31
assert.equal(cache.peek('a').nickname, 'A')
await assert.rejects(cache.load('a', async () => { throw new Error('offline') }), /offline/)
await cache.load('a', loader); assert.equal(calls, 2)
await cache.load('a', loader, true); assert.equal(calls, 3)
})
test('保存后的新资料不会被旧请求覆盖,不同账号不共用缓存', async () => {
const cache = createResourceCache(30)
let finish
const old = cache.load('a', () => new Promise(resolve => { finish = resolve }))
await Promise.resolve()
cache.seed('a', { nickname: 'New' })
finish({ nickname: 'Old' })
assert.equal((await old).nickname, 'New')
assert.equal(cache.peek('b'), undefined)
let resolveA
const readA = cache.load('a', () => new Promise(resolve => { resolveA = resolve }))
await Promise.resolve()
cache.seed('b', { nickname: 'B' })
resolveA({ nickname: 'A' })
await assert.rejects(readA, /登录状态已变更/)
assert.equal(cache.peek('b').nickname, 'B')
let resolveOldA
const oldA = cache.load('a', () => new Promise(resolve => { resolveOldA = resolve }))
await Promise.resolve()
cache.peek('b'); cache.peek('a')
resolveOldA({ nickname: 'Outdated A' })
await assert.rejects(oldA, /资料状态已变更/)
assert.equal(cache.peek('a'), undefined)
})
+29
View File
@@ -0,0 +1,29 @@
// Technical build-time resampling/compression only; artwork and alpha are preserved.
// Requires sharp (available in the bundled workspace runtime), no network requests.
const path = require('node:path')
const fs = require('node:fs')
const sharp = require('sharp')
const source = process.argv[2]
if (!source) throw new Error('Pass the directory exported by the browser pageAssets capability')
const output = path.resolve(__dirname, '../src/assets/tiangong')
const jobs = [
['5ee867165b3314a2.png', 'wealth', 'icon'],
['d00ddb9d78132e08.png', 'fortune', 'icon'],
['c8acca9ecd8bf75b.png', 'longevity', 'icon'],
['6b935cc3a3f6178e.png', 'wenchang', 'icon'],
['778c1072c4dcdcbe.png', 'matchmaker', 'icon'],
['295b80249bc5355a.png', 'wealth-statue', 'statue'],
['1d88a8dcad0553ef.png', 'fortune-statue', 'statue'],
['c7ef27f2d9dcc38a.png', 'clouds', 'background'],
['f0bcdd293f5f5ef3.jpg', 'altar', 'altar'],
['01b38f88c3e69699.jpg', 'altar-lit', 'altar']
]
;(async () => {
for (const [filename, name, kind] of jobs) {
const asset = sharp(path.join(source, filename))
const destination = path.join(output, `proto-${name}.${kind === 'statue' ? 'png' : 'jpg'}`)
if (kind === 'statue') await asset.resize(640, 640, { fit: 'inside' }).png({ palette: true, colours: 128, quality: 80, effort: 10, compressionLevel: 9 }).toFile(destination)
else await asset.resize({ width: kind === 'icon' ? 384 : kind === 'altar' ? 800 : 540, withoutEnlargement: true }).jpeg({ quality: 76, mozjpeg: true }).toFile(destination)
console.log(path.basename(destination), fs.statSync(destination).size)
}
})().catch(error => { console.error(error); process.exitCode = 1 })
+136
View File
@@ -0,0 +1,136 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const Module = require('node:module')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
const filename = path.join(root, 'src/utils/tiangongMock.ts')
const loaded = new Module(filename, module)
loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText, filename)
const { createTiangongMock, DEITY_IDS, isDeityId, hasBoundDeity } = loaded.exports
test('天宫仅财神初始已恭请,五位神仙独立且状态快照隔离', () => {
const mock = createTiangongMock()
const first = mock.snapshot()
assert.equal(DEITY_IDS.length, 5)
assert.deepEqual(DEITY_IDS.filter(id => first.deities[id].unlocked), ['wealth'])
assert.equal(first.balance, 100)
first.deities.wealth.incenseCount = 999
assert.equal(mock.snapshot().deities.wealth.incenseCount, 59)
assert.equal(isDeityId('not-a-god'), false)
})
test('上香点亮供桌次数+1,不猜测爱心扣减与功德奖励', () => {
const mock = createTiangongMock()
const state = mock.incense('wealth')
assert.equal(state.deities.wealth.incenseCount, 60)
assert.equal(state.deities.wealth.lit, true)
assert.equal(state.deities.wealth.merit, 99)
assert.equal(state.balance, 100)
assert.throws(() => mock.incense('fortune'), /未恭请/)
})
test('模拟恭请和送神只改变指定神仙,返回列表状态可同步', () => {
const mock = createTiangongMock()
mock.invite('fortune')
mock.incense('fortune')
assert.equal(mock.snapshot().deities.fortune.incenseCount, 1)
mock.sendOff('fortune')
assert.equal(mock.snapshot().deities.fortune.unlocked, false)
assert.equal(mock.snapshot().deities.fortune.lit, false)
assert.equal(mock.snapshot().deities.wealth.unlocked, true)
assert.equal(mock.snapshot().balance, 100)
})
test('档位、非法金额与余额不足可以验证,但不执行任何真实扣款', () => {
const mock = createTiangongMock()
assert.equal(mock.offering('wealth', 99).insufficient, false)
assert.equal(mock.offering('wealth', 168).insufficient, true)
assert.equal(mock.snapshot().balance, 100)
for (const value of [0, -1, 1.5, NaN, Infinity]) assert.throws(() => mock.offering('wealth', value), /正整数/)
mock.setDemoBalance(0)
assert.equal(mock.offering('wealth', 9).insufficient, true)
})
test('祈愿/回向校验空值,不持久化姓名或祝愿;重置还原样例', () => {
const mock = createTiangongMock()
assert.throws(() => mock.wish('wealth', ' '), /愿望/)
assert.throws(() => mock.dedicate('wealth', '甲', ''), /填写/)
assert.deepEqual(mock.wish('wealth', '测试愿望'), { mock: true })
assert.deepEqual(mock.dedicate('wealth', '测试姓名', '测试祝愿'), { mock: true })
assert.ok(!JSON.stringify(mock.snapshot()).includes('测试'))
mock.invite('fortune')
mock.reset()
assert.equal(mock.snapshot().deities.fortune.unlocked, false)
assert.equal(mock.snapshot().balance, 100)
})
test('未知神仙不回退为财神,页面只导入Mock;预留契约不被调用', () => {
const service = fs.readFileSync(path.join(root, 'src/services/tiangong.ts'), 'utf8')
assert.ok(!service.includes("id || 'wealth'"))
for (const page of ['fortune', 'tiangong-detail']) {
const source = fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8')
assert.ok(!/api\.|tiangongRemoteContract\./.test(source))
}
const mock = createTiangongMock()
assert.throws(() => mock.invite('bad'), /未找到/)
})
test('图像均为本地原型素材,没有错误后缀或带写死数字的整页截图', () => {
for (const name of ['wealth', 'fortune', 'longevity', 'wenchang', 'matchmaker', 'clouds', 'altar', 'altar-lit']) {
const buffer = fs.readFileSync(path.join(root, `src/assets/tiangong/proto-${name}.jpg`))
assert.equal(buffer.subarray(0, 2).toString('hex'), 'ffd8')
}
for (const name of ['wealth', 'fortune']) {
const buffer = fs.readFileSync(path.join(root, `src/assets/tiangong/proto-${name}-statue.png`))
assert.equal(buffer.subarray(1, 4).toString(), 'PNG')
}
const source = fs.readFileSync(path.join(root, 'src/pages/tiangong-detail/index.tsx'), 'utf8')
assert.ok(!source.includes('wealth-detail-scene.jpg'))
})
test('最后一位解绑锁定,任意一位绑定重新解锁;解绑他位不影响已有绑定', () => {
const mock = createTiangongMock()
assert.equal(hasBoundDeity(mock.snapshot()), true)
mock.unbind('wealth')
assert.equal(hasBoundDeity(mock.snapshot()), false)
mock.invite('fortune')
assert.equal(hasBoundDeity(mock.snapshot()), true)
mock.invite('wealth')
mock.unbind('fortune')
assert.equal(hasBoundDeity(mock.snapshot()), true)
})
test('每位初始2次,重复恭请/取消不扣减或重置,耗尽后送神也不能绕过', () => {
const mock = createTiangongMock()
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 2)
// Viewing a confirmation/cancelling is read-only.
mock.snapshot(); mock.snapshot()
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 2)
mock.unbind('wealth')
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
assert.throws(() => mock.unbind('wealth'), /未恭请/)
mock.invite('wealth'); mock.invite('wealth')
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
mock.sendOff('wealth')
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 0)
mock.invite('wealth')
for (const action of ['unbind', 'sendOff']) assert.throws(() => mock[action]('wealth'), /次数已用尽/)
assert.equal(mock.snapshot().deities.wealth.unlocked, true)
assert.equal(mock.snapshot().deities.fortune.remainingUnbinds, 2)
})
test('绑定、解绑、重置同步通知缓存Tab;退订后不再通知', () => {
const mock = createTiangongMock()
const states = []
const unsubscribe = mock.subscribe(() => states.push(hasBoundDeity(mock.snapshot())))
mock.unbind('wealth'); mock.invite('fortune'); mock.reset()
assert.deepEqual(states, [false, true, true])
unsubscribe(); mock.unbind('wealth')
assert.equal(states.length, 3)
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
})
test('绑定门禁和卡片采用神仙用语,真实NFC与解绑契约不被页面调用', () => {
for (const name of ['home', 'my']) {
const source = fs.readFileSync(path.join(root, `src/pages/${name}/index.tsx`), 'utf8')
assert.match(source, /hasBoundDeity/)
assert.match(source, /BindingGate/)
assert.doesNotMatch(source, /吉物|山鬼花钱/)
}
const ui = fs.readFileSync(path.join(root, 'src/components/DeityBinding.tsx'), 'utf8')
assert.match(ui, /disabled=\{state.remainingUnbinds === 0\}/)
assert.doesNotMatch(ui, /getNFCAdapter|tiangongRemoteContract\./)
})
+29
View File
@@ -0,0 +1,29 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { validateWxss } = require('./checkWxss.cjs')
test('WXSS 检查阻止通配选择器和无明确元素的结构伪类', () => {
assert.throws(() => validateWxss('.zodiac-body > * { flex-shrink: 0; }', 'my'), /通配选择器/)
assert.throws(() => validateWxss('.tg-panel-heading > :last-child { flex: 1; }', 'detail'), /结构伪类/)
assert.doesNotThrow(() => validateWxss('.zodiac-summary, .zodiac-large { flex-shrink: 0; } .card:first-child { margin: 0; }', 'valid'))
})
test('所有业务 SCSS 编译后不含上述微信不兼容选择器', () => {
const sass = require(require.resolve('sass', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const source = path.resolve(__dirname, '../src')
let count = 0
function check(folder) {
for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
const file = path.join(folder, entry.name)
if (entry.isDirectory()) check(file)
else if (file.endsWith('.scss')) {
validateWxss(sass.renderSync({ file }).css.toString(), path.relative(source, file))
count++
}
}
}
check(source)
assert.ok(count >= 10)
})