SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,532 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
at,
|
||||
atAll,
|
||||
br,
|
||||
chain,
|
||||
contact,
|
||||
face,
|
||||
forward,
|
||||
image,
|
||||
json,
|
||||
location,
|
||||
MessageChain,
|
||||
music,
|
||||
node,
|
||||
normalizeMessage,
|
||||
poke,
|
||||
raw,
|
||||
record,
|
||||
reply,
|
||||
share,
|
||||
text,
|
||||
video,
|
||||
xml,
|
||||
} from '../src';
|
||||
|
||||
function sender() {
|
||||
return {
|
||||
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 61001 }),
|
||||
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 61002 }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MessageChain.empty and chain()', () => {
|
||||
it('starts empty', () => {
|
||||
const empty = MessageChain.empty();
|
||||
|
||||
expect(empty).toBeInstanceOf(MessageChain);
|
||||
expect(empty.length).toBe(0);
|
||||
expect(empty.isEmpty).toBe(true);
|
||||
expect(empty.toSegments()).toEqual([]);
|
||||
expect(chain().toSegments()).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates length and isEmpty after a segment is added', () => {
|
||||
const next = chain().text('wave');
|
||||
|
||||
expect(next.length).toBe(1);
|
||||
expect(next.isEmpty).toBe(false);
|
||||
expect(chain().isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mutate the previous chain when a segment is added', () => {
|
||||
const base = text('keep');
|
||||
const next = base.face(39);
|
||||
|
||||
expect(base.toSegments()).toEqual([{ type: 'text', data: { text: 'keep' } }]);
|
||||
expect(next.toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'keep' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('segment builders', () => {
|
||||
it('builds a text segment and defaults to an empty string', () => {
|
||||
expect(text().toSegments()).toEqual([{ type: 'text', data: { text: '' } }]);
|
||||
expect(text('wave').toSegments()).toEqual([{ type: 'text', data: { text: 'wave' } }]);
|
||||
expect(chain().text().toSegments()).toEqual([{ type: 'text', data: { text: '' } }]);
|
||||
});
|
||||
|
||||
it('treats br as a newline text segment', () => {
|
||||
expect(br().toSegments()).toEqual([{ type: 'text', data: { text: '\n' } }]);
|
||||
});
|
||||
|
||||
it('stringifies face ids', () => {
|
||||
expect(face(39).toSegments()).toEqual([{ type: 'face', data: { id: '39' } }]);
|
||||
expect(face('178').toSegments()).toEqual([{ type: 'face', data: { id: '178' } }]);
|
||||
});
|
||||
|
||||
it('stringifies at targets and only copies truthy name/uid', () => {
|
||||
expect(at(31415).toSegments()).toEqual([{ type: 'at', data: { qq: '31415' } }]);
|
||||
expect(at('all').toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
|
||||
expect(atAll().toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
|
||||
expect(at(31415, { name: 'Ada', uid: 'uid-ada' }).toSegments()).toEqual([
|
||||
{ type: 'at', data: { qq: '31415', name: 'Ada', uid: 'uid-ada' } },
|
||||
]);
|
||||
expect(at(31415, { name: '', uid: '' }).toSegments()).toEqual([
|
||||
{ type: 'at', data: { qq: '31415' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stringifies reply ids', () => {
|
||||
expect(reply(88).toSegments()).toEqual([{ type: 'reply', data: { id: '88' } }]);
|
||||
expect(reply('mid-88').toSegments()).toEqual([{ type: 'reply', data: { id: 'mid-88' } }]);
|
||||
});
|
||||
|
||||
it('spreads image, record, and video options after file', () => {
|
||||
expect(image('file://pic.webp').toSegments()).toEqual([
|
||||
{ type: 'image', data: { file: 'file://pic.webp' } },
|
||||
]);
|
||||
expect(
|
||||
image('file://pic.webp', {
|
||||
type: 'flash',
|
||||
subType: 4,
|
||||
summary: 'cover',
|
||||
url: 'https://cdn.example/pic.webp',
|
||||
}).toSegments(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'image',
|
||||
data: {
|
||||
file: 'file://pic.webp',
|
||||
type: 'flash',
|
||||
subType: 4,
|
||||
summary: 'cover',
|
||||
url: 'https://cdn.example/pic.webp',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(record('file://voice.amr').toSegments()).toEqual([
|
||||
{ type: 'record', data: { file: 'file://voice.amr' } },
|
||||
]);
|
||||
expect(
|
||||
record('file://voice.amr', { url: 'https://cdn.example/voice.amr' }).toSegments(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'record',
|
||||
data: { file: 'file://voice.amr', url: 'https://cdn.example/voice.amr' },
|
||||
},
|
||||
]);
|
||||
expect(video('file://clip.mp4').toSegments()).toEqual([
|
||||
{ type: 'video', data: { file: 'file://clip.mp4' } },
|
||||
]);
|
||||
expect(
|
||||
video('file://clip.mp4', {
|
||||
url: 'https://cdn.example/clip.mp4',
|
||||
thumb: 'file://thumb.jpg',
|
||||
}).toSegments(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'video',
|
||||
data: {
|
||||
file: 'file://clip.mp4',
|
||||
url: 'https://cdn.example/clip.mp4',
|
||||
thumb: 'file://thumb.jpg',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a json string and stringifies a json object', () => {
|
||||
expect(json('{"prompt":"card"}').toSegments()).toEqual([
|
||||
{ type: 'json', data: { data: '{"prompt":"card"}' } },
|
||||
]);
|
||||
expect(json({ prompt: 'card', n: 2 }).toSegments()).toEqual([
|
||||
{ type: 'json', data: { data: '{"prompt":"card","n":2}' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('spreads xml options after the payload', () => {
|
||||
expect(xml('<item />').toSegments()).toEqual([{ type: 'xml', data: { data: '<item />' } }]);
|
||||
expect(xml('<item />', { id: 'resid-3' }).toSegments()).toEqual([
|
||||
{ type: 'xml', data: { data: '<item />', id: 'resid-3' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits poke id only when it is undefined', () => {
|
||||
expect(poke(6).toSegments()).toEqual([{ type: 'poke', data: { type: 6 } }]);
|
||||
expect(poke(6, 0).toSegments()).toEqual([{ type: 'poke', data: { type: 6, id: 0 } }]);
|
||||
expect(poke('6', '-1').toSegments()).toEqual([
|
||||
{ type: 'poke', data: { type: '6', id: '-1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('mirrors a forward id onto res_id and forward_id', () => {
|
||||
expect(forward('fwd-22').toSegments()).toEqual([
|
||||
{ type: 'forward', data: { id: 'fwd-22', res_id: 'fwd-22', forward_id: 'fwd-22' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores node content as given', () => {
|
||||
const inner = text('nested');
|
||||
|
||||
expect(node(20002, 'bot', 'plain').toSegments()).toEqual([
|
||||
{ type: 'node', data: { user_id: 20002, nickname: 'bot', content: 'plain' } },
|
||||
]);
|
||||
expect(node(20002, 'bot', { type: 'text', data: { text: 'seg' } }).toSegments()).toEqual([
|
||||
{
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: 20002,
|
||||
nickname: 'bot',
|
||||
content: { type: 'text', data: { text: 'seg' } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(node(20002, 'bot', inner).toSegments()).toEqual([
|
||||
{ type: 'node', data: { user_id: 20002, nickname: 'bot', content: inner } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes share, music, location, and contact data through', () => {
|
||||
expect(share({ url: 'https://ex.test/page', title: 'Page' }).toSegments()).toEqual([
|
||||
{ type: 'share', data: { url: 'https://ex.test/page', title: 'Page' } },
|
||||
]);
|
||||
expect(
|
||||
share({
|
||||
url: 'https://ex.test/page',
|
||||
title: 'Page',
|
||||
content: 'blurb',
|
||||
image: 'https://ex.test/cover.png',
|
||||
}).toSegments(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'share',
|
||||
data: {
|
||||
url: 'https://ex.test/page',
|
||||
title: 'Page',
|
||||
content: 'blurb',
|
||||
image: 'https://ex.test/cover.png',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(music({ type: 'qq', id: '991' }).toSegments()).toEqual([
|
||||
{ type: 'music', data: { type: 'qq', id: '991' } },
|
||||
]);
|
||||
expect(
|
||||
location({
|
||||
lat: 30.1,
|
||||
lon: 120.2,
|
||||
title: 'Hangzhou',
|
||||
content: 'West Lake',
|
||||
}).toSegments(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'location',
|
||||
data: { lat: 30.1, lon: 120.2, title: 'Hangzhou', content: 'West Lake' },
|
||||
},
|
||||
]);
|
||||
expect(contact('qq', 20002).toSegments()).toEqual([
|
||||
{ type: 'contact', data: { type: 'qq', id: 20002 } },
|
||||
]);
|
||||
expect(contact('group', '987654').toSegments()).toEqual([
|
||||
{ type: 'contact', data: { type: 'group', id: '987654' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds an arbitrary raw segment', () => {
|
||||
expect(raw('dice', { result: 3 }).toSegments()).toEqual([
|
||||
{ type: 'dice', data: { result: 3 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('chains fluent builders in order', () => {
|
||||
const built = chain().text('ping').at(20002).br().image('file://b.jpg').face(39);
|
||||
|
||||
expect(built.length).toBe(5);
|
||||
expect(built.toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'ping' } },
|
||||
{ type: 'at', data: { qq: '20002' } },
|
||||
{ type: 'text', data: { text: '\n' } },
|
||||
{ type: 'image', data: { file: 'file://b.jpg' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
it('appends a string as a text segment', () => {
|
||||
expect(text('pre').append('post').toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'pre' } },
|
||||
{ type: 'text', data: { text: 'post' } },
|
||||
]);
|
||||
expect(text('pre').append('').toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'pre' } },
|
||||
{ type: 'text', data: { text: '' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the same instance when appending an empty array', () => {
|
||||
const base = text('keep');
|
||||
expect(base.append([])).toBe(base);
|
||||
});
|
||||
|
||||
it('appends a single segment', () => {
|
||||
expect(
|
||||
text('pre')
|
||||
.append({ type: 'face', data: { id: '39' } })
|
||||
.toSegments(),
|
||||
).toEqual([
|
||||
{ type: 'text', data: { text: 'pre' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a segment array', () => {
|
||||
expect(
|
||||
text('pre')
|
||||
.append([
|
||||
{ type: 'at', data: { qq: 'all' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
])
|
||||
.toSegments(),
|
||||
).toEqual([
|
||||
{ type: 'text', data: { text: 'pre' } },
|
||||
{ type: 'at', data: { qq: 'all' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends another chain by copying its segments', () => {
|
||||
const extra = at(20002).text('!');
|
||||
expect(text('pre').append(extra).toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'pre' } },
|
||||
{ type: 'at', data: { qq: '20002' } },
|
||||
{ type: 'text', data: { text: '!' } },
|
||||
]);
|
||||
expect(extra.toSegments()).toEqual([
|
||||
{ type: 'at', data: { qq: '20002' } },
|
||||
{ type: 'text', data: { text: '!' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a duck-typed chain-like value via toSegments', () => {
|
||||
const duck = {
|
||||
length: 1,
|
||||
toSegments() {
|
||||
return [{ type: 'poke', data: { type: 6 } }];
|
||||
},
|
||||
toJSON() {
|
||||
return this.toSegments();
|
||||
},
|
||||
};
|
||||
|
||||
expect(text('pre').append(duck).toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'pre' } },
|
||||
{ type: 'poke', data: { type: 6 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-use reply', () => {
|
||||
it('throws when reply is added twice through reply()', () => {
|
||||
const once = reply(88);
|
||||
expect(() => (once as unknown as { reply(id: number): unknown }).reply(89)).toThrow(
|
||||
'Message segment "reply" can only appear once in a chain',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a second reply is appended', () => {
|
||||
expect(() => reply(88).append({ type: 'reply', data: { id: '89' } })).toThrow(
|
||||
'Message segment "reply" can only appear once in a chain',
|
||||
);
|
||||
expect(() =>
|
||||
chain().append([
|
||||
{ type: 'reply', data: { id: '88' } },
|
||||
{ type: 'text', data: { text: 'x' } },
|
||||
{ type: 'reply', data: { id: '89' } },
|
||||
]),
|
||||
).toThrow('Message segment "reply" can only appear once in a chain');
|
||||
});
|
||||
|
||||
it('throws when raw reply is added twice', () => {
|
||||
expect(() => raw('reply', { id: '88' }).raw('reply', { id: '89' })).toThrow(
|
||||
'Message segment "reply" can only appear once in a chain',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows two independent chains to each have their own reply', () => {
|
||||
const base = text('fork');
|
||||
expect(base.reply(88).toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'fork' } },
|
||||
{ type: 'reply', data: { id: '88' } },
|
||||
]);
|
||||
expect(base.reply(89).toSegments()).toEqual([
|
||||
{ type: 'text', data: { text: 'fork' } },
|
||||
{ type: 'reply', data: { id: '89' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows non-reply types, including raw, to appear more than once', () => {
|
||||
expect(image('a.webp').image('b.webp').toSegments()).toEqual([
|
||||
{ type: 'image', data: { file: 'a.webp' } },
|
||||
{ type: 'image', data: { file: 'b.webp' } },
|
||||
]);
|
||||
expect(raw('dice', { result: 1 }).raw('dice', { result: 2 }).toSegments()).toEqual([
|
||||
{ type: 'dice', data: { result: 1 } },
|
||||
{ type: 'dice', data: { result: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still allows other segments after a reply', () => {
|
||||
expect(reply(88).text('after').toSegments()).toEqual([
|
||||
{ type: 'reply', data: { id: '88' } },
|
||||
{ type: 'text', data: { text: 'after' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization and iteration', () => {
|
||||
it('returns a shallow copy from build, toArray, toSegments, and toJSON', () => {
|
||||
const built = text('copy').at(20002);
|
||||
const viaBuild = built.build();
|
||||
const viaArray = built.toArray();
|
||||
const viaSegments = built.toSegments();
|
||||
const viaJson = built.toJSON();
|
||||
const expected = [
|
||||
{ type: 'text', data: { text: 'copy' } },
|
||||
{ type: 'at', data: { qq: '20002' } },
|
||||
];
|
||||
|
||||
expect(viaBuild).toEqual(expected);
|
||||
expect(viaArray).toEqual(expected);
|
||||
expect(viaSegments).toEqual(expected);
|
||||
expect(viaJson).toEqual(expected);
|
||||
expect(viaBuild).not.toBe(viaArray);
|
||||
expect(viaArray).not.toBe(viaSegments);
|
||||
expect(viaSegments).not.toBe(viaJson);
|
||||
|
||||
viaBuild.push({ type: 'face', data: { id: '1' } });
|
||||
expect(built.toSegments()).toEqual(expected);
|
||||
expect(built.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses toJSON when stringified', () => {
|
||||
expect(JSON.parse(JSON.stringify(text('copy').at(20002)))).toEqual([
|
||||
{ type: 'text', data: { text: 'copy' } },
|
||||
{ type: 'at', data: { qq: '20002' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders CQ text through toString', () => {
|
||||
expect(chain().toString()).toBe('');
|
||||
expect(text('A&B[C]').toString()).toBe('A&B[C]');
|
||||
expect(text('wave').at(20002).face(39).toString()).toBe(
|
||||
'wave[CQ:at,qq=20002][CQ:face,id=39]',
|
||||
);
|
||||
expect(image('a,b').toString()).toBe('[CQ:image,file=a,b]');
|
||||
expect(raw('dice', {}).toString()).toBe('[CQ:dice]');
|
||||
expect(
|
||||
chain()
|
||||
.append({ type: 'image', data: { file: 'z.webp', url: undefined } })
|
||||
.toString(),
|
||||
).toBe('[CQ:image,file=z.webp]');
|
||||
});
|
||||
|
||||
it('iterates stored segments', () => {
|
||||
const built = text('wave').face(39);
|
||||
expect([...built]).toEqual([
|
||||
{ type: 'text', data: { text: 'wave' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
]);
|
||||
|
||||
const walked: unknown[] = [];
|
||||
|
||||
for (const segment of built) {
|
||||
walked.push(segment);
|
||||
}
|
||||
|
||||
expect(walked).toEqual([
|
||||
{ type: 'text', data: { text: 'wave' } },
|
||||
{ type: 'face', data: { id: '39' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendToGroup and sendToPrivate', () => {
|
||||
it('forwards the chain instance and options to the client', async () => {
|
||||
const client = sender();
|
||||
const groupPayload = text('group-hi');
|
||||
const privatePayload = text('priv-hi');
|
||||
const options = { autoEscape: true, timeoutMs: 2500, echo: 'echo-chain' };
|
||||
|
||||
await expect(groupPayload.sendToGroup(client, 70001, options)).resolves.toEqual({
|
||||
message_id: 61001,
|
||||
});
|
||||
await expect(privatePayload.sendToPrivate(client, 80001, options)).resolves.toEqual({
|
||||
message_id: 61002,
|
||||
});
|
||||
|
||||
expect(client.sendGroupMessage).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendGroupMessage).toHaveBeenCalledWith(70001, groupPayload, options);
|
||||
expect(client.sendPrivateMessage).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendPrivateMessage).toHaveBeenCalledWith(80001, privatePayload, options);
|
||||
});
|
||||
|
||||
it('passes undefined options when none are given', async () => {
|
||||
const client = sender();
|
||||
const payload = text('plain');
|
||||
|
||||
await payload.sendToGroup(client, 70002);
|
||||
await payload.sendToPrivate(client, 80002);
|
||||
|
||||
expect(client.sendGroupMessage).toHaveBeenCalledWith(70002, payload, undefined);
|
||||
expect(client.sendPrivateMessage).toHaveBeenCalledWith(80002, payload, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMessage', () => {
|
||||
it('returns a string unchanged', () => {
|
||||
expect(normalizeMessage('plain-text')).toBe('plain-text');
|
||||
expect(normalizeMessage('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns toSegments() for a chain and for duck-typed chain-like values', () => {
|
||||
expect(normalizeMessage(text('wave').at(20002))).toEqual([
|
||||
{ type: 'text', data: { text: 'wave' } },
|
||||
{ type: 'at', data: { qq: '20002' } },
|
||||
]);
|
||||
|
||||
const segs = [{ type: 'face', data: { id: '39' } }];
|
||||
const duck = { toSegments: () => segs };
|
||||
expect(normalizeMessage(duck)).toBe(segs);
|
||||
});
|
||||
|
||||
it('returns an array input by reference', () => {
|
||||
const segs = [{ type: 'text', data: { text: 'arr' } }];
|
||||
expect(normalizeMessage(segs)).toBe(segs);
|
||||
});
|
||||
|
||||
it('wraps a single segment in a new array', () => {
|
||||
const segment = { type: 'text', data: { text: 'solo' } };
|
||||
expect(normalizeMessage(segment)).toEqual([segment]);
|
||||
expect(normalizeMessage(segment)).not.toBe(segment);
|
||||
});
|
||||
|
||||
it('does not treat an object with a non-function toSegments as a chain', () => {
|
||||
const value = { type: 'text', data: { text: 'solo' }, toSegments: 0 };
|
||||
expect(normalizeMessage(value as never)).toEqual([value]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user