import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { parse, compileScript } from 'vue/compiler-sfc'; import * as Vue from 'vue'; import ts from 'typescript'; const source = readFileSync(new URL('../src/components/BottomNav.vue', import.meta.url), 'utf8'); const descriptor = parse(source, { filename: 'BottomNav.vue' }).descriptor; const css = descriptor.styles.map((style) => style.content).join('\n'); const compiled = compileScript(descriptor, { id: 'bottom-nav-test', inlineTemplate: true, templateOptions: { compilerOptions: { isCustomElement: (tag) => ['view', 'text'].includes(tag) } }, }); const js = ts.transpileModule(compiled.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, } }).outputText; function render(unread, active = 'home') { const calls = [], exports = {}; globalThis.uni = { reLaunch: ({ url }) => calls.push(['reLaunch', url]), navigateTo: ({ url }) => calls.push(['navigateTo', url]), }; new Function('require', 'exports', js)((name) => { if (name === 'vue') return Vue; if (name === './AppIcon.vue') return { default: { name: 'AppIcon' } }; if (name === '../utils/app-state') return { clientState: { unreadMessages: unread } }; throw new Error(name); }, exports); const result = exports.default.setup({ active }, { expose() {} })({}, []); function flatten(node) { if (!node || typeof node !== 'object') return []; return [node, ...(Array.isArray(node.children) ? node.children.flatMap(flatten) : [])]; } return { calls, nodes: flatten(result) }; } test('bottom navigation explicitly overrides App-vue shrinking/clipping defaults', () => { const button = css.match(/\.nav-item\s*\{([^}]+)\}/)?.[1]; for (const rule of [/\bwidth:\s*100%\s*;/, /\bmargin:\s*0\s*;/, /\boverflow:\s*visible\s*;/, /\bline-height:\s*1\s*;/]) { assert.match(button, rule); } const badge = css.match(/\.badge\s*\{([^}]+)\}/)?.[1]; for (const rule of [/display:\s*flex/, /align-items:\s*center/, /justify-content:\s*center/, /white-space:\s*nowrap/, /pointer-events:\s*none/, /box-sizing:\s*border-box/]) { assert.match(badge, rule); } assert.match(css, /safe-area-inset-bottom/); }); test('badge appears only on messages, hides zero, and caps long counts at 99+', () => { for (const [count, expected] of [[-1, null], [0, null], [1, '1'], [9, '9'], [99, '99'], [100, '99+'], [10000, '99+']]) { const { nodes } = render(count); const badges = nodes.filter((node) => node.props?.class === 'badge'); assert.equal(badges.length, expected === null ? 0 : 1); if (expected !== null) assert.equal(badges[0].children.trim(), expected); assert.equal(nodes.filter((node) => node.type === 'button').length, 5); } }); test('full-width tab buttons preserve navigation and the central publish action', () => { const { calls, nodes } = render(100, 'messages'); const buttons = nodes.filter((node) => node.type === 'button'); assert.match(buttons.find((node) => node.props['aria-label'] === '消息').props.class, /active/); for (const button of buttons) button.props.onTap(); assert.deepEqual(calls, [ ['reLaunch', '/pages/home/index'], ['reLaunch', '/pages/messages/index'], ['navigateTo', '/pages/publish/index'], ['reLaunch', '/pages/feed/index'], ['reLaunch', '/pages/profile/index'], ]); });