package history // 本文件锁定 chat_messages.voice 列的**读写视图**(design/24 §10.4 / §10.9-4): // ① 写入后能读回同样的 JSON 单对象,且列不存在时先幂等前滚(迁移 0020 之前建出的表); // ② 坏形状**拒绝写入**(绝不落下读不回来的列); // ③ 未知消息 id → ErrMessageNotFound(不假装写成功); // ④ 音频副本的名字并入**同一套**引用计数(按新旧名字集合的差增减,同一事务); // ⑤ 批量读是**按 id 集合一次查**,取不到的 id 不出现在映射里(与「取到空串」可区分)。 import ( "context" "errors" "testing" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" ) // seedVoiceMessage 经真实存储追加一条消息并返回已存行(含生成的 id)。 func seedVoiceMessage(t *testing.T, st *Store, role, content string) Message { t.Helper() got, err := st.AppendFull(context.Background(), Message{SessionID: MainSessionID, Role: role, Content: content}) if err != nil { t.Fatalf("append %q: %v", content, err) } return got } // voiceRawWithCopy 编码一条「有副本」的语音事实(ASR 转写成功 + 副本三字段齐备)。 func voiceRawWithCopy(t *testing.T, name string) string { t.Helper() raw, err := voiceref.Encode(voiceref.Ref{ Source: voiceref.SourceASR, Status: voiceref.StatusTranscribed, Name: name, MIME: "audio/ogg", Size: 1234, DurationMs: 3200, }) if err != nil { t.Fatalf("编码语音事实: %v", err) } return raw } // voiceRawWithoutCopy 编码一条「没有副本」的语音事实(微信按既有裁决不取字节)。 func voiceRawWithoutCopy(t *testing.T) string { t.Helper() raw, err := voiceref.Encode(voiceref.Ref{ Source: voiceref.SourcePlatform, Status: voiceref.StatusTranscribed, }) if err != nil { t.Fatalf("编码语音事实: %v", err) } return raw } func TestUpdateMessageVoiceRoundTrip(t *testing.T) { t.Parallel() refs := &recordingRefs{} st := newRefStore(t, refs) ctx := context.Background() msg := seedVoiceMessage(t, st, RoleUser, "(语音)") name := "0123456789abcdef0123456789abcdef.ogg" raw := voiceRawWithCopy(t, name) // 全新库由本包 DDL 建表(**不含** voice 列)⇒ 这一次写入顺带把列幂等前滚。 if err := st.UpdateMessageVoice(ctx, msg.ID, raw); err != nil { t.Fatalf("UpdateMessageVoice: %v", err) } cols, err := st.VoiceColumns(ctx, []string{msg.ID, "does-not-exist"}) if err != nil { t.Fatalf("VoiceColumns: %v", err) } if got := cols[msg.ID]; got != raw { t.Fatalf("读回 = %q, want %q", got, raw) } if _, ok := cols["does-not-exist"]; ok { t.Fatal("取不到的 id 不该出现在映射里(与「取到空串」必须可区分)") } if len(refs.retained) != 1 || refs.retained[0] != name { t.Fatalf("引用计数 retain = %v, want [%s]", refs.retained, name) } if len(refs.released) != 0 { t.Fatalf("首次写入不该 release:%v", refs.released) } // 清空(= 撤销语音事实)是合法值,且副本的名字被释放一次。 if err := st.UpdateMessageVoice(ctx, msg.ID, ""); err != nil { t.Fatalf("清空 voice: %v", err) } if len(refs.released) != 1 || refs.released[0] != name { t.Fatalf("清空后 release = %v, want [%s]", refs.released, name) } cols, err = st.VoiceColumns(ctx, []string{msg.ID}) if err != nil { t.Fatalf("VoiceColumns(清空后): %v", err) } if got, ok := cols[msg.ID]; !ok || got != "" { t.Fatalf("清空后列 = (%q, %v), want (\"\", true)", got, ok) } } func TestUpdateMessageVoiceRejectsMalformedAndUnknown(t *testing.T) { t.Parallel() refs := &recordingRefs{} st := newRefStore(t, refs) ctx := context.Background() msg := seedVoiceMessage(t, st, RoleUser, "(语音)") good := voiceRawWithCopy(t, "0123456789abcdef0123456789abcdef.wav") if err := st.UpdateMessageVoice(ctx, msg.ID, good); err != nil { t.Fatalf("先写一条合法值: %v", err) } // 坏形状全部拒绝,且**列保持原值**(绝不落下读不回来的内容)。 bad := []string{ "{not json", `{"source":"asr"}`, // 状态缺席(闭集外) `{"source":"telepathy","status":"transcribed"}`, // 来源不在闭集 `{"source":"asr","status":"transcribed","name":"x.ogg"}`, // 有 name 没有 mime/size(同生共死被破) `{"source":"asr","status":"transcribed","mime":"audio/x"}`, // 有 mime 没有 name `{"source":"asr","status":"transcribed","durationMs":-1}`, // 负时长 } for _, raw := range bad { err := st.UpdateMessageVoice(ctx, msg.ID, raw) if !errors.Is(err, voiceref.ErrBadRef) { t.Fatalf("UpdateMessageVoice(%q) err = %v, want wrap of ErrBadRef", raw, err) } } cols, err := st.VoiceColumns(ctx, []string{msg.ID}) if err != nil { t.Fatalf("VoiceColumns: %v", err) } if cols[msg.ID] != good { t.Fatalf("坏写入改动了列:%q, want %q", cols[msg.ID], good) } // 未知 id ⇒ ErrMessageNotFound(绝不假装写成功)。 if err := st.UpdateMessageVoice(ctx, "no-such-message", good); !errors.Is(err, ErrMessageNotFound) { t.Fatalf("未知 id err = %v, want ErrMessageNotFound", err) } } func TestUpdateMessageVoiceSyncsRefsByDifference(t *testing.T) { t.Parallel() refs := &recordingRefs{} st := newRefStore(t, refs) ctx := context.Background() msg := seedVoiceMessage(t, st, RoleUser, "(语音)") first := voiceRawWithCopy(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ogg") second := voiceRawWithCopy(t, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3") // ① 没有 → 有副本:retain 一次。 if err := st.UpdateMessageVoice(ctx, msg.ID, first); err != nil { t.Fatalf("第一次写入: %v", err) } // ② 内容逐字不变:一次都不增减(重复计数会让音频永远回收不掉)。 if err := st.UpdateMessageVoice(ctx, msg.ID, first); err != nil { t.Fatalf("同值重写: %v", err) } if len(refs.retained) != 1 || len(refs.released) != 0 { t.Fatalf("同值重写后 retain/release = %v/%v, want 1/0", refs.retained, refs.released) } // ③ 换成另一份副本:旧的释放、新的保留(各一次)。 if err := st.UpdateMessageVoice(ctx, msg.ID, second); err != nil { t.Fatalf("换副本: %v", err) } if len(refs.retained) != 2 || refs.retained[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3" { t.Fatalf("换副本后 retain = %v", refs.retained) } if len(refs.released) != 1 || refs.released[0] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ogg" { t.Fatalf("换副本后 release = %v", refs.released) } // ④ 有副本 → 无副本(微信那条路):release 一次,不新增 retain。 if err := st.UpdateMessageVoice(ctx, msg.ID, voiceRawWithoutCopy(t)); err != nil { t.Fatalf("写成无副本: %v", err) } if len(refs.retained) != 2 { t.Fatalf("无副本写法不该 retain:%v", refs.retained) } if len(refs.released) != 2 || refs.released[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3" { t.Fatalf("无副本写法 release = %v", refs.released) } } func TestVoiceColumnsBatchesAndSkipsBlanks(t *testing.T) { t.Parallel() st := newRefStore(t, &recordingRefs{}) ctx := context.Background() withVoice := seedVoiceMessage(t, st, RoleUser, "语音") without := seedVoiceMessage(t, st, RoleAssistant, "文本") raw := voiceRawWithCopy(t, "cccccccccccccccccccccccccccccccc.webm") if err := st.UpdateMessageVoice(ctx, withVoice.ID, raw); err != nil { t.Fatalf("UpdateMessageVoice: %v", err) } cols, err := st.VoiceColumns(ctx, []string{withVoice.ID, without.ID, "", withVoice.ID}) if err != nil { t.Fatalf("VoiceColumns: %v", err) } if len(cols) != 2 { t.Fatalf("映射大小 = %d, want 2", len(cols)) } if cols[withVoice.ID] != raw { t.Fatalf("语音行 = %q, want %q", cols[withVoice.ID], raw) } if got, ok := cols[without.ID]; !ok || got != "" { t.Fatalf("文本行 = (%q, %v), want (\"\", true)", got, ok) } if empty, err := st.VoiceColumns(ctx, nil); err != nil || len(empty) != 0 { t.Fatalf("空 id 列表 ⇒ 空映射且无错误,得到 (%v, %v)", empty, err) } }