155 lines
8.0 KiB
JavaScript
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();
|
|
});
|