feat: refine tiangong flows and integrate profile avatars
This commit is contained in:
1 parent
4608dbd5cf
commit
5be2ca6b50
65 files changed
+2099
-532
No files matched your search
@@ -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\)/)
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
@@ -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\./)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user