1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
|
package main
import (
"encoding/xml"
"fmt"
"iter"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func testEpisode() *Episode {
return &Episode{
Number: 1,
Recorded: "2026-08-28",
Sources: []Source{{
Title: `Why We're "Dropping" Basecamp & Co`,
URL: "https://example.org/post?a=1&b=2",
Author: "Will Sexton",
Site: "Duke University Libraries Blog",
Published: "2023-11-30",
}},
Audio: Audio{File: "001.m4a", Bytes: 11672176, Duration: 1855350, MIME: "audio/mp4"},
}
}
func renderFixture(t *testing.T, eps ...*Episode) string {
t.Helper()
dir := t.TempDir()
d := &dirs{
episodes: filepath.Join(dir, "episodes"),
audio: filepath.Join(dir, "audio"),
out: filepath.Join(dir, "out"),
}
for _, e := range eps {
// writeEpisode's transcript writer only emits what the recogniser
// produces, since that is all it is ever handed in the program (see
// WriteTranscript). A fixture that needs quotations therefore keeps
// its own source text and writes that, rather than round-tripping
// through a writer that is not meant to serialise them.
src := transcriptSource[e.Transcript]
tr := e.Transcript
if src != "" {
e.Transcript = nil
}
if err := writeEpisode(d.episodes, e); err != nil {
t.Fatalf("writeEpisode: %v", err)
}
e.Transcript = tr
if src != "" {
p := transcriptPath(episodePath(d.episodes, e.Number))
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatalf("writing transcript: %v", err)
}
}
}
if err := render(d); err != nil {
t.Fatalf("render: %v", err)
}
return d.out
}
// transcriptSource remembers the text a fixture's transcript was parsed from,
// so renderFixture can put the same bytes on disk. Keyed by the document so
// that a fixture stays a *Episode and nothing else has to change.
var transcriptSource = map[*Document]string{}
// parseFixture parses a transcript and remembers its source for the fixture
// writer.
func parseFixture(t *testing.T, src string) *Document {
t.Helper()
d := mustParse(t, src)
transcriptSource[d] = src
return d
}
func read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading %s: %v", path, err)
}
return string(b)
}
// The feed is XML, and it is generated from user-supplied strings containing
// the characters that break XML. It must parse.
func TestFeedIsWellFormedXML(t *testing.T) {
out := renderFixture(t, testEpisode())
feed := read(t, filepath.Join(out, "feed.xml"))
var parsed struct {
Channel struct {
Title string `xml:"title"`
Items []struct {
Title string `xml:"title"`
GUID string `xml:"guid"`
Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
} `xml:"enclosure"`
} `xml:"item"`
} `xml:"channel"`
}
if err := xml.Unmarshal([]byte(feed), &parsed); err != nil {
t.Fatalf("feed is not well-formed XML: %v\n%s", err, feed)
}
if len(parsed.Channel.Items) != 1 {
t.Fatalf("items = %d, want 1", len(parsed.Channel.Items))
}
it := parsed.Channel.Items[0]
// Escaping must round-trip: the reader gets the original characters back.
if want := `001 — Why We're "Dropping" Basecamp & Co`; it.Title != want {
t.Errorf("title = %q, want %q", it.Title, want)
}
// The enclosure length is what podcast clients use for the progress bar,
// and a wrong one makes clients hang. It must be the real byte count.
if it.Enclosure.Length != 11672176 {
t.Errorf("enclosure length = %d, want 11672176", it.Enclosure.Length)
}
if it.Enclosure.Type != "audio/mp4" {
t.Errorf("enclosure type = %q, want audio/mp4", it.Enclosure.Type)
}
if !strings.HasPrefix(it.Enclosure.URL, baseURL+"/audio/") {
t.Errorf("enclosure url = %q, want it under %s/audio/", it.Enclosure.URL, baseURL)
}
}
// GUIDs and episode URLs are permanent. If this test has to be changed, every
// existing subscriber sees every episode again as new.
func TestPermanentIdentifiers(t *testing.T) {
out := renderFixture(t, testEpisode())
feed := read(t, filepath.Join(out, "feed.xml"))
for _, want := range []string{
"<guid isPermaLink=\"true\">https://observations.profpatsch.de/001/</guid>",
"<link>https://observations.profpatsch.de/001/</link>",
`url="https://observations.profpatsch.de/audio/001.m4a"`,
} {
if !strings.Contains(feed, want) {
t.Errorf("feed missing %s", want)
}
}
if _, err := os.Stat(filepath.Join(out, "001", "index.html")); err != nil {
t.Errorf("episode page not at /001/: %v", err)
}
}
func TestEpisodePage(t *testing.T) {
out := renderFixture(t, testEpisode())
page := read(t, filepath.Join(out, "001", "index.html"))
for _, want := range []string{
`<audio controls preload="metadata" id="player" src="/audio/001.m4a">`,
`href="https://example.org/post?a=1&b=2"`, // source link, escaped
"Will Sexton",
"2023-11-30",
`href="/feed.xml"`,
} {
if !strings.Contains(page, want) {
t.Errorf("episode page missing %q", want)
}
}
// No cover art exists, so the feed must not claim any: an itunes:image
// pointing at a missing file is worse than none.
feed := read(t, filepath.Join(out, "feed.xml"))
if strings.Contains(feed, "itunes:image") {
t.Error("feed declares itunes:image, but there is no artwork")
}
}
// Colours must name both modes. With light-dark() there is no ordering
// constraint left to enforce — a later rule cannot clobber the dark half,
// because there is no separate dark half — so what remains worth checking is
// that no colour is declared for one mode only. A bare hex here renders the
// same in both, which is how a page ends up dark-on-dark.
func TestColoursDeclareBothModes(t *testing.T) {
if !strings.Contains(styleCSS, "color-scheme: light dark") {
t.Error("missing `color-scheme: light dark` on :root; light-dark() will not " +
"resolve and the audio player will not follow the theme")
}
// The whole point of the rewrite: no media query should remain.
if strings.Contains(styleCSS, "prefers-color-scheme") {
t.Error("stylesheet still has a prefers-color-scheme block; colours are " +
"supposed to be expressed with light-dark() so that source order " +
"stops mattering")
}
// Every colour-valued declaration must go through light-dark(). Split into
// declarations first so that a hex *inside* light-dark() does not trip
// this, and so the property name is read from the declaration rather than
// from whatever text happens to precede it.
//
// All three stylesheets, not just the shared one: the trap is a property
// of writing a bare colour anywhere, and the two page-specific sheets are
// where new rules actually get added.
for name, sheet := range map[string]string{
"styleCSS": styleCSS, "replyCSS": replyCSS, "transcriptCSS": transcriptCSS,
} {
for prop, value := range cssDeclarations(sheet) {
switch prop {
case "color", "background", "background-color", "border", "border-top",
"border-left", "border-color":
if strings.Contains(value, "#") && !strings.Contains(value, "light-dark(") {
t.Errorf("%s: %s: %s — colour declared for one mode only; wrap it in light-dark()",
name, prop, value)
}
}
}
}
}
// cssDeclarations yields the property/value pairs of a flat stylesheet (no
// nesting, which is all this one uses).
func cssDeclarations(css string) iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
for _, block := range strings.Split(css, "}") {
body := block
if i := strings.Index(block, "{"); i >= 0 {
body = block[i+1:]
}
for _, decl := range strings.Split(body, ";") {
colon := strings.Index(decl, ":")
if colon < 0 {
continue
}
prop := strings.TrimSpace(decl[:colon])
value := strings.TrimSpace(decl[colon+1:])
if prop == "" || value == "" {
continue
}
if !yield(prop, value) {
return
}
}
}
}
}
// Links had a dark-mode colour but no light-mode one, so light mode fell back
// to the browser default while dark mode was styled. Naming both values is
// what surfaced it; this keeps it surfaced.
func TestLinkColoursAreStyledInBothModes(t *testing.T) {
for _, sel := range []string{"a { color: light-dark(", "a:visited { color: light-dark("} {
if !strings.Contains(styleCSS, sel) {
t.Errorf("missing %q: link colour is not defined for both modes", sel)
}
}
}
// A themed episode reads several texts and is named after neither of them.
func twoSourceEpisode() *Episode {
e := testEpisode()
e.Number = 2
e.EpisodeTitle = "Commoditizing Your Complement"
e.Sources = []Source{
{
Title: "Laws of Tech", URL: "https://gwern.net/complement",
Author: "Gwern Branwen", Site: "gwern.net",
Published: "2018-03-17", Note: "intro only",
},
{
Title: "Strategy Letter V", URL: "https://www.joelonsoftware.com/2002/06/12/strategy-letter-v/",
Author: "Joel Spolsky", Site: "Joel on Software",
Published: "2002-06-12", Note: "the main piece",
},
}
e.Audio.File = "002.m4a"
return e
}
func TestEpisodeTitleFallsBackToSoleSource(t *testing.T) {
// The common case writes no title, and must not have to.
if got, want := testEpisode().Subject(), `Why We're "Dropping" Basecamp & Co`; got != want {
t.Errorf("Subject() = %q, want the source title %q", got, want)
}
// A themed episode is named for its subject, not for either text.
if got, want := twoSourceEpisode().Subject(), "Commoditizing Your Complement"; got != want {
t.Errorf("Subject() = %q, want %q", got, want)
}
}
func TestMultipleSourcesRendered(t *testing.T) {
out := renderFixture(t, twoSourceEpisode())
page := read(t, filepath.Join(out, "002", "index.html"))
for _, want := range []string{
`<h1>002 — Commoditizing Your Complement</h1>`,
`href="https://gwern.net/complement"`,
`href="https://www.joelonsoftware.com/2002/06/12/strategy-letter-v/"`,
"Gwern Branwen, gwern.net",
"Joel Spolsky, Joel on Software",
// The notes are what tell a reader which text is which.
`<span class="note">(intro only)</span>`,
`<span class="note">(the main piece)</span>`,
} {
if !strings.Contains(page, want) {
t.Errorf("episode page missing %q", want)
}
}
// Every source must reach the feed, or a listener cannot find the texts.
feed := read(t, filepath.Join(out, "feed.xml"))
for _, want := range []string{"https://gwern.net/complement", "joelonsoftware.com"} {
if !strings.Contains(feed, want) {
t.Errorf("feed missing source %q", want)
}
}
index := read(t, filepath.Join(out, "index.html"))
if !strings.Contains(index, "Gwern Branwen and Joel Spolsky") {
t.Errorf("index byline should name both authors, got:\n%s", index)
}
}
// Single-source episodes are the ones already published, and their feed text
// must not drift: a changed description re-notifies every subscriber.
func TestSingleSourceSummaryUnchanged(t *testing.T) {
e := testEpisode()
got := episodeSummary(e)
want := "Reading \"Why We're \\\"Dropping\\\" Basecamp & Co\" by Will Sexton, " +
"Duke University Libraries Blog (2023-11-30), with commentary." +
"\n\nSource: https://example.org/post?a=1&b=2"
if got != want {
t.Errorf("summary drifted:\n got: %q\nwant: %q", got, want)
}
}
func TestIndexListsEpisodesNewestFirst(t *testing.T) {
a := testEpisode()
b := testEpisode()
b.Number = 2
b.Sources[0].Title = "Second"
b.Recorded = "2026-09-01"
out := renderFixture(t, a, b)
index := read(t, filepath.Join(out, "index.html"))
i2 := strings.Index(index, "/002/")
i1 := strings.Index(index, "/001/")
if i2 < 0 || i1 < 0 {
t.Fatalf("index does not link both episodes")
}
if i2 > i1 {
t.Error("index lists 001 before 002; newest should come first")
}
}
func TestEmptySiteStillRenders(t *testing.T) {
out := renderFixture(t)
index := read(t, filepath.Join(out, "index.html"))
if !strings.Contains(index, "No episodes yet") {
t.Error("empty index should say so")
}
feed := read(t, filepath.Join(out, "feed.xml"))
if err := xml.Unmarshal([]byte(feed), new(struct{})); err != nil {
t.Errorf("empty feed is not well-formed: %v", err)
}
}
func TestDurationFormatting(t *testing.T) {
for _, c := range []struct {
ms int64
hms string
short string
}{
{1855350, "30:55", "30 min"},
{3600000, "1:00:00", "60 min"},
{3661000, "1:01:01", "61 min"},
{30000, "0:30", "under a minute"},
} {
e := &Episode{Audio: Audio{Duration: c.ms}}
if got := e.DurationHMS(); got != c.hms {
t.Errorf("DurationHMS(%d) = %q, want %q", c.ms, got, c.hms)
}
if got := e.DurationShort(); got != c.short {
t.Errorf("DurationShort(%d) = %q, want %q", c.ms, got, c.short)
}
}
}
func TestValidateRejectsBadInput(t *testing.T) {
for name, mangle := range map[string]func(*Episode){
"no title": func(e *Episode) { e.Sources[0].Title = "" },
"no url": func(e *Episode) { e.Sources[0].URL = "" },
"no author": func(e *Episode) { e.Sources[0].Author = "" },
"no published": func(e *Episode) { e.Sources[0].Published = "" },
"non-http url": func(e *Episode) { e.Sources[0].URL = "ftp://example.org" },
"bad recorded": func(e *Episode) { e.Recorded = "28.08.2026" },
"bad published": func(e *Episode) { e.Sources[0].Published = "Nov 2023" },
"zero number": func(e *Episode) { e.Number = 0 },
"no sources": func(e *Episode) { e.Sources = nil },
} {
e := testEpisode()
mangle(e)
if err := e.validate(); err == nil {
t.Errorf("%s: validate() accepted it", name)
}
}
if err := testEpisode().validate(); err != nil {
t.Errorf("valid episode rejected: %v", err)
}
}
// Episode JSON is the durable artefact, so a round-trip must be lossless —
// including the transcript, which cannot be regenerated.
func TestEpisodeRoundTrip(t *testing.T) {
dir := t.TempDir()
e := testEpisode()
e.Transcript = NewDocument(&Transcript{Segments: []Segment{{
Language: "en-US",
Words: []Word{
{Text: "Hey", Formatted: "Hey,", StartMs: 150, EndMs: 930, Paragraph: true},
{Text: "there", StartMs: 930, EndMs: 1110},
},
}}})
e.Transcript.Episode = "001"
if err := writeEpisode(dir, e); err != nil {
t.Fatalf("writeEpisode: %v", err)
}
got, err := readEpisode(episodePath(dir, 1))
if err != nil {
t.Fatalf("readEpisode: %v", err)
}
if got.Transcript == nil || got.Transcript.WordCount() != 2 {
t.Fatalf("transcript did not survive the round trip: %+v", got.Transcript)
}
if got.Transcript.Text() != e.Transcript.Text() {
t.Errorf("text changed: %q, want %q", got.Transcript.Text(), e.Transcript.Text())
}
// The timings are the thing that cannot be regenerated, so they are what
// a round trip has to preserve exactly.
for i, want := range e.Transcript.Entries {
if got.Transcript.Entries[i] != want {
t.Errorf("entry %d = %+v, want %+v", i, got.Transcript.Entries[i], want)
}
}
if got.Audio.Bytes != e.Audio.Bytes {
t.Errorf("bytes = %d, want %d", got.Audio.Bytes, e.Audio.Bytes)
}
}
// The transcript lives beside the episode, not inside it: the whole point is
// that the episode file stays small enough to read and to diff.
func TestTranscriptStoredSeparately(t *testing.T) {
dir := t.TempDir()
e := testEpisode()
e.Transcript = NewDocument(&Transcript{Segments: []Segment{{
Language: "en-US",
Words: []Word{{Text: "Hey", StartMs: 150, EndMs: 930}},
}}})
if err := writeEpisode(dir, e); err != nil {
t.Fatalf("writeEpisode: %v", err)
}
meta := read(t, episodePath(dir, 1))
if strings.Contains(meta, "00:00:00") || strings.Contains(meta, "Hey") {
t.Errorf("episode file contains transcript data:\n%s", meta)
}
if !strings.Contains(read(t, transcriptPath(episodePath(dir, 1))), "00:00:00.150") {
t.Error("transcript file does not contain the transcript")
}
}
// A transcript companion must not be picked up as an episode of its own, or
// every episode would appear twice and the second copy would fail to parse.
func TestTranscriptFileIsNotAnEpisode(t *testing.T) {
dir := t.TempDir()
e := testEpisode()
e.Transcript = NewDocument(&Transcript{Segments: []Segment{{
Words: []Word{{Text: "Hey", StartMs: 0, EndMs: 1}},
}}})
if err := writeEpisode(dir, e); err != nil {
t.Fatalf("writeEpisode: %v", err)
}
eps, err := loadEpisodes(dir)
if err != nil {
t.Fatalf("loadEpisodes: %v", err)
}
if len(eps) != 1 {
t.Fatalf("loaded %d episodes, want 1", len(eps))
}
if eps[0].Transcript == nil {
t.Error("episode loaded without its transcript")
}
}
// Episodes without a transcript have no companion file, and must still load.
func TestEpisodeWithoutTranscriptLoads(t *testing.T) {
dir := t.TempDir()
e := testEpisode()
e.Transcript = nil
if err := writeEpisode(dir, e); err != nil {
t.Fatalf("writeEpisode: %v", err)
}
got, err := readEpisode(episodePath(dir, 1))
if err != nil {
t.Fatalf("readEpisode: %v", err)
}
if got.Transcript != nil {
t.Errorf("expected no transcript, got %d words", got.Transcript.WordCount())
}
}
// The drop zone lives on episode pages, not on the index.
//
// It is deliberately not on the front page: the passphrase that unlocks it is
// spoken inside an episode, so on the index it would be a file input, a
// required field and a consent checkbox standing between a visitor and the
// list of episodes, usable only by people who had already listened.
func TestDropZoneIsOnEpisodePagesNotTheIndex(t *testing.T) {
out := renderFixture(t, testEpisode())
index := read(t, filepath.Join(out, "index.html"))
// Neither the markup nor the styling for it: the CSS is split so the
// index does not ship rules for elements it does not have.
for _, unwanted := range []string{
`<form class="reply"`, `name="passphrase"`, "var maxBytes",
".drop {", ".recorder {",
// Same argument for the transcript: the index has no transcript, so
// it should not carry the rules for one either.
".transcript {", "data-t=",
} {
if strings.Contains(index, unwanted) {
t.Errorf("the index carries %q; it belongs on episode pages", unwanted)
}
}
episode := read(t, filepath.Join(out, "001", "index.html"))
if !strings.Contains(episode, `<form class="reply"`) {
t.Error("the episode page has no reply form")
}
}
// The drop zone is progressive enhancement: the markup must be a real form
// that works with JavaScript switched off, with the script only making it
// nicer. A future edit that turns it into a div with a click handler would
// silently drop everyone who blocks scripts.
func TestDropZoneWorksWithoutJavaScript(t *testing.T) {
out := renderFixture(t, testEpisode())
page := read(t, filepath.Join(out, "001", "index.html"))
for _, want := range []string{
`<form class="reply"`,
`method="POST"`,
`action="/submit"`,
`enctype="multipart/form-data"`,
`<input type="file" name="file"`,
`name="passphrase"`,
`<button type="submit"`,
} {
if !strings.Contains(page, want) {
t.Errorf("episode page is missing %q, so the form does not work without JS", want)
}
}
}
// Consent to transcription is the sender's to give, so the box must arrive
// unticked. A `checked` attribute here would be consent-by-default.
func TestConsentCheckboxIsUnticked(t *testing.T) {
out := renderFixture(t, testEpisode())
page := read(t, filepath.Join(out, "001", "index.html"))
i := strings.Index(page, `name="consent"`)
if i < 0 {
t.Fatal("no consent checkbox on the page")
}
// Look at the input element itself, not the whole document.
start := strings.LastIndex(page[:i], "<input")
end := strings.Index(page[i:], ">") + i
if start < 0 || end < start {
t.Fatal("could not isolate the consent input")
}
if strings.Contains(page[start:end], "checked") {
t.Errorf("consent checkbox is pre-ticked: %s", page[start:end])
}
if !strings.Contains(page, "Gemini") {
t.Error("the page does not say who would receive the audio")
}
}
// The stated limit and the one the browser enforces must be the same number,
// and both must match what the server accepts.
func TestStatedSizeLimitMatchesEnforcedOne(t *testing.T) {
out := renderFixture(t, testEpisode())
page := read(t, filepath.Join(out, "001", "index.html"))
if !strings.Contains(page, "var maxBytes = 5242880;") {
t.Error("the script does not enforce the documented limit")
}
if !strings.Contains(page, maxSubmitSize) {
t.Errorf("the page does not state the %s limit", maxSubmitSize)
}
if maxSubmitBytes != 5<<20 {
t.Errorf("maxSubmitBytes = %d; observations-inbox accepts 5 MB", maxSubmitBytes)
}
}
// The recorder is an addition to the form, not a replacement for it. It must
// ship hidden, so that a browser without MediaRecorder — or with JavaScript
// off entirely — never shows a Record button that does nothing, and is left
// with the file input, which works everywhere.
func TestRecorderShipsHidden(t *testing.T) {
out := renderFixture(t, testEpisode())
page := read(t, filepath.Join(out, "001", "index.html"))
i := strings.Index(page, `class="recorder"`)
if i < 0 {
t.Fatal("no recorder on the episode page")
}
start := strings.LastIndex(page[:i], "<div")
end := strings.Index(page[i:], ">") + i
if !strings.Contains(page[start:end], "hidden") {
t.Errorf("the recorder is not hidden by default: %s", page[start:end])
}
// It is only revealed behind a feature test.
if !strings.Contains(page, "window.MediaRecorder") {
t.Error("the recorder is not behind a MediaRecorder feature test")
}
// The button must not be a submit button, or pressing Record would post
// the form.
if !strings.Contains(page, `<button type="button" id="rec">`) {
t.Error(`the Record button is not type="button"`)
}
}
// transcriptEpisode is an episode whose transcript exercises the shapes that
// actually occur: a quotation opening mid-paragraph (31 of episode 001's 32
// do), a paragraph break inside a quotation, and a second quotation from a
// different source.
func transcriptEpisode(t *testing.T) *Episode {
t.Helper()
e := testEpisode()
e.Transcript = parseFixture(t, `.episode 001
.recorded 2026-08-28
.source sexton "Will Sexton" "Why We're Dropping Basecamp"
.url sexton https://example.org/basecamp
.source spolsky "Joel Spolsky" "Strategy Letter V"
.url spolsky https://example.org/v
.p
00:00:00.150 00:00:02.670 Hey, and welcome to observations.
00:00:41.490 00:00:43.530 And the article starts like this.
.quote sexton
00:00:45.510 00:00:47.730 We at Duke University libraries have
00:00:47.730 00:00:49.650 decided to stop using it.
.p
00:00:50.000 00:00:52.000 A second paragraph of the quotation.
.endquote
00:00:52.110 00:00:54.150 I'm not yet done with the intro.
.p
00:01:00.000 00:01:02.000 And now something else entirely.
.quote spolsky
00:01:03.000 00:01:05.000 Smart companies commoditize.
.endquote
`)
return e
}
func transcriptOf(t *testing.T, page string) string {
t.Helper()
i := strings.Index(page, `<div class="transcript"`)
if i < 0 {
t.Fatal("no transcript on the page")
}
rest := page[i:]
j := strings.Index(rest, "\n<script")
if j < 0 {
t.Fatal("could not find the end of the transcript")
}
return rest[:j]
}
// The transcript is the episode in text, and rendering it is the whole point
// of extracting it at ingest. Every line of speech must reach the page, in
// order and exactly once: a bug in the block building would show up as words
// quietly missing from the middle of an episode.
func TestTranscriptRendersEveryLineInOrder(t *testing.T) {
e := transcriptEpisode(t)
out := renderFixture(t, e)
body := transcriptOf(t, read(t, filepath.Join(out, "001", "index.html")))
var want []string
for _, entry := range e.Transcript.Entries {
if entry.Kind == EntrySpeech {
want = append(want, entry.Text)
}
}
at := 0
for _, line := range want {
// Apostrophes are escaped in the output, so compare on the escaped
// form the template produces.
esc := strings.ReplaceAll(line, "'", "'")
i := strings.Index(body[at:], esc)
if i < 0 {
t.Fatalf("line %q missing from the transcript, or out of order", line)
}
at += i + len(esc)
}
if got, want := strings.Count(body, "<span data-t="), len(want); got != want {
t.Errorf("%d spans on the page, want %d — a line is duplicated or dropped", got, want)
}
}
// The timings are the part of a transcript that cannot be regenerated, and
// the script seeks with them. They must reach the page to the millisecond.
func TestTranscriptCarriesExactTimings(t *testing.T) {
e := transcriptEpisode(t)
out := renderFixture(t, e)
body := transcriptOf(t, read(t, filepath.Join(out, "001", "index.html")))
for _, entry := range e.Transcript.Entries {
if entry.Kind != EntrySpeech {
continue
}
want := fmt.Sprintf(`data-t="%d,%d"`, entry.Start, entry.End)
if !strings.Contains(body, want) {
t.Errorf("transcript missing %s for %q", want, entry.Text)
}
}
}
// A <blockquote> inside a <p> is invalid HTML, and browsers recover from it
// by closing the paragraph early — which silently reorders the page. Since
// nearly every quotation opens mid-paragraph, this is the failure the block
// building exists to prevent.
func TestQuotationsSplitTheirParagraph(t *testing.T) {
out := renderFixture(t, transcriptEpisode(t))
body := transcriptOf(t, read(t, filepath.Join(out, "001", "index.html")))
depth := 0
for _, tok := range regexp.MustCompile(`</?(?:p|blockquote)\b[^>]*>`).FindAllString(body, -1) {
switch {
case strings.HasPrefix(tok, "</p"):
depth--
case strings.HasPrefix(tok, "<p"):
depth++
case strings.HasPrefix(tok, "<blockquote"):
if depth > 0 {
t.Fatalf("a <blockquote> is nested inside a <p>:\n%s", body)
}
}
}
if depth != 0 {
t.Errorf("unbalanced <p> elements in the transcript (depth %d)", depth)
}
// The commentary after a quotation must survive the split.
if !strings.Contains(body, "I'm not yet done with the intro.") {
t.Error("the speech following a quotation was lost in the split")
}
// A paragraph break inside a quotation stays inside it.
quote := body[strings.Index(body, "<blockquote>"):]
quote = quote[:strings.Index(quote, "</blockquote>")]
if !strings.Contains(quote, "A second paragraph of the quotation.") {
t.Error("a paragraph break inside a quotation broke out of it")
}
}
// A quotation is the text being read rather than the reaction to it, so it
// has to say which text. Repeating that under all 32 of an episode's passages
// is noise, though, so it is stated when the source changes.
func TestQuotationsAreAttributedOnChange(t *testing.T) {
out := renderFixture(t, transcriptEpisode(t))
body := transcriptOf(t, read(t, filepath.Join(out, "001", "index.html")))
if got := strings.Count(body, `class="attrib"`); got != 2 {
t.Errorf("%d attributions, want 2 (one per source, not one per passage)", got)
}
for _, want := range []string{
`<a href="https://example.org/basecamp" target="_blank" rel="noopener">Why We're Dropping Basecamp, Will Sexton</a>`,
`<a href="https://example.org/v" target="_blank" rel="noopener">Strategy Letter V, Joel Spolsky</a>`,
} {
if !strings.Contains(body, want) {
t.Errorf("transcript missing attribution %s", want)
}
}
}
// Repeated passages from one text are credited once. An episode reads a
// single text in dozens of pieces, and a credit under every one of them
// buries the case that matters — an episode alternating between two.
func TestRepeatedQuotationsAreNotRecredited(t *testing.T) {
d := mustParse(t, `.source a "A Author" "A Title"
.source b "B Author" "B Title"
.quote a
00:00:00.000 00:00:01.000 one
.endquote
.quote a
00:00:01.000 00:00:02.000 two
.endquote
.quote b
00:00:02.000 00:00:03.000 three
.endquote
.quote a
00:00:03.000 00:00:04.000 four
.endquote
`)
var got []string
for _, b := range transcriptBlocks(d) {
if b.Quote {
got = append(got, b.AttribText())
}
}
want := []string{"A Title, A Author", "", "B Title, B Author", "A Title, A Author"}
if len(got) != len(want) {
t.Fatalf("got %d quotations, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("quotation %d attributed %q, want %q", i, got[i], want[i])
}
}
}
// An episode ingested without a transcript has nothing to show, and must not
// render an empty heading over an empty box.
func TestEpisodeWithoutTranscriptRendersNoSection(t *testing.T) {
e := testEpisode()
e.Transcript = nil
out := renderFixture(t, e)
page := read(t, filepath.Join(out, "001", "index.html"))
for _, unwanted := range []string{`id="transcript"`, "<blockquote", "data-t="} {
if strings.Contains(page, unwanted) {
t.Errorf("an episode with no transcript still renders %q", unwanted)
}
}
}
// The transcript belongs on the page, not in the feed: a feed entry carries
// the summary, and 10,000 words in every <description> would bloat the feed
// and re-notify every subscriber whenever a correction landed.
func TestTranscriptIsNotInTheFeed(t *testing.T) {
out := renderFixture(t, transcriptEpisode(t))
feed := read(t, filepath.Join(out, "feed.xml"))
for _, unwanted := range []string{"data-t=", "We at Duke University libraries"} {
if strings.Contains(feed, unwanted) {
t.Errorf("the feed carries transcript content (%q)", unwanted)
}
}
}
// Seeking is progressive enhancement, like the reply form: the words have to
// be on the page as prose for a reader with no scripting, and the paragraph
// timestamps have to work as ordinary in-page anchors.
func TestTranscriptWorksWithoutJavaScript(t *testing.T) {
out := renderFixture(t, transcriptEpisode(t))
body := transcriptOf(t, read(t, filepath.Join(out, "001", "index.html")))
if !strings.Contains(body, "Hey, and welcome to observations.") {
t.Error("the transcript text is not in the markup")
}
// The anchor and the paragraph it names must agree, or the link goes
// nowhere.
if !strings.Contains(body, `<p id="t150">`) {
t.Error("paragraphs carry no anchor to link to")
}
if !strings.Contains(body, `<a class="ts" href="#t150">0:00</a>`) {
t.Error("the paragraph timestamp is not a link to its own anchor")
}
// The cursor only becomes a pointer once the script has said so, and only
// while the recording is running.
if !strings.Contains(read(t, filepath.Join(out, "001", "index.html")),
"classList.toggle('seekable', !player.paused)") {
t.Error("the transcript is styled as clickable without the script enabling it")
}
}
// Every quotation links back to the passage it was read from, unlike the
// credit line, which appears once per source. The link carries a text
// fragment so the reader lands on the sentence rather than the article.
func TestQuotationsLinkToTheSource(t *testing.T) {
out := renderFixture(t, transcriptEpisode(t))
body := transcriptOf(t, read(t, filepath.Join(out, "001", "index.html")))
// Two quotations, two marks — one per passage, not one per source.
if got := strings.Count(body, `class="src"`); got != 2 {
t.Errorf("%d source marks, want one per quotation (2)", got)
}
if !strings.Contains(body, "https://example.org/basecamp#:~:text=We%20at%20Duke%20University") {
t.Errorf("first quotation has no text fragment to its passage:\n%s", body)
}
// The arrow says nothing to a screen reader, so words have to be there —
// including the warning that the link opens elsewhere, which is otherwise
// a surprise rather than a navigation.
if !strings.Contains(body, `<span class="vh"> Read this passage in the original (opens in a new tab)</span>`) {
t.Error("the source mark has no accessible name")
}
// The mark goes at the end of the passage, not after each paragraph of a
// quotation that has several.
quote := body[strings.Index(body, "<blockquote>"):]
quote = quote[:strings.Index(quote, "</blockquote>")]
if strings.Count(quote, `class="src"`) != 1 {
t.Errorf("a multi-paragraph quotation carries %d marks, want 1",
strings.Count(quote, `class="src"`))
}
}
// .skip moves the fragment past words the speaker reworded. Without it the
// fragment matches nothing and the link falls back to the top of the article.
func TestSkipMovesTheFragment(t *testing.T) {
d := mustParse(t, `.source a "A Author" "A Title"
.url a https://example.org/a
.quote a
.skip 2
00:00:00.000 00:00:01.000 from which the DEI movement drew its
.endquote
`)
blocks := transcriptBlocks(d)
if len(blocks) != 1 {
t.Fatalf("got %d blocks, want 1", len(blocks))
}
// Two words skipped: the fragment starts at the third.
want := "https://example.org/a#:~:text=the%20DEI%20movement%20drew"
if blocks[0].Link != want {
t.Errorf("link = %q, want %q", blocks[0].Link, want)
}
}
// A skip that runs past the end of the quotation would produce an empty
// fragment, which matches nothing; the link must degrade to the article.
func TestOversizedSkipFallsBackToTheArticle(t *testing.T) {
d := mustParse(t, `.source a "A Author" "A Title"
.url a https://example.org/a
.quote a
.skip 50
00:00:00.000 00:00:01.000 only four words here
.endquote
`)
b := transcriptBlocks(d)[0]
if b.Link != "https://example.org/a" {
t.Errorf("link = %q, want the bare article URL", b.Link)
}
}
// A source with no URL has nothing to link to, so no mark is drawn.
func TestQuotationWithoutURLHasNoMark(t *testing.T) {
d := mustParse(t, `.source a "A Author" "A Title"
.quote a
00:00:00.000 00:00:01.000 some words here
.endquote
`)
if got := transcriptBlocks(d)[0].Link; got != "" {
t.Errorf("link = %q, want none when the source has no URL", got)
}
}
// A transcript timestamp and the player have to be one clock. Two would be a
// bug nobody notices until an episode runs past the hour, which 002 does.
func TestTranscriptTimecodesMatchThePlayer(t *testing.T) {
for _, c := range []struct {
ms int64
want string
}{
{0, "0:00"},
{150, "0:00"},
{74250, "1:14"},
{4276320, "1:11:16"}, // episode 002 is this long
} {
if got := timecode(c.ms); got != c.want {
t.Errorf("timecode(%d) = %q, want %q", c.ms, got, c.want)
}
}
// The same function formats the episode's running time, so the two cannot
// drift apart.
e := &Episode{Audio: Audio{Duration: 4276320}}
if got := e.DurationHMS(); got != timecode(4276320) {
t.Errorf("DurationHMS = %q but timecode = %q", got, timecode(4276320))
}
}
// Navigation belongs above the fold on an episode page. With the transcript
// inline, a nav at the bottom sits below ten thousand words.
func TestEpisodeNavIsAboveTheHeading(t *testing.T) {
out := renderFixture(t, transcriptEpisode(t))
page := read(t, filepath.Join(out, "001", "index.html"))
nav := strings.Index(page, `<nav class="top">`)
h1 := strings.Index(page, "<h1>")
if nav < 0 {
t.Fatal("no nav on the episode page")
}
if nav > h1 {
t.Error("the nav is below the heading; it should lead the page")
}
if strings.Count(page, "All episodes") != 1 {
t.Error("the nav appears more than once")
}
}
// A recorded take has to end up in the same file input a dropped file lands
// in, so that submission has exactly one path through the checks.
func TestRecordedTakeGoesThroughTheFileInput(t *testing.T) {
out := renderFixture(t, testEpisode())
page := read(t, filepath.Join(out, "001", "index.html"))
for _, want := range []string{
"new DataTransfer()", // the only way to build a FileList
"file.files = dt.files",
"file.dispatchEvent(new Event('change'))",
} {
if !strings.Contains(page, want) {
t.Errorf("the recorder does not feed the file input (%q missing)", want)
}
}
}
|