First real commit. Setting up for full CI/CD and testing pipeline

This commit is contained in:
2026-11-04 22:30:59 +01:00
parent cbb843344e
commit e08c997914
26 changed files with 3561 additions and 198 deletions

39
internal/reader/engine.go Normal file
View File

@@ -0,0 +1,39 @@
package reader
import "time"
const (
MinWPM = 100
MaxWPM = 1000
)
// WordDuration calculates how long a word should be displayed
// at the specified reading speed.
func WordDuration(word string, wpm int) time.Duration {
// Enforce supported reading speed limits.
if wpm < MinWPM {
wpm = MinWPM
}
if wpm > MaxWPM {
wpm = MaxWPM
}
// Base duration for one word.
duration := time.Minute / time.Duration(wpm)
// Add pauses for punctuation.
switch {
case IsSentenceEnding(word):
duration += 300 * time.Millisecond
case IsPausePunctuation(word):
duration += 150 * time.Millisecond
}
// Allow slightly more time for longer words.
if RuneLength(word) >= 10 {
duration += 50 * time.Millisecond
}
return duration
}

View File

@@ -0,0 +1,273 @@
package reader
import (
"reflect"
"testing"
"time"
)
func TestORPIndex(t *testing.T) {
tests := []struct {
name string
word string
expected int
}{
{"empty", "", 0},
{"one character", "a", 0},
{"two characters", "go", 1},
{"three characters", "cat", 1},
{"five characters", "hello", 1},
{"six characters", "reader", 2},
{"nine characters", "wonderful", 2},
{"ten characters", "technology", 3},
{"long word", "extraordinary", 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ORPIndex([]rune(tt.word))
if got != tt.expected {
t.Errorf("ORPIndex(%q) = %d, want %d",
tt.word, got, tt.expected)
}
})
}
}
func TestSplitWord(t *testing.T) {
tests := []struct {
name string
input string
expected Word
}{
{
name: "normal word",
input: "hello",
expected: Word{
Text: "hello",
Prefix: "h",
Focus: "e",
Suffix: "llo",
},
},
{
name: "unicode word",
input: "café",
expected: Word{
Text: "café",
Prefix: "c",
Focus: "a",
Suffix: "fé",
},
},
{
name: "emoji",
input: "🙂hello",
expected: Word{
Text: "🙂hello",
Prefix: "🙂h",
Focus: "e",
Suffix: "llo",
},
},
{
name: "empty word",
input: "",
expected: Word{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SplitWord(tt.input)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("SplitWord(%q) = %+v, want %+v",
tt.input, got, tt.expected)
}
})
}
}
func TestTokenize(t *testing.T) {
tests := []struct {
name string
input string
expectedCount int
}{
{"normal text", "Hello world", 2},
{"multiple spaces", "Hello world", 2},
{"newlines", "Hello\nworld", 2},
{"tabs", "Hello\tworld", 2},
{"mixed whitespace", " Hello\n world\tagain ", 3},
{"empty input", "", 0},
{"whitespace only", " \n\t ", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Tokenize(tt.input)
if len(got) != tt.expectedCount {
t.Errorf("Tokenize(%q) returned %d words, want %d",
tt.input, len(got), tt.expectedCount)
}
})
}
}
func TestTokenizePreservesWords(t *testing.T) {
input := "Hello, world!"
words := Tokenize(input)
expected := []string{"Hello,", "world!"}
for i, word := range words {
if word.Text != expected[i] {
t.Errorf("word %d = %q, want %q",
i, word.Text, expected[i])
}
}
}
func TestIsSentenceEnding(t *testing.T) {
tests := []struct {
word string
expected bool
}{
{"hello.", true},
{"hello!", true},
{"hello?", true},
{"hello…", true},
{`hello."`, true},
{"hello!)", true},
{"hello", false},
{"hello,", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := IsSentenceEnding(tt.word)
if got != tt.expected {
t.Errorf("IsSentenceEnding(%q) = %t, want %t",
tt.word, got, tt.expected)
}
})
}
}
func TestIsPausePunctuation(t *testing.T) {
tests := []struct {
word string
expected bool
}{
{"hello,", true},
{"hello;", true},
{"hello:", true},
{`hello,"`, true},
{"hello", false},
{"hello.", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := IsPausePunctuation(tt.word)
if got != tt.expected {
t.Errorf("IsPausePunctuation(%q) = %t, want %t",
tt.word, got, tt.expected)
}
})
}
}
func TestWordDuration(t *testing.T) {
tests := []struct {
name string
word string
wpm int
expected time.Duration
}{
{
name: "normal word",
word: "hello",
wpm: 300,
expected: 200 * time.Millisecond,
},
{
name: "sentence ending",
word: "hello.",
wpm: 300,
expected: 500 * time.Millisecond,
},
{
name: "comma pause",
word: "hello,",
wpm: 300,
expected: 350 * time.Millisecond,
},
{
name: "long word",
word: "extraordinary",
wpm: 300,
expected: 250 * time.Millisecond,
},
{
name: "sentence ending and long word",
word: "extraordinary.",
wpm: 300,
expected: 550 * time.Millisecond,
},
{
name: "minimum WPM clamp",
word: "hello",
wpm: 50,
expected: 600 * time.Millisecond,
},
{
name: "maximum WPM clamp",
word: "hello",
wpm: 1500,
expected: 60 * time.Millisecond,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WordDuration(tt.word, tt.wpm)
if got != tt.expected {
t.Errorf("WordDuration(%q, %d) = %v, want %v",
tt.word, tt.wpm, got, tt.expected)
}
})
}
}
func TestRuneLength(t *testing.T) {
tests := []struct {
word string
expected int
}{
{"hello", 5},
{"café", 4},
{"🙂", 1},
{"🙂hello", 6},
{"", 0},
}
for _, tt := range tests {
t.Run(tt.word, func(t *testing.T) {
got := RuneLength(tt.word)
if got != tt.expected {
t.Errorf("RuneLength(%q) = %d, want %d",
tt.word, got, tt.expected)
}
})
}
}

20
internal/reader/orp.go Normal file
View File

@@ -0,0 +1,20 @@
package reader
// ORPIndex calculates the approximate optimal recognition
// point of a word.
//
// The returned value is a zero-based rune index.
func ORPIndex(runes []rune) int {
length := len(runes)
switch {
case length <= 1:
return 0
case length <= 5:
return 1
case length <= 9:
return 2
default:
return 3
}
}

View File

@@ -0,0 +1,98 @@
package reader
import (
"strings"
"unicode"
)
type Word struct {
Text string `json:"text"`
Prefix string `json:"prefix"`
Focus string `json:"focus"`
Suffix string `json:"suffix"`
}
// Tokenize splits text into words while ignoring repeated whitespace.
func Tokenize(text string) []Word {
fields := strings.Fields(text)
words := make([]Word, 0, len(fields))
for _, field := range fields {
words = append(words, SplitWord(field))
}
return words
}
// SplitWord separates a word into its prefix, focus character and suffix.
func SplitWord(word string) Word {
if word == "" {
return Word{}
}
runes := []rune(word)
position := ORPIndex(runes)
// Keep the focus index inside the word.
if position >= len(runes) {
position = len(runes) - 1
}
return Word{
Text: word,
Prefix: string(runes[:position]),
Focus: string(runes[position : position+1]),
Suffix: string(runes[position+1:]),
}
}
// lastMeaningfulRune returns the last rune after ignoring
// closing quotes and brackets.
func lastMeaningfulRune(word string) rune {
word = strings.TrimRightFunc(word, func(r rune) bool {
switch r {
case '"', '\'', '”', '’', ')', ']', '}', '»':
return true
default:
return false
}
})
runes := []rune(word)
if len(runes) == 0 {
return 0
}
return runes[len(runes)-1]
}
// IsSentenceEnding checks whether a word ends a sentence.
func IsSentenceEnding(word string) bool {
switch lastMeaningfulRune(word) {
case '.', '!', '?', '…':
return true
default:
return false
}
}
// IsPausePunctuation checks for punctuation that should
// introduce a short pause during playback.
func IsPausePunctuation(word string) bool {
switch lastMeaningfulRune(word) {
case ',', ';', ':':
return true
default:
return false
}
}
// RuneLength returns the number of Unicode characters in a word.
func RuneLength(word string) int {
return len([]rune(word))
}
// IsPunctuation reports whether the final meaningful character is punctuation.
func IsPunctuation(word string) bool {
return unicode.IsPunct(lastMeaningfulRune(word))
}