]> git.giacomolli.uk - onechat/blob - voice_transcriber_test.go
Require consent and review for autosquash
[onechat] / voice_transcriber_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13 )
14
15 func TestVoiceStoresPrivateAudioAndUsesPlainTextWithInternalTimestamps(t *testing.T) {
16 store, transcriber := voiceTestInput(t, `
17 output=
18 context=
19 while [ "$#" -gt 0 ]; do
20 case "$1" in
21 --no-timestamps) exit 2 ;;
22 --max-context) shift; context=$1 ;;
23 --output-file) shift; output=$1 ;;
24 esac
25 shift
26 done
27 [ "$context" = 0 ]
28 printf ' A made-up voice transcript.\n' > "$output.txt"
29 printf 'private speech in process output'
30 printf 'private speech in process errors' >&2
31 `)
32
33 data := []byte("made-up audio")
34 text := transcriber.Text(t.Context(), store, data)
35 require.Contains(t, text, "[voice ")
36 require.True(t, strings.HasSuffix(text, "]\nA made-up voice transcript."))
37 require.Equal(t, text, transcriber.Text(t.Context(), store, data))
38
39 files, err := os.ReadDir(store.dir)
40 require.NoError(t, err)
41 require.Len(t, files, 1)
42
43 saved, err := os.ReadFile(filepath.Join(store.dir, files[0].Name()))
44 require.NoError(t, err)
45 require.Equal(t, data, saved)
46
47 info, err := os.Stat(filepath.Join(store.dir, files[0].Name()))
48 require.NoError(t, err)
49 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
50
51 info, err = os.Stat(store.dir)
52 require.NoError(t, err)
53 require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
54 }
55
56 func TestVoiceFailuresKeepStoredAudioAndSafeMarkers(t *testing.T) {
57 testCases := []struct {
58 name string
59 script string
60 }{
61 {
62 name: "failed process does not expose its errors",
63 script: "echo secret-speech >&2; exit 1",
64 },
65 {
66 name: "missing transcript gets a marker",
67 script: "exit 0",
68 },
69 {
70 name: "empty transcript gets a marker",
71 script: "while [ \"$1\" != --output-file ]; do shift; done; shift; touch \"$1.txt\"",
72 },
73 }
74
75 for _, tc := range testCases {
76 t.Run(tc.name, func(t *testing.T) {
77 store, transcriber := voiceTestInput(t, tc.script)
78 text := transcriber.Text(t.Context(), store, []byte("made-up audio"))
79 require.Contains(t, text, "[voice ")
80 require.Contains(t, text, "(voice not transcribed: local transcription failed)")
81 require.NotContains(t, text, "secret-speech")
82
83 files, err := os.ReadDir(store.dir)
84 require.NoError(t, err)
85 require.Len(t, files, 1)
86 })
87 }
88 }
89
90 func TestVoiceCancellationAndCallerDeadlineStopTheProcess(t *testing.T) {
91 store, transcriber := voiceTestInput(t, "exec sleep 60")
92 ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
93 defer cancel()
94
95 text := transcriber.Text(ctx, store, []byte("made-up audio"))
96 require.Contains(t, text, "context deadline exceeded")
97
98 ctx, cancel = context.WithCancel(t.Context())
99 cancel()
100
101 text = transcriber.Text(ctx, store, []byte("made-up audio"))
102 require.Contains(t, text, "context canceled")
103 }
104
105 func TestVoiceUnavailableAndStorageFailuresDoNotStopIntake(t *testing.T) {
106 store, transcriber := voiceTestInput(t, "exit 0")
107 transcriber.config.Model = filepath.Join(t.TempDir(), "missing")
108 text := transcriber.Text(t.Context(), store, []byte("made-up audio"))
109 require.Contains(t, text, "[voice ")
110 require.Contains(t, text, "transcriber or model is not available")
111 require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, nil))
112 require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, bytes.Repeat([]byte{0}, voiceMaxBytes+1)))
113
114 blocked := filepath.Join(t.TempDir(), "blocked")
115 require.NoError(t, os.WriteFile(blocked, nil, 0o600))
116
117 store.dir = filepath.Join(blocked, "child")
118 require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, []byte("audio")))
119 }
120
121 func TestVoiceBackupsKeepAudioButNotTemporaryTranscripts(t *testing.T) {
122 root := t.TempDir()
123 store := &VoiceStore{dir: filepath.Join(root, voiceDirName)}
124 name, err := store.Save([]byte("made-up backed-up audio"))
125 require.NoError(t, err)
126
127 temp := filepath.Join(store.dir, ".transcript-in-progress")
128 require.NoError(t, os.Mkdir(temp, 0o700))
129 require.NoError(t, os.WriteFile(filepath.Join(temp, "text.txt"), []byte("private temporary text"), 0o600))
130
131 target := t.TempDir()
132 require.NoError(t, backupCopy(t.Context(), root, target, filepath.Join(t.TempDir(), "backup.tar.gz")))
133 require.FileExists(t, filepath.Join(target, voiceDirName, name))
134 require.NoDirExists(t, filepath.Join(target, voiceDirName, ".transcript-in-progress"))
135
136 archive := filepath.Join(t.TempDir(), "voice.tar.gz")
137 _, err = backupArchive(t.Context(), target, archive)
138 require.NoError(t, err)
139
140 restored := t.TempDir()
141 require.NoError(t, backupExtract(t.Context(), archive, restored))
142
143 audio, err := os.ReadFile(filepath.Join(restored, voiceDirName, name))
144 require.NoError(t, err)
145 require.Equal(t, []byte("made-up backed-up audio"), audio)
146
147 info, err := os.Stat(filepath.Join(restored, voiceDirName, name))
148 require.NoError(t, err)
149 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
150 }
151
152 func TestVoiceConfigReadsExplicitLocalPaths(t *testing.T) {
153 dir := t.TempDir()
154 configWriteTest(t, dir, `{"voice":{"transcriber":"local-whisper","model":"model.bin"}}`)
155
156 config, err := loadConfig(dir)
157 require.NoError(t, err)
158 require.Equal(t, VoiceConfig{
159 Transcriber: "local-whisper",
160 Model: "model.bin",
161 }, config.Voice)
162 }
163
164 func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) {
165 _, transcriber := voiceTestInput(t, "exit 0")
166 valid := transcriber.config
167 t.Setenv("PATH", filepath.Dir(valid.Transcriber))
168
169 testCases := []struct {
170 name string
171 config VoiceConfig
172 program string
173 model string
174 available bool
175 }{
176 {
177 name: "explicit paths override invalid built paths",
178 config: valid,
179 program: "missing",
180 model: "missing",
181 available: true,
182 },
183 {
184 name: "built paths supply absent settings",
185 program: valid.Transcriber,
186 model: valid.Model,
187 available: true,
188 },
189 {
190 name: "PATH supplies absent executable",
191 config: VoiceConfig{Model: valid.Model},
192 available: true,
193 },
194 {
195 name: "invalid explicit executable does not fall back",
196 config: VoiceConfig{
197 Transcriber: "missing",
198 Model: valid.Model,
199 },
200 program: valid.Transcriber,
201 },
202 {
203 name: "invalid explicit model does not fall back",
204 config: VoiceConfig{Model: "missing"},
205 program: valid.Transcriber,
206 model: valid.Model,
207 },
208 {
209 name: "missing model leaves input unavailable",
210 program: valid.Transcriber,
211 },
212 }
213
214 for _, tc := range testCases {
215 t.Run(tc.name, func(t *testing.T) {
216 program, model, err := voiceLookup(tc.config, tc.program, tc.model)
217 if !tc.available {
218 require.ErrorIs(t, err, errVoiceUnavailable)
219
220 return
221 }
222
223 require.NoError(t, err)
224 require.Equal(t, valid.Transcriber, program)
225 require.Equal(t, valid.Model, model)
226 })
227 }
228 }