You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
419 lines
17 KiB
419 lines
17 KiB
import 'dart:convert';
|
|
|
|
import 'package:eaimar/data/models/assistant_directive.dart';
|
|
import 'package:eaimar/data/models/memory_item.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// 用例里的 payload 全部是 2026-08-29 从真机上抓的原始帧(见 scratchpad 的
|
|
/// bailian_raw_frames.jsonl),不是手编的——协议里那几个坑(arguments 是
|
|
/// 字符串、参数是中文、纯指令帧 text 为空)只有照抄真实数据才守得住。
|
|
void main() {
|
|
Map<String, dynamic> outputOf(String raw) =>
|
|
(json.decode(raw)['payload']['output'] as Map).cast<String, dynamic>();
|
|
|
|
group('tool_calls 解析', () {
|
|
test('一次性闹钟:time + date', () {
|
|
final output = outputOf(r'''
|
|
{"payload":{"output":{"event":"RespondingContent",
|
|
"text":"好的,已为你设置5点的闹钟,别忘了准时参加哦!","finished":true,"finish_reason":"stop",
|
|
"extra_info":{"tool_calls":[{"function":{"name":"SET_clock",
|
|
"arguments":"{\"time\": \"17:00\", \"date\": \"2026-08-29\"}"},"type":"FUNCTION"}]},
|
|
"dialog_id":"d1","round_id":"r1","llm_request_id":"l1"}}}''');
|
|
|
|
final ds = AssistantDirective.parseFromOutput(output, source: 'emai');
|
|
expect(ds, hasLength(1));
|
|
expect(ds.first.name, 'SET_clock');
|
|
expect(ds.first.args['time'], '17:00');
|
|
expect(ds.first.args['date'], '2026-08-29');
|
|
expect(ds.first.roundId, 'r1');
|
|
|
|
final r = AssistantReminder.fromSetClock(ds.first)!;
|
|
expect(r.time, '17:00');
|
|
// ⚠️ 这里**不能**断言 '2026-08-29':帧是那天抓的,重放时早已是过去,
|
|
// 会被 rollForwardPastDate 挪到就近的未来(见下面那个 group)。
|
|
// 原始值仍由上面的 args['date'] 守着。
|
|
final today = DateTime.now();
|
|
expect(
|
|
DateTime.parse(r.date!).isBefore(DateTime(today.year, today.month, today.day)),
|
|
isFalse,
|
|
reason: '一次性闹钟不该落在过去',
|
|
);
|
|
expect(r.repeat, RepeatRule.once);
|
|
expect(r.content, isEmpty); // 这一轮模型没给 content
|
|
});
|
|
|
|
test('重复闹钟:content + repeat,没有 date', () {
|
|
final output = outputOf(r'''
|
|
{"payload":{"output":{"event":"RespondingContent","text":"好的","finished":true,
|
|
"finish_reason":"stop","extra_info":{"tool_calls":[{"function":{"name":"SET_clock",
|
|
"arguments":"{\"content\": \"开会\", \"time\": \"08:00\", \"repeat\": \"周一\"}"},
|
|
"type":"FUNCTION"}]},"dialog_id":"d","round_id":"r2","llm_request_id":"l"}}}''');
|
|
|
|
final r =
|
|
AssistantReminder.fromSetClock(AssistantDirective.parseFromOutput(
|
|
output,
|
|
source: 'emai',
|
|
).first)!;
|
|
expect(r.content, '开会');
|
|
expect(r.time, '08:00');
|
|
expect(r.date, isNull);
|
|
expect(r.repeat, RepeatRule.weekly);
|
|
expect(r.weekday, DateTime.monday);
|
|
expect(r.repeatRaw, '周一');
|
|
});
|
|
|
|
test('纯指令帧:text 为空,finish_reason=command_calls', () {
|
|
final output = outputOf(r'''
|
|
{"payload":{"output":{"event":"RespondingContent","text":"","spoken":"",
|
|
"finished":true,"finish_reason":"command_calls",
|
|
"extra_info":{"tool_calls":[{"function":{"name":"ROUTE_map",
|
|
"arguments":"{\"endLoc_city\": \"深圳市\", \"endLoc_poi\": \"机场\"}"},"type":"FUNCTION"}]},
|
|
"dialog_id":"d","round_id":"r3","llm_request_id":"l"}}}''');
|
|
|
|
expect(output['text'], isEmpty);
|
|
expect(output['finish_reason'], 'command_calls');
|
|
final ds = AssistantDirective.parseFromOutput(output, source: 'emai');
|
|
expect(ds.single.name, 'ROUTE_map');
|
|
expect(ds.single.args['endLoc_city'], '深圳市');
|
|
});
|
|
|
|
test('tool_infos 是服务端插件的结果,不能当成端侧指令', () {
|
|
final output = outputOf(r'''
|
|
{"payload":{"output":{"event":"RespondingContent","text":"Let me see.","finished":true,
|
|
"finish_reason":"stop","extra_info":{"tool_infos":[
|
|
{"output":"","tool_name":"weather","success":true,"type":"plugin"}]},
|
|
"dialog_id":"d","round_id":"r4","llm_request_id":"l"}}}''');
|
|
|
|
expect(AssistantDirective.parseFromOutput(output, source: 'emai'), isEmpty);
|
|
});
|
|
|
|
test('没有 extra_info 的普通回复不产生指令', () {
|
|
final output = outputOf(
|
|
'{"payload":{"output":{"event":"RespondingContent","text":"你好","finished":true}}}');
|
|
expect(AssistantDirective.parseFromOutput(output, source: 'emai'), isEmpty);
|
|
});
|
|
|
|
test('arguments 是坏 JSON 时保留原文而不是整条丢掉', () {
|
|
final output = outputOf(r'''
|
|
{"payload":{"output":{"event":"RespondingContent","text":"","finished":true,
|
|
"extra_info":{"tool_calls":[{"function":{"name":"SET_clock",
|
|
"arguments":"{不是合法 JSON"},"type":"FUNCTION"}]},"round_id":"r5"}}}''');
|
|
|
|
final d = AssistantDirective.parseFromOutput(output, source: 'emai').single;
|
|
expect(d.args, isEmpty);
|
|
expect(d.rawArguments, '{不是合法 JSON');
|
|
// time 解不出来 → 不落提醒,交由调用方记 failed
|
|
expect(AssistantReminder.fromSetClock(d), isNull);
|
|
});
|
|
});
|
|
|
|
/// 真机实测(2026-09-19 09:25 说「下午2点去恒玄公司」):模型下发的 date 是
|
|
/// 2026-09-18,比当天早一天,闹钟落到昨天那页、且永远不会响。
|
|
/// 见 AssistantReminder.rollForwardPastDate 的注释。
|
|
group('模型给了过去的日期', () {
|
|
// 2026-09-19 09:25,正是那次真机复现的时刻
|
|
final now = DateTime(2026, 9, 19, 9, 25);
|
|
|
|
test('时间点还没到 → 挪到今天', () {
|
|
expect(
|
|
AssistantReminder.rollForwardPastDate(
|
|
'2026-09-18', '14:00', now, RepeatRule.once),
|
|
'2026-09-19',
|
|
);
|
|
});
|
|
|
|
test('时间点已经过了 → 挪到明天', () {
|
|
expect(
|
|
AssistantReminder.rollForwardPastDate(
|
|
'2026-09-18', '08:00', now, RepeatRule.once),
|
|
'2026-09-20',
|
|
);
|
|
});
|
|
|
|
test('今天或以后的日期原样不动', () {
|
|
for (final d in ['2026-09-19', '2026-09-20', '2026-12-31']) {
|
|
expect(
|
|
AssistantReminder.rollForwardPastDate(d, '08:00', now, RepeatRule.once),
|
|
d,
|
|
reason: '$d 不在过去,不该被改',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('重复闹钟的锚点即使在过去也不动', () {
|
|
// 「每周一」的锚点本来就可能是上周一,挪了会把第一次触发推迟一周
|
|
for (final rule in [
|
|
RepeatRule.daily,
|
|
RepeatRule.weekly,
|
|
RepeatRule.weekdays,
|
|
RepeatRule.weekend,
|
|
]) {
|
|
expect(
|
|
AssistantReminder.rollForwardPastDate('2026-09-01', '08:00', now, rule),
|
|
'2026-09-01',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('没有日期就还是没有日期', () {
|
|
expect(
|
|
AssistantReminder.rollForwardPastDate(null, '08:00', now, RepeatRule.once),
|
|
isNull,
|
|
);
|
|
});
|
|
});
|
|
|
|
/// 拾忆页的「本地兜底」投影要不要出这一条。
|
|
///
|
|
/// 真机实测(2026-09-19):同一个闹钟出现两张卡,一张能编辑(服务端那份),
|
|
/// 点另一张提示「没有同步到服务器」(本地那份)。服务端 memory_item
|
|
/// id=36/37 各只有一行,重复纯在客户端投影层。
|
|
group('本地提醒去重(pendingLocalReminders)', () {
|
|
AssistantReminder mk(String id, {bool synced = false}) => AssistantReminder(
|
|
id: id,
|
|
content: '送员工到恒玄公司',
|
|
time: '17:00',
|
|
date: '2026-09-19',
|
|
repeat: RepeatRule.once,
|
|
repeatRaw: '',
|
|
weekday: null,
|
|
createdAt: DateTime(2026, 9, 19, 9, 50),
|
|
directiveId: id,
|
|
roundId: 'r',
|
|
synced: synced,
|
|
);
|
|
|
|
// 真机那条:client_key 就是指令 id
|
|
MemoryItem server(String key, {bool byClientKey = true}) => MemoryItem(
|
|
id: 37,
|
|
title: '送员工到恒玄公司',
|
|
happenDate: '2026-09-19',
|
|
happenTime: '17:00',
|
|
clientKey: byClientKey ? key : '',
|
|
sourceId: byClientKey ? '' : key,
|
|
);
|
|
|
|
const k = '79a34c5913ad4fddba0c91e8bcb031b6#0';
|
|
|
|
test('服务端已有(client_key 对上)→ 本地那份不投影', () {
|
|
expect(pendingLocalReminders([mk(k)], [server(k)]), isEmpty);
|
|
});
|
|
|
|
test('只写了 source_id 的历史数据也认', () {
|
|
expect(
|
|
pendingLocalReminders([mk(k)], [server(k, byClientKey: false)]),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('⚠️ synced 标志丢了也不该重复 —— 这正是真机上出现两张卡的原因', () {
|
|
// 上传成功后进程被杀 / 重装 / 清缓存,本地 synced 会退回 false,
|
|
// 但服务端那行还在。只看 synced 就会又投影一条出来。
|
|
final r = mk(k, synced: false);
|
|
expect(pendingLocalReminders([r], [server(k)]), isEmpty);
|
|
});
|
|
|
|
test('服务端确实没有 → 必须投影,否则本地闹钟没有任何展示出口', () {
|
|
expect(pendingLocalReminders([mk(k)], const []), hasLength(1));
|
|
expect(
|
|
pendingLocalReminders([mk(k)], [server('别的指令#0')]),
|
|
hasLength(1),
|
|
);
|
|
});
|
|
|
|
test('本地已标记 synced 的不投影', () {
|
|
expect(pendingLocalReminders([mk(k, synced: true)], const []), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('中文重复规则', () {
|
|
test('每天 / 每日', () {
|
|
expect(AssistantReminder.parseRepeat('每天').$1, RepeatRule.daily);
|
|
expect(AssistantReminder.parseRepeat('每日').$1, RepeatRule.daily);
|
|
});
|
|
|
|
test('周几:周日和周天都是 7', () {
|
|
expect(AssistantReminder.parseRepeat('周三'), (RepeatRule.weekly, 3));
|
|
expect(AssistantReminder.parseRepeat('每周五'), (RepeatRule.weekly, 5));
|
|
expect(AssistantReminder.parseRepeat('星期日'), (RepeatRule.weekly, 7));
|
|
expect(AssistantReminder.parseRepeat('周天'), (RepeatRule.weekly, 7));
|
|
});
|
|
|
|
test('工作日 / 周末', () {
|
|
expect(AssistantReminder.parseRepeat('工作日').$1, RepeatRule.weekdays);
|
|
expect(AssistantReminder.parseRepeat('周末').$1, RepeatRule.weekend);
|
|
});
|
|
|
|
test('空表示不重复,认不出的写法归 unknown 而不是瞎猜', () {
|
|
expect(AssistantReminder.parseRepeat('').$1, RepeatRule.once);
|
|
expect(AssistantReminder.parseRepeat('隔一天').$1, RepeatRule.unknown);
|
|
});
|
|
});
|
|
|
|
group('12 小时制歧义校正', () {
|
|
// 这一组用例来自真机实测:说 "3:30pm"、模型文案写"下午3:30",
|
|
// 但 time 给的是 "03:30"。照收就会定到凌晨三点半。
|
|
String fix(String time, String reply) =>
|
|
AssistantReminder.disambiguateAfternoon(time, reply);
|
|
|
|
test('文案说下午而小时 < 12 时 +12', () {
|
|
expect(fix('03:30', '好的,我已经帮你设定了明天下午3:30的闹钟。'), '15:30');
|
|
expect(fix('09:00', '晚上9点提醒你'), '21:00');
|
|
expect(fix('03:30', 'Alarm set for 3:30 PM tomorrow'), '15:30');
|
|
});
|
|
|
|
test('文案说上午 / 没提时段时原样保留', () {
|
|
expect(fix('08:00', '已经为你设定了每周一早上8点的闹钟'), '08:00');
|
|
expect(fix('08:00', '好的,已经设好了'), '08:00');
|
|
expect(fix('08:00', '上午8点到下午6点都提醒'), '08:00'); // 同时出现,不猜
|
|
});
|
|
|
|
test('已经是 24 小时制的不动', () {
|
|
expect(fix('17:00', '下午5点的闹钟'), '17:00');
|
|
expect(fix('21:00', '晚上9点'), '21:00');
|
|
expect(fix('00:30', '凌晨的闹钟'), '00:30');
|
|
});
|
|
|
|
test('端到端:真实帧走完 fromSetClock 得到 15:30', () {
|
|
final output = outputOf(r'''
|
|
{"payload":{"output":{"event":"RespondingContent",
|
|
"text":"好的,我已经帮你设定了明天下午3:30的闹钟。","finished":true,"finish_reason":"stop",
|
|
"extra_info":{"tool_calls":[{"function":{"name":"SET_clock",
|
|
"arguments":"{\"time\": \"03:30\", \"date\": \"2026-08-30\", \"content\": \"项目 review\"}"},
|
|
"type":"FUNCTION"}]},"dialog_id":"d","round_id":"r9","llm_request_id":"l"}}}''');
|
|
final r = AssistantReminder.fromSetClock(
|
|
AssistantDirective.parseFromOutput(output, source: 'emai').single)!;
|
|
expect(r.time, '15:30');
|
|
expect(r.content, '项目 review');
|
|
// 同上:帧里的 2026-08-30 重放时已是过去,会被挪到就近的未来。
|
|
// 这条用例守的是 15:30 那个校正,日期由专门的 group 守。
|
|
final today = DateTime.now();
|
|
expect(
|
|
DateTime.parse(r.date!)
|
|
.isBefore(DateTime(today.year, today.month, today.day)),
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('时间归一', () {
|
|
AssistantReminder? build(String time) => AssistantReminder.fromSetClock(
|
|
AssistantDirective(
|
|
id: 'x',
|
|
name: 'SET_clock',
|
|
args: {'time': time},
|
|
rawArguments: '',
|
|
dialogId: '',
|
|
roundId: '',
|
|
llmRequestId: '',
|
|
source: 'emai',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
test('补零与全角冒号', () {
|
|
expect(build('8:0')?.time, '08:00');
|
|
expect(build('08:30')?.time, '08:30');
|
|
expect(build('21:00:00')?.time, '21:00');
|
|
});
|
|
|
|
test('非法时间不落提醒', () {
|
|
expect(build('25:00'), isNull);
|
|
expect(build('晚上八点'), isNull);
|
|
expect(build(''), isNull);
|
|
});
|
|
});
|
|
|
|
test('序列化可往返(将来要原样上传服务器)', () {
|
|
final d = AssistantDirective(
|
|
id: 'r#0',
|
|
name: 'SET_clock',
|
|
args: {'time': '08:00', 'repeat': '周一'},
|
|
rawArguments: '{"time":"08:00","repeat":"周一"}',
|
|
dialogId: 'd',
|
|
roundId: 'r',
|
|
llmRequestId: 'l',
|
|
source: 'earphone',
|
|
createdAt: DateTime.parse('2026-08-29T10:00:00'),
|
|
status: DirectiveStatus.done,
|
|
);
|
|
final back = AssistantDirective.fromJson(json
|
|
.decode(json.encode(d.toJson()))
|
|
.cast<String, dynamic>());
|
|
expect(back.name, d.name);
|
|
expect(back.args['repeat'], '周一');
|
|
expect(back.status, DirectiveStatus.done);
|
|
expect(back.source, 'earphone');
|
|
expect(back.synced, isFalse);
|
|
});
|
|
|
|
group('commands 形状(2026-09-11 车载助手真机帧)', () {
|
|
// EaiCar 车载助手的指令不在 tool_calls 里,而是 extra_info.commands。
|
|
// 只解析 tool_calls 的话这里恒为 0 条 —— 表现就是"AI 说已经关闭了、实际没反应"。
|
|
Map<String, dynamic> frame() => {
|
|
'dialog_id': 'd1',
|
|
'round_id': 'r3',
|
|
'text': '好的,那我先不打扰你啦,有需要随时叫我哦~',
|
|
'finish_reason': 'stop',
|
|
'extra_info': {
|
|
'tool_infos': [],
|
|
'agent_info': {
|
|
'intent_infos': [
|
|
{'domain': 'general_command', 'intent': 'quit'}
|
|
],
|
|
'round': 3,
|
|
},
|
|
'commands': [
|
|
{
|
|
'intent_info': {'domain': 'general_command', 'intent': 'quit'},
|
|
'name': 'quit',
|
|
'params': [],
|
|
}
|
|
],
|
|
'query': '关闭对话。',
|
|
},
|
|
};
|
|
|
|
test('解得出 quit 指令', () {
|
|
final list =
|
|
AssistantDirective.parseFromOutput(frame(), source: 'earphone');
|
|
expect(list.length, 1);
|
|
expect(list.first.name, 'quit');
|
|
expect(list.first.args['intent'], 'quit');
|
|
expect(list.first.replyText, contains('不打扰'));
|
|
});
|
|
|
|
test('两种形状同时出现时都要解出来', () {
|
|
final f = frame();
|
|
(f['extra_info'] as Map)['tool_calls'] = [
|
|
{
|
|
'function': {'name': 'SET_clock', 'arguments': '{"time":"08:00"}'}
|
|
}
|
|
];
|
|
final list = AssistantDirective.parseFromOutput(f, source: 'earphone');
|
|
expect(list.map((d) => d.name).toList(), ['SET_clock', 'quit']);
|
|
});
|
|
|
|
test('commands 为空时不产生指令', () {
|
|
final f = frame();
|
|
(f['extra_info'] as Map)['commands'] = [];
|
|
expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty);
|
|
});
|
|
|
|
// 2026-09-25 真机:车载助手新应用 mm_127ddb82… 把 commands 下发成 JSON 字符串
|
|
test('commands 是 JSON 字符串时也解得出 quit', () {
|
|
final f = frame();
|
|
(f['extra_info'] as Map)['commands'] =
|
|
'[{"intent_info":{"domain":"general_command","intent":"quit"},"name":"quit","params":[]}]';
|
|
final list = AssistantDirective.parseFromOutput(f, source: 'earphone');
|
|
expect(list.map((d) => d.name).toList(), ['quit']);
|
|
expect(list.first.args['intent'], 'quit');
|
|
});
|
|
|
|
test('commands 是畸形字符串时不抛、不产生指令', () {
|
|
final f = frame();
|
|
(f['extra_info'] as Map)['commands'] = '[{oops';
|
|
expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty);
|
|
});
|
|
});
|
|
}
|
|
|