Files
2026-09-03 08:38:17 +08:00

155 lines
8.0 KiB
JavaScript

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();
});