Browse Source
周期报告弹窗(MemoryReportSheet) - 一次只弹最近一份未确认的。用户一个月不开 App 会攒 4 份周报 + 1 份月报, 连弹 5 次没人受得了——服务端不带参数时就只返回最近一份。 - 不可关闭(需求 4 要求用户确认),但**确认失败也放人走**:把人扣在一个不可 关闭的弹窗里比漏记一次确认糟得多,下次进来会再弹。 - summary 为空时不播报、只显示统计。文案生成失败时服务端是刻意只回写统计的 (宁可少一句话也不让报告整个没有),这里对齐那个降级。 - 统计数字全部来自 stat_json,客户端不做任何加减——同一份数据在两处各算一遍, 早晚会出现两个不同的结果。花销按币种分行,不做汇率换算。 - 排在每日安排弹窗之后:两个都要播报,同时弹会互相盖住,而「今天要做什么」 比回顾更即时。 存量 DBTask → memory_item 迁移 模块 Start 时起 goroutine 跑一次,幂等(client_key="task:<id>" + 唯一索引)。 - **不删也不改 allhelp_task**。不可逆迁移的通例:出问题时旧表是唯一回退依据。 - **失败不阻断启动**。它是补数据不是启动前置;返回 error 会让 home 退出, 而五个服务同镜像、entrypoint 会连坐全杀。 - trigger_time 用 time.Parse 保留原时区再取日期,**不先转 UTC**—— 那会让 00:30+08:00 这类凌晨任务整体退到前一天(有用例守着)。 - RemindAhead 置 0:旧体系「服务端仅记录不触发」,用户从没被这些任务提醒过, 迁过来默认开提醒等于给所有人补发一堆历史提醒。 - UserEdited 置 true:迁移来的是用户资产,不该被会议重新生成之类的清理逻辑碰到。 - TaskType_Cron 按一次性迁,不猜重复规则——猜错会让用户每天被一个他没设过的 闹钟吵醒。 - 标题与描述都空、没有 uid、既无触发时间也无创建时间的一律跳过,不往新表塞垃圾。 7 个映射用例覆盖:基本字段、凌晨不退日期、五种 TaskType、三种状态、 无触发时间退回创建日期、四类该跳过的、标题回退描述。 验证:go build / go vet 全通过,comm + memory + mcp 三个包测试通过; dart analyze 零错误,flutter build apk --debug 通过。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
5 changed files with 577 additions and 0 deletions
@ -0,0 +1,235 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../../../core/utils/logger.dart'; |
|||
import '../../../../data/models/memory_item.dart'; |
|||
import '../../../../data/services/memory_service.dart'; |
|||
import '../../../../data/services/tts_service.dart'; |
|||
|
|||
/// 周报/月报弹窗:播报 AI 文案 + 用户确认。 |
|||
/// |
|||
/// ⚠️ **一次只弹最近一份未确认的**。用户一个月不开 App 会攒下 4 份周报 + 1 份 |
|||
/// 月报,连弹 5 次没人受得了——服务端的 memory_getreport 不带参数时就只返回 |
|||
/// 最近一份,其余去历史列表里翻。 |
|||
/// |
|||
/// ⚠️ 报告还在生成时服务端返回 MemoryReportNotReady(5205),MemoryService 那边 |
|||
/// 拿到 null 就当「暂时没有」。**不要弹错**:报告没生成完不是故障。 |
|||
class MemoryReportSheet { |
|||
static const String _tag = 'MemoryReport'; |
|||
static const String _sessionId = 'memory_report_briefing'; |
|||
|
|||
static bool _showing = false; |
|||
|
|||
/// 有待确认的报告就弹出来。没有则静默返回。 |
|||
static Future<void> maybeShow() async { |
|||
if (_showing) return; |
|||
_showing = true; |
|||
try { |
|||
final report = await MemoryService.to.loadPendingReport(); |
|||
if (report == null || report.confirmed) return; |
|||
await Get.bottomSheet( |
|||
_ReportSheetView(report: report), |
|||
isScrollControlled: true, |
|||
isDismissible: false, // 需求 4:播报后需要用户确认 |
|||
); |
|||
} catch (e) { |
|||
Logger.w(_tag, '报告弹窗失败(不影响主流程): $e'); |
|||
} finally { |
|||
_showing = false; |
|||
} |
|||
} |
|||
} |
|||
|
|||
class _ReportSheetView extends StatefulWidget { |
|||
final MemoryReport report; |
|||
const _ReportSheetView({required this.report}); |
|||
|
|||
@override |
|||
State<_ReportSheetView> createState() => _ReportSheetViewState(); |
|||
} |
|||
|
|||
class _ReportSheetViewState extends State<_ReportSheetView> { |
|||
bool _speaking = false; |
|||
bool _confirming = false; |
|||
TtsService? _tts; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
WidgetsBinding.instance.addPostFrameCallback((_) => _speak()); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_stop(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
Future<void> _speak() async { |
|||
final text = widget.report.summary; |
|||
// 文案生成失败时服务端只回写统计、summary 为空(那是刻意的降级: |
|||
// 宁可少一句话也不让报告整个没有)。没文案就不播,页面照常显示统计。 |
|||
if (text.isEmpty) return; |
|||
if (!Get.isRegistered<TtsService>()) return; |
|||
try { |
|||
_tts = Get.find<TtsService>(); |
|||
setState(() => _speaking = true); |
|||
await _tts!.startspeak(MemoryReportSheet._sessionId); |
|||
await _tts!.speakOnce(MemoryReportSheet._sessionId, text); |
|||
} catch (e) { |
|||
Logger.w(MemoryReportSheet._tag, '播报失败: $e'); |
|||
} finally { |
|||
if (mounted) setState(() => _speaking = false); |
|||
} |
|||
} |
|||
|
|||
Future<void> _stop() async { |
|||
try { |
|||
await _tts?.stop(); |
|||
} catch (_) {} |
|||
if (mounted) setState(() => _speaking = false); |
|||
} |
|||
|
|||
Future<void> _confirm() async { |
|||
setState(() => _confirming = true); |
|||
await _stop(); |
|||
// 确认失败也放用户走:把人扣在一个不可关闭的弹窗里比漏记一次确认糟得多, |
|||
// 下次进来会再弹一次(服务端仍认为未确认),可以接受。 |
|||
final ok = await MemoryService.to.confirmReport(widget.report.id); |
|||
if (!ok) { |
|||
Logger.w(MemoryReportSheet._tag, '确认报告失败,仍关闭弹窗(下次会再弹)'); |
|||
} |
|||
if (mounted) Get.back(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDark = Theme.of(context).brightness == Brightness.dark; |
|||
final stat = MemoryService.parseStat(widget.report.statJson); |
|||
return PopScope( |
|||
// 需求 4:播报后需要用户确认,所以不给系统返回键直接关掉 |
|||
canPop: false, |
|||
child: Container( |
|||
decoration: BoxDecoration( |
|||
color: isDark ? const Color(0xFF1C1C1E) : Colors.white, |
|||
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), |
|||
), |
|||
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 20.h), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Text( |
|||
(widget.report.isWeek |
|||
? 'memReportWeekTitle' |
|||
: 'memReportMonthTitle') |
|||
.tr, |
|||
style: |
|||
TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), |
|||
), |
|||
), |
|||
if (widget.report.summary.isNotEmpty) |
|||
TextButton.icon( |
|||
onPressed: _speaking ? _stop : _speak, |
|||
icon: Icon(_speaking ? Icons.stop : Icons.volume_up, |
|||
size: 18.sp), |
|||
label: Text( |
|||
(_speaking ? 'memReportStop' : 'memReportPlay').tr, |
|||
style: TextStyle(fontSize: 13.sp)), |
|||
), |
|||
], |
|||
), |
|||
Text( |
|||
'${widget.report.periodStart} ~ ${widget.report.periodEnd}', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDark ? Colors.white38 : Colors.black38), |
|||
), |
|||
SizedBox(height: 14.h), |
|||
_statRow(stat, isDark), |
|||
if (widget.report.summary.isNotEmpty) ...[ |
|||
SizedBox(height: 14.h), |
|||
Flexible( |
|||
child: SingleChildScrollView( |
|||
child: Text(widget.report.summary, |
|||
style: TextStyle(fontSize: 14.sp, height: 1.6)), |
|||
), |
|||
), |
|||
], |
|||
SizedBox(height: 20.h), |
|||
SizedBox( |
|||
width: double.infinity, |
|||
child: ElevatedButton( |
|||
onPressed: _confirming ? null : _confirm, |
|||
child: _confirming |
|||
? SizedBox( |
|||
width: 16.w, |
|||
height: 16.w, |
|||
child: const CircularProgressIndicator(strokeWidth: 2)) |
|||
: Text('memReportConfirm'.tr), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 统计条。数字全部来自服务端 SQL 算好的 stat_json,**不在客户端做任何加减** |
|||
/// ——同一份数据在两处各算一遍,早晚会出现两个不同的结果。 |
|||
Widget _statRow(Map<String, dynamic> stat, bool isDark) { |
|||
final tiles = <Widget>[]; |
|||
|
|||
void add(String labelKey, String value) { |
|||
tiles.add(Container( |
|||
margin: EdgeInsets.only(right: 10.w, bottom: 8.h), |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), |
|||
decoration: BoxDecoration( |
|||
color: isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.04), |
|||
borderRadius: BorderRadius.circular(10.r), |
|||
), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text(value, |
|||
style: |
|||
TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w800)), |
|||
SizedBox(height: 2.h), |
|||
Text(labelKey.tr, |
|||
style: TextStyle( |
|||
fontSize: 10.sp, |
|||
color: isDark ? Colors.white54 : Colors.black45)), |
|||
], |
|||
), |
|||
)); |
|||
} |
|||
|
|||
final todoDone = memInt(stat['todo_done']); |
|||
final todoTotal = memInt(stat['todo_total']); |
|||
if (todoTotal > 0) add('memCatTodo', '$todoDone/$todoTotal'); |
|||
final ideaCount = memInt(stat['idea_count']); |
|||
if (ideaCount > 0) add('memCatIdea', '$ideaCount'); |
|||
final alarmTotal = memInt(stat['alarm_total']); |
|||
if (alarmTotal > 0) add('memCatAlarm', '$alarmTotal'); |
|||
|
|||
// 花销按币种分行:服务端不做汇率换算,客户端也不做。 |
|||
final exps = stat['expenses']; |
|||
if (exps is List) { |
|||
for (final e in exps) { |
|||
if (e is! Map) continue; |
|||
final cents = memInt(e['total_cents']); |
|||
if (cents == 0) continue; |
|||
final cur = memStr(e['currency'], 'CNY'); |
|||
add('memCatExpense', '$cur ${(cents / 100).toStringAsFixed(2)}'); |
|||
} |
|||
} |
|||
|
|||
if (tiles.isEmpty) return const SizedBox.shrink(); |
|||
return Wrap(children: tiles); |
|||
} |
|||
} |
|||
@ -0,0 +1,181 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/mysql" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
/* |
|||
存量 DBTask(allhelp_task)→ memory_item 的一次性迁移。 |
|||
|
|||
在模块 Start 时跑一次,**幂等**:靠 (uid, client_key) 唯一索引挡重复, |
|||
client_key 用 "task:<id>",同一条任务再迁一次会被唯一索引挡掉。 |
|||
|
|||
⚠️ **不删 allhelp_task 表,也不改它的数据**。这是不可逆迁移的通例: |
|||
出问题时旧表是唯一的回退依据。确认新表跑稳后由人手工处置。 |
|||
|
|||
⚠️ 迁移失败**不阻断服务启动**。它是补数据,不是启动的前置条件; |
|||
在这里返回 error 会让 home 退出,而五个服务同镜像、entrypoint 会连坐全杀。 |
|||
*/ |
|||
|
|||
// 每轮最多搬多少条,避免一次把全表读进内存
|
|||
const migrateBatchSize = 500 |
|||
|
|||
// migrateTasks 把存量任务搬进记忆项。返回搬了几条。
|
|||
func (this *modelComp) migrateTasks() (int, error) { |
|||
// 先看有没有活儿。表不存在(老部署没有 allhelp 模块)时这里会报错,
|
|||
// 直接当作「没有存量」返回,不是故障。
|
|||
var pending int64 |
|||
if err := mysql.Table(comm.TableAllhelpTask).Count(&pending).Error; err != nil { |
|||
return 0, nil |
|||
} |
|||
if pending == 0 { |
|||
return 0, nil |
|||
} |
|||
|
|||
migrated := 0 |
|||
var lastID uint64 |
|||
for { |
|||
tasks := make([]*pb.DBTask, 0, migrateBatchSize) |
|||
if err := mysql.Table(comm.TableAllhelpTask). |
|||
Where("id > ?", lastID). |
|||
Order("id asc").Limit(migrateBatchSize). |
|||
Find(&tasks).Error; err != nil { |
|||
return migrated, err |
|||
} |
|||
if len(tasks) == 0 { |
|||
break |
|||
} |
|||
for _, t := range tasks { |
|||
lastID = t.Id |
|||
item := taskToMemoryItem(t) |
|||
if item == nil { |
|||
continue |
|||
} |
|||
// 已迁过就跳过:查一次比撞唯一索引再吞错误干净,
|
|||
// 而且能区分「已迁过」和「真的写失败了」。
|
|||
if exist, err := this.findItemByClientKey(item.Uid, item.ClientKey); err != nil { |
|||
this.module.Warnf("迁移任务 %d 查重失败已跳过: %v", t.Id, err) |
|||
continue |
|||
} else if exist != nil { |
|||
continue |
|||
} |
|||
if err := this.addItem(item); err != nil { |
|||
this.module.Warnf("迁移任务 %d 写入失败已跳过: %v", t.Id, err) |
|||
continue |
|||
} |
|||
migrated++ |
|||
} |
|||
if len(tasks) < migrateBatchSize { |
|||
break |
|||
} |
|||
} |
|||
return migrated, nil |
|||
} |
|||
|
|||
// taskToMemoryItem 字段映射。无法映射出日期的条目返回 nil(跳过而不是硬塞今天)。
|
|||
func taskToMemoryItem(t *pb.DBTask) *pb.DBMemoryItem { |
|||
if t == nil || t.Uid == "" { |
|||
return nil |
|||
} |
|||
title := strings.TrimSpace(t.TaskName) |
|||
if title == "" { |
|||
title = strings.TrimSpace(t.TaskDesc) |
|||
} |
|||
if title == "" { |
|||
// 标题和描述都空的任务没有迁的价值,迁过去也只是一条空白记录
|
|||
return nil |
|||
} |
|||
|
|||
// trigger_time 是带时区的 RFC3339(如 2025-04-08T15:00:00+08:00)。
|
|||
// ⚠️ 用 time.Parse 保留原时区再取日期,**不要先转 UTC**——
|
|||
// 那会让 00:30 这类凌晨任务整体退到前一天。
|
|||
var ( |
|||
happenDate string |
|||
happenTime string |
|||
tz string |
|||
) |
|||
if ts := strings.TrimSpace(t.TriggerTime); ts != "" { |
|||
if parsed, err := time.Parse(time.RFC3339, ts); err == nil { |
|||
happenDate = comm.FormatMemoryDate(parsed) |
|||
happenTime = parsed.Format("15:04") |
|||
// 原串里的时区偏移信息只有偏移没有 IANA 名,存不了 tz,
|
|||
// 留空让服务端按容器时区处理——这是已知的精度损失。
|
|||
_ = tz |
|||
} |
|||
} |
|||
if happenDate == "" { |
|||
// 没有可用的触发时间就退回创建日期:记忆项的 happen_date 永不为空,
|
|||
// 日历按日期排,没有日期的项根本渲染不出来。
|
|||
if t.CreateTime > 0 { |
|||
happenDate = comm.FormatMemoryDate(time.Unix(t.CreateTime, 0)) |
|||
} else { |
|||
return nil |
|||
} |
|||
} |
|||
|
|||
item := &pb.DBMemoryItem{ |
|||
Uid: t.Uid, |
|||
Category: comm.MemoryCatTodo, // 存量任务一律按待办迁,不猜分类
|
|||
Source: comm.MemorySrcAssistant, |
|||
SourceId: fmt.Sprintf("task:%d", t.Id), |
|||
// 幂等键:同一条任务重复迁移会被 (uid, client_key) 唯一索引挡掉
|
|||
ClientKey: fmt.Sprintf("task:%d", t.Id), |
|||
Title: title, |
|||
Detail: strings.TrimSpace(t.TaskDesc), |
|||
HappenDate: happenDate, |
|||
HappenTime: happenTime, |
|||
DateCertain: happenTime != "", |
|||
RepeatRule: taskTypeToRepeat(t.TaskType), |
|||
Currency: "CNY", |
|||
State: taskStatusToState(t.Status), |
|||
// 存量任务在旧体系里「服务端仅记录、不自动触发」,用户从没被它提醒过。
|
|||
// 迁过来就默认开提醒等于突然给所有人补发一堆历史提醒,所以置 0。
|
|||
RemindAhead: 0, |
|||
// 迁移来的数据算用户资产,不该被会议重新生成之类的清理逻辑碰到
|
|||
UserEdited: true, |
|||
CreateTime: t.CreateTime, |
|||
FinishTime: t.FinishTime, |
|||
} |
|||
if item.RepeatRule == comm.MemoryRepeatWeekly { |
|||
if d, ok := comm.ParseMemoryDate(happenDate); ok { |
|||
w := int32(d.Weekday()) |
|||
if w == 0 { |
|||
w = 7 // 周日:Go 的 0 → 客户端口径的 7
|
|||
} |
|||
item.Weekday = w |
|||
} |
|||
} |
|||
return item |
|||
} |
|||
|
|||
func taskTypeToRepeat(t pb.TaskType) string { |
|||
switch t { |
|||
case pb.TaskType_TaskType_Daily: |
|||
return comm.MemoryRepeatDaily |
|||
case pb.TaskType_TaskType_Weekly: |
|||
return comm.MemoryRepeatWeekly |
|||
case pb.TaskType_TaskType_Monthly: |
|||
return comm.MemoryRepeatMonthly |
|||
default: |
|||
// Cron 表达式没有对应的 repeat_rule,按一次性迁——
|
|||
// 猜错的重复规则会让用户每天被一个他没设过的闹钟吵醒。
|
|||
return comm.MemoryRepeatOnce |
|||
} |
|||
} |
|||
|
|||
func taskStatusToState(s pb.TaskStatus) pb.MemoryState { |
|||
switch s { |
|||
case pb.TaskStatus_TaskStatus_Completed: |
|||
return pb.MemoryState_MemoryState_Done |
|||
case pb.TaskStatus_TaskStatus_Cancelled: |
|||
return pb.MemoryState_MemoryState_Dropped |
|||
default: |
|||
return pb.MemoryState_MemoryState_Pending |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"testing" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
func TestTaskToMemoryItem_Basic(t *testing.T) { |
|||
task := &pb.DBTask{ |
|||
Id: 42, Uid: "u1", |
|||
TaskName: "跟进A客户", TaskDesc: "电话联系", |
|||
TaskType: pb.TaskType_TaskType_Once, |
|||
TriggerTime: "2026-04-08T15:00:00+08:00", |
|||
Status: pb.TaskStatus_TaskStatus_Pending, |
|||
CreateTime: 1700000000, |
|||
} |
|||
it := taskToMemoryItem(task) |
|||
if it == nil { |
|||
t.Fatal("不该返回 nil") |
|||
} |
|||
if it.HappenDate != "2026-04-08" { |
|||
t.Errorf("日期期望 2026-04-08,得到 %s", it.HappenDate) |
|||
} |
|||
if it.HappenTime != "15:00" { |
|||
t.Errorf("时刻期望 15:00,得到 %s", it.HappenTime) |
|||
} |
|||
if it.ClientKey != "task:42" { |
|||
t.Errorf("幂等键期望 task:42,得到 %s", it.ClientKey) |
|||
} |
|||
if it.Category != comm.MemoryCatTodo { |
|||
t.Errorf("存量任务应一律迁成 todo,得到 %s", it.Category) |
|||
} |
|||
// 旧体系里「服务端仅记录不触发」,用户从没被提醒过。
|
|||
// 迁过来默认开提醒 = 给所有人补发一堆历史提醒。
|
|||
if it.RemindAhead != 0 { |
|||
t.Errorf("迁移来的任务不该默认开提醒,得到 %d", it.RemindAhead) |
|||
} |
|||
if !it.UserEdited { |
|||
t.Error("迁移来的是用户资产,应标 user_edited 避免被自动清理碰到") |
|||
} |
|||
} |
|||
|
|||
// ⚠️ 凌晨任务:如果先转 UTC 再取日期,00:30+08:00 会退到前一天。
|
|||
func TestTaskToMemoryItem_EarlyMorningKeepsLocalDate(t *testing.T) { |
|||
task := &pb.DBTask{ |
|||
Id: 1, Uid: "u1", TaskName: "晨会", |
|||
TriggerTime: "2026-04-08T00:30:00+08:00", |
|||
CreateTime: 1700000000, |
|||
} |
|||
it := taskToMemoryItem(task) |
|||
if it == nil { |
|||
t.Fatal("不该返回 nil") |
|||
} |
|||
if it.HappenDate != "2026-04-08" { |
|||
t.Errorf("凌晨任务的日期不该退到前一天,期望 2026-04-08 得到 %s", it.HappenDate) |
|||
} |
|||
} |
|||
|
|||
func TestTaskToMemoryItem_RepeatMapping(t *testing.T) { |
|||
cases := map[pb.TaskType]string{ |
|||
pb.TaskType_TaskType_Once: comm.MemoryRepeatOnce, |
|||
pb.TaskType_TaskType_Daily: comm.MemoryRepeatDaily, |
|||
pb.TaskType_TaskType_Weekly: comm.MemoryRepeatWeekly, |
|||
pb.TaskType_TaskType_Monthly: comm.MemoryRepeatMonthly, |
|||
// cron 没有对应的 repeat_rule,按一次性迁:猜错的重复规则会让用户
|
|||
// 每天被一个他没设过的闹钟吵醒
|
|||
pb.TaskType_TaskType_Cron: comm.MemoryRepeatOnce, |
|||
} |
|||
for tt, want := range cases { |
|||
task := &pb.DBTask{ |
|||
Id: 1, Uid: "u1", TaskName: "x", TaskType: tt, |
|||
TriggerTime: "2026-04-08T15:00:00+08:00", |
|||
} |
|||
it := taskToMemoryItem(task) |
|||
if it == nil || it.RepeatRule != want { |
|||
t.Errorf("TaskType %v 期望 %s,得到 %v", tt, want, it) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestTaskToMemoryItem_StatusMapping(t *testing.T) { |
|||
cases := map[pb.TaskStatus]pb.MemoryState{ |
|||
pb.TaskStatus_TaskStatus_Pending: pb.MemoryState_MemoryState_Pending, |
|||
pb.TaskStatus_TaskStatus_Completed: pb.MemoryState_MemoryState_Done, |
|||
pb.TaskStatus_TaskStatus_Cancelled: pb.MemoryState_MemoryState_Dropped, |
|||
} |
|||
for st, want := range cases { |
|||
if got := taskStatusToState(st); got != want { |
|||
t.Errorf("状态 %v 期望 %v 得到 %v", st, want, got) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 没有触发时间时退回创建日期——happen_date 永不为空是拾忆页能工作的前提
|
|||
func TestTaskToMemoryItem_FallsBackToCreateTime(t *testing.T) { |
|||
created := time.Date(2026, 3, 1, 10, 0, 0, 0, time.Local) |
|||
task := &pb.DBTask{ |
|||
Id: 1, Uid: "u1", TaskName: "无时间任务", |
|||
CreateTime: created.Unix(), |
|||
} |
|||
it := taskToMemoryItem(task) |
|||
if it == nil { |
|||
t.Fatal("不该返回 nil") |
|||
} |
|||
if it.HappenDate != comm.FormatMemoryDate(created) { |
|||
t.Errorf("应退回创建日期 %s,得到 %s", comm.FormatMemoryDate(created), it.HappenDate) |
|||
} |
|||
if it.DateCertain { |
|||
t.Error("没有具体时刻时 date_certain 应为 false") |
|||
} |
|||
} |
|||
|
|||
// 该跳过的都要跳过,别往新表里塞垃圾
|
|||
func TestTaskToMemoryItem_SkipsUnusable(t *testing.T) { |
|||
cases := []struct { |
|||
name string |
|||
task *pb.DBTask |
|||
}{ |
|||
{"nil", nil}, |
|||
{"没有 uid", &pb.DBTask{Id: 1, TaskName: "x", CreateTime: 1}}, |
|||
{"标题与描述都空", &pb.DBTask{Id: 1, Uid: "u1", CreateTime: 1}}, |
|||
{"既无触发时间也无创建时间", &pb.DBTask{Id: 1, Uid: "u1", TaskName: "x"}}, |
|||
} |
|||
for _, c := range cases { |
|||
if got := taskToMemoryItem(c.task); got != nil { |
|||
t.Errorf("%s: 应跳过,却返回了 %+v", c.name, got) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 描述为空时用标题;标题为空时用描述
|
|||
func TestTaskToMemoryItem_TitleFallback(t *testing.T) { |
|||
task := &pb.DBTask{ |
|||
Id: 1, Uid: "u1", TaskDesc: "只有描述", |
|||
TriggerTime: "2026-04-08T15:00:00+08:00", |
|||
} |
|||
it := taskToMemoryItem(task) |
|||
if it == nil || it.Title != "只有描述" { |
|||
t.Errorf("标题空时应退回描述,得到 %v", it) |
|||
} |
|||
} |
|||
Loading…
Reference in new issue