@folotoy/folotoy-openclaw-plugin 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,51 +2,110 @@
2
2
  "id": "folotoy-openclaw-plugin",
3
3
  "name": "FoloToy",
4
4
  "description": "Empower your FoloToy with OpenClaw AI capabilities.",
5
- "version": "0.6.2",
6
- "channels": ["folotoy"],
5
+ "version": "0.8.0",
6
+ "channels": [
7
+ "folotoy"
8
+ ],
7
9
  "configSchema": {
8
10
  "type": "object",
9
11
  "additionalProperties": false,
10
- "properties": {
11
- "flow": {
12
- "type": "string",
13
- "enum": ["direct", "api"],
14
- "description": "Authentication flow",
15
- "default": "direct"
16
- },
17
- "toy_sn": {
18
- "type": "string",
19
- "description": "Toy serial number"
20
- },
21
- "toy_key": {
22
- "type": "string",
23
- "description": "Toy key (direct flow)"
24
- },
25
- "api_url": {
26
- "type": "string",
27
- "description": "FoloToy API base URL (api flow)"
28
- },
29
- "api_key": {
30
- "type": "string",
31
- "description": "FoloToy API key (api flow)"
32
- },
33
- "mqtt_host": {
34
- "type": "string",
35
- "description": "MQTT broker host"
36
- },
37
- "mqtt_port": {
38
- "type": "number",
39
- "description": "MQTT broker port"
12
+ "properties": {}
13
+ },
14
+ "channelConfigs": {
15
+ "folotoy": {
16
+ "label": "FoloToy",
17
+ "description": "FoloToy smart toy channel — bridges toys to OpenClaw via MQTT.",
18
+ "schema": {
19
+ "$schema": "http://json-schema.org/draft-07/schema#",
20
+ "type": "object",
21
+ "properties": {
22
+ "flow": {
23
+ "default": "direct",
24
+ "description": "Auth flow",
25
+ "type": "string",
26
+ "enum": [
27
+ "direct",
28
+ "api"
29
+ ]
30
+ },
31
+ "toy_sn": {
32
+ "description": "Toy serial number",
33
+ "type": "string"
34
+ },
35
+ "toy_key": {
36
+ "description": "Toy key (used as MQTT password in direct flow)",
37
+ "type": "string"
38
+ },
39
+ "api_url": {
40
+ "default": "https://api.folotoy.cn",
41
+ "description": "FoloToy API base URL (api flow)",
42
+ "type": "string"
43
+ },
44
+ "api_key": {
45
+ "description": "FoloToy API key (api flow)",
46
+ "type": "string"
47
+ },
48
+ "mqtt_host": {
49
+ "default": "f.folotoy.cn",
50
+ "description": "MQTT broker host",
51
+ "type": "string"
52
+ },
53
+ "mqtt_port": {
54
+ "default": 1883,
55
+ "description": "MQTT broker port",
56
+ "type": "integer",
57
+ "minimum": -9007199254740991,
58
+ "maximum": 9007199254740991
59
+ },
60
+ "summary_enabled": {
61
+ "default": true,
62
+ "description": "Enable reply summarization for over-long replies",
63
+ "type": "boolean"
64
+ },
65
+ "summary_max_chars": {
66
+ "default": 200,
67
+ "description": "Character threshold that triggers summarization",
68
+ "type": "integer",
69
+ "minimum": -9007199254740991,
70
+ "maximum": 9007199254740991
71
+ },
72
+ "sentence_split_enabled": {
73
+ "default": true,
74
+ "description": "Stream replies sentence-by-sentence for faster TTS",
75
+ "type": "boolean"
76
+ },
77
+ "sentence_split_delimiters": {
78
+ "default": "!。?;!.?;~",
79
+ "description": "Punctuation characters that split sentences",
80
+ "type": "string"
81
+ },
82
+ "soothing_loop_enabled": {
83
+ "default": true,
84
+ "description": "Repeat soothing replies while waiting for the LLM. Disable to send only the initial order=1 reply.",
85
+ "type": "boolean"
86
+ },
87
+ "soothing_loop_interval_ms": {
88
+ "default": 8000,
89
+ "description": "Interval (ms) between soothing replies during the LLM wait",
90
+ "type": "integer",
91
+ "minimum": -9007199254740991,
92
+ "maximum": 9007199254740991
93
+ }
94
+ },
95
+ "required": [
96
+ "flow",
97
+ "api_url",
98
+ "mqtt_host",
99
+ "mqtt_port",
100
+ "summary_enabled",
101
+ "summary_max_chars",
102
+ "sentence_split_enabled",
103
+ "sentence_split_delimiters",
104
+ "soothing_loop_enabled",
105
+ "soothing_loop_interval_ms"
106
+ ],
107
+ "additionalProperties": false
40
108
  }
41
109
  }
42
- },
43
- "uiHints": {
44
- "flow": { "label": "Auth Flow" },
45
- "toy_sn": { "label": "Toy SN" },
46
- "toy_key": { "label": "Toy Key", "sensitive": true },
47
- "api_url": { "label": "API URL", "placeholder": "https://api.folotoy.cn" },
48
- "api_key": { "label": "API Key", "sensitive": true },
49
- "mqtt_host": { "label": "MQTT Host", "placeholder": "198.19.249.25" },
50
- "mqtt_port": { "label": "MQTT Port", "placeholder": "1883" }
51
110
  }
52
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folotoy/folotoy-openclaw-plugin",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Empower your FoloToy with OpenClaw AI capabilities.",
5
5
  "keywords": [
6
6
  "folotoy",
@@ -22,17 +22,18 @@
22
22
  },
23
23
  "main": "./src/index.ts",
24
24
  "types": "./src/index.ts",
25
- "bin": {
26
- "folotoy-openclaw-plugin": "./bin/folotoy.mjs"
27
- },
28
25
  "files": [
29
- "src",
30
- "bin",
26
+ "src/index.ts",
27
+ "src/mqtt.ts",
28
+ "src/config.ts",
29
+ "src/config-schema.ts",
30
+ "src/soothing.ts",
31
+ "src/strip-markdown.ts",
31
32
  "dist",
32
33
  "openclaw.plugin.json"
33
34
  ],
34
35
  "scripts": {
35
- "build": "tsc",
36
+ "build": "tsc && node scripts/sync-manifest.mjs",
36
37
  "prepublishOnly": "npm run build",
37
38
  "dev": "tsc --watch",
38
39
  "typecheck": "tsc --noEmit",
@@ -42,7 +43,7 @@
42
43
  },
43
44
  "dependencies": {
44
45
  "mqtt": "^5.10.1",
45
- "qrcode-terminal": "^0.12.0"
46
+ "zod": "^4.0.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^22.13.10",
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod'
2
+
3
+ import {
4
+ DEFAULT_API_URL,
5
+ DEFAULT_MQTT_HOST,
6
+ DEFAULT_MQTT_PORT,
7
+ DEFAULT_SENTENCE_SPLIT_DELIMITERS,
8
+ DEFAULT_SENTENCE_SPLIT_ENABLED,
9
+ DEFAULT_SOOTHING_LOOP_ENABLED,
10
+ DEFAULT_SOOTHING_LOOP_INTERVAL_MS,
11
+ DEFAULT_SUMMARY_ENABLED,
12
+ DEFAULT_SUMMARY_MAX_CHARS,
13
+ } from './config.js'
14
+
15
+ /**
16
+ * Top-level zod schema for `channels.folotoy.*` config.
17
+ *
18
+ * Used both as runtime input validation (via openclaw plugin SDK's
19
+ * `buildChannelConfigSchema()`) and as the source of the JSON Schema that
20
+ * OpenClaw's web UI renders. OpenClaw 2026.4.x's UI requires a zod-derived
21
+ * schema for form rendering — a raw JSON Schema in the old shape causes
22
+ * "Unsupported type" / "Use Raw mode" fallback.
23
+ *
24
+ * Field set matches the flat config currently consumed by index.ts:
25
+ * `account.<field> ?? DEFAULT_*`.
26
+ */
27
+ export const FolotoyConfigSchema = z.object({
28
+ flow: z.enum(['direct', 'api']).default('direct').describe('Auth flow'),
29
+
30
+ toy_sn: z.string().optional().describe('Toy serial number'),
31
+ toy_key: z.string().optional().describe('Toy key (used as MQTT password in direct flow)'),
32
+
33
+ api_url: z.string().default(DEFAULT_API_URL).describe('FoloToy API base URL (api flow)'),
34
+ api_key: z.string().optional().describe('FoloToy API key (api flow)'),
35
+
36
+ mqtt_host: z.string().default(DEFAULT_MQTT_HOST).describe('MQTT broker host'),
37
+ mqtt_port: z.number().int().default(DEFAULT_MQTT_PORT).describe('MQTT broker port'),
38
+
39
+ summary_enabled: z
40
+ .boolean()
41
+ .default(DEFAULT_SUMMARY_ENABLED)
42
+ .describe('Enable reply summarization for over-long replies'),
43
+ summary_max_chars: z
44
+ .number()
45
+ .int()
46
+ .default(DEFAULT_SUMMARY_MAX_CHARS)
47
+ .describe('Character threshold that triggers summarization'),
48
+
49
+ sentence_split_enabled: z
50
+ .boolean()
51
+ .default(DEFAULT_SENTENCE_SPLIT_ENABLED)
52
+ .describe('Stream replies sentence-by-sentence for faster TTS'),
53
+ sentence_split_delimiters: z
54
+ .string()
55
+ .default(DEFAULT_SENTENCE_SPLIT_DELIMITERS)
56
+ .describe('Punctuation characters that split sentences'),
57
+
58
+ soothing_loop_enabled: z
59
+ .boolean()
60
+ .default(DEFAULT_SOOTHING_LOOP_ENABLED)
61
+ .describe(
62
+ 'Repeat soothing replies while waiting for the LLM. Disable to send only the initial order=1 reply.',
63
+ ),
64
+ soothing_loop_interval_ms: z
65
+ .number()
66
+ .int()
67
+ .default(DEFAULT_SOOTHING_LOOP_INTERVAL_MS)
68
+ .describe('Interval (ms) between soothing replies during the LLM wait'),
69
+ })
70
+
71
+ export type FolotoyConfig = z.infer<typeof FolotoyConfigSchema>
package/src/config.ts CHANGED
@@ -44,7 +44,7 @@ export const DEFAULT_SUMMARY_MAX_CHARS = 200
44
44
  export const DEFAULT_SENTENCE_SPLIT_ENABLED = true
45
45
  export const DEFAULT_SENTENCE_SPLIT_DELIMITERS = '!。?;!.?;~'
46
46
  export const DEFAULT_SOOTHING_LOOP_ENABLED = true
47
- export const DEFAULT_SOOTHING_LOOP_INTERVAL_MS = 3000
47
+ export const DEFAULT_SOOTHING_LOOP_INTERVAL_MS = 8000
48
48
 
49
49
  export function flatToPluginConfig(flat: FlatChannelConfig): PluginConfig {
50
50
  const flow = flat.flow ?? 'direct'
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { OpenClawPluginApi, ChannelPlugin, PluginRuntime } from 'openclaw/plugin-sdk/core'
2
+ import { buildChannelConfigSchema } from 'openclaw/plugin-sdk'
2
3
  import { resolveCredentials, createMqttClient, buildInboundTopic, buildOutboundTopic, buildNotificationTopic } from './mqtt.js'
3
4
  import { createSoothingPicker } from './soothing.js'
4
5
  import { stripMarkdown } from './strip-markdown.js'
5
- import { DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT, DEFAULT_SUMMARY_ENABLED, DEFAULT_SUMMARY_MAX_CHARS, DEFAULT_SENTENCE_SPLIT_ENABLED, DEFAULT_SENTENCE_SPLIT_DELIMITERS, DEFAULT_SOOTHING_LOOP_ENABLED, DEFAULT_SOOTHING_LOOP_INTERVAL_MS, flatToPluginConfig } from './config.js'
6
+ import { DEFAULT_SUMMARY_ENABLED, DEFAULT_SUMMARY_MAX_CHARS, DEFAULT_SENTENCE_SPLIT_ENABLED, DEFAULT_SENTENCE_SPLIT_DELIMITERS, DEFAULT_SOOTHING_LOOP_ENABLED, DEFAULT_SOOTHING_LOOP_INTERVAL_MS, flatToPluginConfig } from './config.js'
6
7
  import type { FlatChannelConfig } from './config.js'
8
+ import { FolotoyConfigSchema } from './config-schema.js'
7
9
  import type { MqttClient } from 'mqtt'
8
10
 
9
11
  type InboundMessage = {
@@ -42,40 +44,12 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
42
44
  capabilities: {
43
45
  chatTypes: ['direct'],
44
46
  },
47
+ // ChannelPlugin's per-channel configSchema is left empty here on purpose:
48
+ // the plugin-level configSchema (set on the default-exported plugin object
49
+ // below) is what OpenClaw 2026.4.x's web UI reads. Mirrors what
50
+ // openclaw-weixin and other 2026.4.x-compatible plugins do.
45
51
  configSchema: {
46
- schema: {
47
- type: 'object',
48
- properties: {
49
- flow: { type: 'string', enum: ['direct', 'api'], default: 'direct' },
50
- toy_sn: { type: 'string' },
51
- toy_key: { type: 'string' },
52
- api_url: { type: 'string', default: 'https://api.folotoy.cn' },
53
- api_key: { type: 'string' },
54
- mqtt_host: { type: 'string', default: DEFAULT_MQTT_HOST },
55
- mqtt_port: { type: 'number', default: DEFAULT_MQTT_PORT },
56
- summary_enabled: { type: 'boolean', default: DEFAULT_SUMMARY_ENABLED },
57
- summary_max_chars: { type: 'number', default: DEFAULT_SUMMARY_MAX_CHARS },
58
- sentence_split_enabled: { type: 'boolean', default: DEFAULT_SENTENCE_SPLIT_ENABLED },
59
- sentence_split_delimiters: { type: 'string', default: DEFAULT_SENTENCE_SPLIT_DELIMITERS },
60
- soothing_loop_enabled: { type: 'boolean', default: DEFAULT_SOOTHING_LOOP_ENABLED },
61
- soothing_loop_interval_ms: { type: 'number', default: DEFAULT_SOOTHING_LOOP_INTERVAL_MS },
62
- },
63
- },
64
- uiHints: {
65
- flow: { label: 'Auth Flow' },
66
- toy_sn: { label: 'Toy SN' },
67
- toy_key: { label: 'Toy Key', sensitive: true },
68
- api_url: { label: 'API URL', placeholder: 'https://api.folotoy.com' },
69
- api_key: { label: 'API Key', sensitive: true },
70
- mqtt_host: { label: 'MQTT Host', placeholder: DEFAULT_MQTT_HOST },
71
- mqtt_port: { label: 'MQTT Port' },
72
- summary_enabled: { label: 'Enable Summary' },
73
- summary_max_chars: { label: 'Summary Max Characters' },
74
- sentence_split_enabled: { label: 'Enable Sentence Splitting' },
75
- sentence_split_delimiters: { label: 'Sentence Delimiters' },
76
- soothing_loop_enabled: { label: 'Enable Soothing Loop' },
77
- soothing_loop_interval_ms: { label: 'Soothing Loop Interval (ms)' },
78
- },
52
+ schema: { type: 'object', additionalProperties: false, properties: {} },
79
53
  },
80
54
  config: {
81
55
  listAccountIds: (cfg) => {
@@ -455,7 +429,21 @@ export function sendNotification({ text, accountId }: { text: string; accountId?
455
429
  return { channel: 'folotoy', messageId: String(msgId) }
456
430
  }
457
431
 
458
- export default (api: OpenClawPluginApi) => {
459
- subagent = api.runtime.subagent
460
- api.registerChannel({ plugin: folotoyChannel })
432
+ /**
433
+ * Plugin manifest exposed to OpenClaw. Top-level fields (id/name/description/
434
+ * configSchema) are what the OpenClaw 2026.4.x web UI reads to render the
435
+ * config form — the per-channel configSchema on `folotoyChannel` above is
436
+ * intentionally empty so the UI uses this one.
437
+ */
438
+ const folotoyPlugin = {
439
+ id: 'folotoy-openclaw-plugin',
440
+ name: 'FoloToy',
441
+ description: 'Empower your FoloToy with OpenClaw AI capabilities.',
442
+ configSchema: buildChannelConfigSchema(FolotoyConfigSchema),
443
+ register(api: OpenClawPluginApi) {
444
+ subagent = api.runtime.subagent
445
+ api.registerChannel({ plugin: folotoyChannel })
446
+ },
461
447
  }
448
+
449
+ export default folotoyPlugin
package/bin/folotoy.mjs DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- import('../dist/cli/install.js')
@@ -1,126 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { EventEmitter } from 'events'
3
- import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
4
-
5
- // Replicate the message parsing logic from index.ts for unit testing
6
- type InboundMessage = {
7
- msgId: number
8
- identifier: 'chat_input'
9
- inputParams: { text: string; recording_id: number }
10
- }
11
-
12
- type OutboundMessage = {
13
- msgId: number
14
- identifier: 'chat_output'
15
- outParams: { content: string; recording_id: number; order: number; is_finished: boolean }
16
- }
17
-
18
- function makeMockClient() {
19
- const emitter = new EventEmitter()
20
- return Object.assign(emitter, {
21
- subscribe: vi.fn((_topic: string, cb: (err: null) => void) => cb(null)),
22
- publish: vi.fn(),
23
- })
24
- }
25
-
26
- function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: string, onMessage: (msgId: number, text: string, recording_id: number) => void) {
27
- const topic = buildInboundTopic(toy_sn)
28
- client.subscribe(topic, () => {})
29
- client.on('message', (_topic: string, payload: Buffer) => {
30
- if (_topic !== topic) return
31
- try {
32
- const msg = JSON.parse(payload.toString()) as InboundMessage
33
- if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
34
- onMessage(msg.msgId, msg.inputParams.text, msg.inputParams.recording_id)
35
- } catch {
36
- // ignore
37
- }
38
- })
39
- }
40
-
41
- describe('inbound message parsing', () => {
42
- const toy_sn = 'SN001'
43
- const inboundTopic = buildInboundTopic(toy_sn)
44
-
45
- it('calls onMessage with msgId, text and recording_id on valid chat_input', () => {
46
- const client = makeMockClient()
47
- const onMessage = vi.fn()
48
- setupSubscriber(client, toy_sn, onMessage)
49
-
50
- const msg: InboundMessage = { msgId: 42, identifier: 'chat_input', inputParams: { text: 'hello', recording_id: 100 } }
51
- client.emit('message', inboundTopic, Buffer.from(JSON.stringify(msg)))
52
-
53
- expect(onMessage).toHaveBeenCalledWith(42, 'hello', 100)
54
- })
55
-
56
- it('ignores messages on other topics', () => {
57
- const client = makeMockClient()
58
- const onMessage = vi.fn()
59
- setupSubscriber(client, toy_sn, onMessage)
60
-
61
- const msg: InboundMessage = { msgId: 1, identifier: 'chat_input', inputParams: { text: 'hi', recording_id: 1 } }
62
- client.emit('message', '/openapi/folotoy/OTHER/thing/command/call', Buffer.from(JSON.stringify(msg)))
63
-
64
- expect(onMessage).not.toHaveBeenCalled()
65
- })
66
-
67
- it('ignores messages with unknown identifier', () => {
68
- const client = makeMockClient()
69
- const onMessage = vi.fn()
70
- setupSubscriber(client, toy_sn, onMessage)
71
-
72
- client.emit('message', inboundTopic, Buffer.from(JSON.stringify({ msgId: 1, identifier: 'other', inputParams: { text: 'hi', recording_id: 1 } })))
73
-
74
- expect(onMessage).not.toHaveBeenCalled()
75
- })
76
-
77
- it('ignores malformed JSON', () => {
78
- const client = makeMockClient()
79
- const onMessage = vi.fn()
80
- setupSubscriber(client, toy_sn, onMessage)
81
-
82
- client.emit('message', inboundTopic, Buffer.from('not json'))
83
-
84
- expect(onMessage).not.toHaveBeenCalled()
85
- })
86
- })
87
-
88
- describe('outbound message format', () => {
89
- const toy_sn = 'SN001'
90
- const outboundTopic = buildOutboundTopic(toy_sn)
91
-
92
- it('publishes chat_output with recording_id, order and is_finished', () => {
93
- const client = makeMockClient()
94
- const outMsg: OutboundMessage = {
95
- msgId: 42,
96
- identifier: 'chat_output',
97
- outParams: { content: 'world', recording_id: 100, order: 1, is_finished: false },
98
- }
99
- client.publish(outboundTopic, JSON.stringify(outMsg))
100
-
101
- expect(client.publish).toHaveBeenCalledOnce()
102
- const [t, payload] = client.publish.mock.calls[0] as [string, string]
103
- expect(t).toBe(outboundTopic)
104
- expect(JSON.parse(payload)).toEqual({
105
- msgId: 42,
106
- identifier: 'chat_output',
107
- outParams: { content: 'world', recording_id: 100, order: 1, is_finished: false },
108
- })
109
- })
110
-
111
- it('publishes finish message with is_finished=true', () => {
112
- const client = makeMockClient()
113
- const finishMsg: OutboundMessage = {
114
- msgId: 42,
115
- identifier: 'chat_output',
116
- outParams: { content: '', recording_id: 100, order: 2, is_finished: true },
117
- }
118
- client.publish(outboundTopic, JSON.stringify(finishMsg))
119
-
120
- const [, payload] = client.publish.mock.calls[0] as [string, string]
121
- const parsed = JSON.parse(payload)
122
- expect(parsed.outParams.is_finished).toBe(true)
123
- expect(parsed.outParams.order).toBe(2)
124
- expect(parsed.outParams.content).toBe('')
125
- })
126
- })
@@ -1,14 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
3
-
4
- describe('buildInboundTopic', () => {
5
- it('builds the correct inbound topic for a given SN', () => {
6
- expect(buildInboundTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/command/call')
7
- })
8
- })
9
-
10
- describe('buildOutboundTopic', () => {
11
- it('builds the correct outbound topic for a given SN', () => {
12
- expect(buildOutboundTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/command/callAck')
13
- })
14
- })
@@ -1,47 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { pickSoothingReply } from '../soothing.js'
3
-
4
- const CATEGORIES = [
5
- { input: '我今天好难过', label: '难过类' },
6
- { input: '我很生气', label: '生气类' },
7
- { input: '我好累', label: '累/怕类' },
8
- { input: '给我讲个故事', label: '故事类' },
9
- { input: '唱首儿歌', label: '唱歌类' },
10
- { input: '讲个笑话', label: '笑话类' },
11
- { input: '为什么天是蓝的', label: '知识类' },
12
- { input: '然后呢', label: '继续类' },
13
- { input: '你好', label: '打招呼类' },
14
- { input: '晚安', label: '告别类' },
15
- { input: '帮我连接wifi', label: '配网类' },
16
- { input: '帮我查天气', label: '查询类' },
17
- { input: '随便说点什么', label: '兜底' },
18
- ]
19
-
20
- describe('pickSoothingReply', () => {
21
- it.each(CATEGORIES)('$label — returns a non-empty string', ({ input }) => {
22
- const reply = pickSoothingReply(input)
23
- expect(typeof reply).toBe('string')
24
- expect(reply.length).toBeGreaterThan(0)
25
- })
26
-
27
- it.each(CATEGORIES)('$label — never contains 马上', ({ input }) => {
28
- // Run multiple times to cover random selection
29
- for (let i = 0; i < 20; i++) {
30
- expect(pickSoothingReply(input)).not.toContain('马上')
31
- }
32
- })
33
-
34
- it('randomly selects from candidates (not always the same reply)', () => {
35
- const results = new Set(
36
- Array.from({ length: 40 }, () => pickSoothingReply('我今天好难过'))
37
- )
38
- expect(results.size).toBeGreaterThan(1)
39
- })
40
-
41
- it('告别类 returns only short affirmatives', () => {
42
- const allowed = new Set(['好嘞~', '嗯嗯~', '好的~', '哦~', '嗯~'])
43
- for (let i = 0; i < 20; i++) {
44
- expect(allowed.has(pickSoothingReply('晚安'))).toBe(true)
45
- }
46
- })
47
- })