import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import ts from 'typescript'; import { parse } from 'vue/compiler-sfc'; function loadModule(file, require = () => { throw new Error('Unexpected import'); }) { const source = readFileSync(new URL(file, import.meta.url), 'utf8'); const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; const result = {}; new Function('exports', 'require', js)(result, require); return result; } const display = loadModule('../src/utils/message-display.ts'); test('conversation timestamps support RFC3339 and legacy nullable times, never NaN', () => { const now = new Date(2026, 7, 31, 12, 23); const stamp = new Date(2026, 7, 31, 8, 5).toISOString(); assert.equal(display.formatConversationTime(stamp, now), '08:05'); assert.equal(display.formatConversationTime({ Valid: true, Time: stamp }, now), '08:05'); assert.equal(display.formatConversationTime(new Date(2026, 7, 30, 12).toISOString(), now), '8-30'); for (const invalid of [null, undefined, '', {}, 123, 'not-a-date', '0001-01-01T00:00:00Z', { Valid: false, Time: stamp }, { Valid: true, Time: 'invalid' }]) { assert.equal(display.formatConversationTime(invalid, now), ''); } }); test('unread totals use the authoritative count, accept zero, reject invalid counts', () => { assert.equal(display.unreadTotal([{ unread: 0, unreadCount: 3 }, { unread: '2' }, { unread: -2 }, { unread: 'bad' }, { unreadCount: 1 }]), 3); }); test('HTTP send response and WebSocket echo create one outgoing message in either order', () => { const optimistic = { id: 'local-1', clientMsgId: 'client-1', senderId: 1, status: 'sending' }; const saved = { ...optimistic, id: 7, seq: 1, status: 'sent' }; let items = display.upsertMessage([optimistic], saved); items = display.upsertMessage(items, saved); assert.deepEqual(items, [saved]); assert.deepEqual(display.upsertMessage([optimistic, saved], saved), [saved]); assert.equal(display.upsertMessage(items, { ...saved, id: 8, senderId: 2 }).length, 2); }); function stateFixture() { let listener, token = 'test-session', serverUnread = 0, requests = 0; const replies = []; const state = loadModule('../src/utils/app-state.ts', (name) => { if (name === 'vue') return { reactive: (value) => value }; if (name === './message-display') return display; if (name === './im') return { connectIM: (fn) => { listener = fn; return () => { listener = undefined; }; } }; if (name === './request') return { getToken: () => token, api: { conversations: () => { requests++; return replies.length ? replies.shift() : Promise.resolve({ items: [{ unread: serverUnread }] }); }, notifications: async () => ({ unread: 0 }), appConfig: async () => ({}), }, }; throw new Error(name); }); return { ...state, emit: (frame) => listener(frame), setUnread: (value) => { serverUnread = value; }, replies, requests: () => requests, logout: () => { token = ''; state.stopClientServices(); } }; } globalThis.uni = {}; test('own echoes and duplicate recipient pushes do not accumulate unread badges; reads/recalls resync', async () => { const f = stateFixture(); try { await f.startClientServices(); const own = { command: 'MESSAGE_PUSH', data: { id: 1, senderId: 1, conversationId: 1 } }; f.emit(own); f.emit(own); await f.refreshUnreadCounts(); assert.equal(f.clientState.unreadMessages, 0); f.setUnread(1); const incoming = { ...own, data: { ...own.data, id: 2, senderId: 2 } }; f.emit(incoming); f.emit(incoming); await f.refreshUnreadCounts(); assert.equal(f.clientState.unreadMessages, 1); for (const command of ['READ_ACK', 'MESSAGE_RECALLED', 'MESSAGE_RESTORED']) { const before = f.requests(); f.setUnread(command === 'MESSAGE_RESTORED' ? 1 : 0); f.emit({ command }); await new Promise((resolve) => setTimeout(resolve, 220)); assert.equal(f.requests(), before + 1); assert.equal(f.clientState.unreadMessages, command === 'MESSAGE_RESTORED' ? 1 : 0); } } finally { f.stopClientServices(); } }); test('stale requests and logged-out sessions cannot resurrect unread badges', async () => { const f = stateFixture(); await f.startClientServices(); let resolveOld; f.replies.push(new Promise((resolve) => { resolveOld = resolve; })); const old = f.refreshUnreadCounts(); f.setUnread(0); await f.refreshUnreadCounts(); resolveOld({ items: [{ unread: 9 }] }); await old; assert.equal(f.clientState.unreadMessages, 0); let resolveLogout; f.replies.push(new Promise((resolve) => { resolveLogout = resolve; })); const pending = f.refreshUnreadCounts(); f.logout(); resolveLogout({ items: [{ unread: 8 }] }); await pending; assert.equal(f.clientState.unreadMessages, 0); }); test('actual chat page acknowledges only visible incoming messages and merges outgoing echoes', async () => { const hooks = {}, reads = [], state = { activeConversationId: 0 }; let listener, history = [], historyRequests = 0, totalsRefreshes = 0; globalThis.uni = { getStorageSync: () => 1, getSystemInfoSync: () => ({ uniPlatform: 'web' }) }; const source = parse(readFileSync(new URL('../src/pages/chat/index.vue', import.meta.url), 'utf8')).descriptor.scriptSetup.content; const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; const api = { messages: async () => { historyRequests++; return { items: [...history], hasMore: false }; }, profile: async () => ({ nickname: '对方', avatar: '' }), conversations: async () => ({ items: [{ id: 10, unread: 0 }] }), conversationSettings: async (id, value) => { reads.push([id, value.readSeq]); }, sendMessage: async (conversationId, type, content, clientMsgId) => { const saved = { id: 7, seq: 2, senderId: 1, conversationId, clientMsgId, type, content }; await listener({ command: 'MESSAGE_PUSH', data: saved }); return saved; }, }; const require = (name) => { if (name === 'vue') return { ref: (value) => ({ value }), computed: (fn) => ({ get value() { return fn(); } }), nextTick: async () => {} }; if (name === '@dcloudio/uni-app') return Object.fromEntries(['onLoad', 'onShow', 'onHide', 'onUnload'].map((key) => [key, (fn) => { hooks[key] = fn; }])); if (name.endsWith('.vue')) return {}; if (name.endsWith('/im')) return { connectIM: (fn) => { listener = fn; return () => {}; } }; if (name.endsWith('/request')) return { api, messageId: () => 'outgoing-id' }; if (name.endsWith('/message-display')) return display; if (name.endsWith('/app-state')) return { clientState: state, setActiveConversation: (id) => { state.activeConversationId = id || 0; }, refreshUnreadCounts: async () => { totalsRefreshes++; } }; throw new Error(name); }; const page = new Function('exports', 'require', js + '\nreturn {items,sendPayload};')({}, require); const tick = () => new Promise((resolve) => setImmediate(resolve)); hooks.onLoad({ id: '10', userId: '2' }); hooks.onShow(); await tick(); assert.equal(state.activeConversationId, 10); assert.equal(historyRequests, 1); await page.sendPayload(1, { text: 'hello' }); assert.equal(page.items.value.length, 1); assert.equal(page.items.value[0].id, 7); assert.equal(reads.length, 0, 'an outgoing echo must not acknowledge incoming messages'); const incoming = { id: 8, seq: 3, senderId: 2, conversationId: 10, clientMsgId: 'peer-id' }; await listener({ command: 'MESSAGE_PUSH', data: incoming }); await listener({ command: 'MESSAGE_PUSH', data: incoming }); assert.equal(page.items.value.length, 2); assert.deepEqual(reads, [[10, 3], [10, 3]]); assert.ok(totalsRefreshes >= 3); hooks.onHide(); const count = reads.length; await listener({ command: 'MESSAGE_PUSH', data: { ...incoming, id: 9, seq: 4 } }); assert.equal(reads.length, count); assert.equal(state.activeConversationId, 0); history = [incoming]; hooks.onShow(); await tick(); assert.equal(historyRequests, 2); hooks.onUnload(); });