First real commit. Setting up for full CI/CD and testing pipeline
This commit is contained in:
39
internal/reader/engine.go
Normal file
39
internal/reader/engine.go
Normal 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
|
||||
}
|
||||
273
internal/reader/engine_test.go
Normal file
273
internal/reader/engine_test.go
Normal 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
20
internal/reader/orp.go
Normal 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
|
||||
}
|
||||
}
|
||||
98
internal/reader/tokenizer.go
Normal file
98
internal/reader/tokenizer.go
Normal 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))
|
||||
}
|
||||
64
internal/settings/settings.go
Normal file
64
internal/settings/settings.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package settings
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
MinReadingSpeed = 100
|
||||
MaxReadingSpeed = 1000
|
||||
|
||||
MinFontSize = 24
|
||||
MaxFontSize = 96
|
||||
|
||||
ThemeDark = "dark"
|
||||
ThemeLight = "light"
|
||||
)
|
||||
|
||||
// Settings contains user preferences.
|
||||
type Settings struct {
|
||||
ReadingSpeed int `json:"readingSpeed"`
|
||||
FontSize int `json:"fontSize"`
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
|
||||
// Default returns the default application settings.
|
||||
func Default() Settings {
|
||||
return Settings{
|
||||
ReadingSpeed: 300,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks whether all settings contain supported values.
|
||||
func (s Settings) Validate() bool {
|
||||
return s.ReadingSpeed >= MinReadingSpeed &&
|
||||
s.ReadingSpeed <= MaxReadingSpeed &&
|
||||
s.FontSize >= MinFontSize &&
|
||||
s.FontSize <= MaxFontSize &&
|
||||
(s.Theme == ThemeDark || s.Theme == ThemeLight)
|
||||
}
|
||||
|
||||
// Normalize returns settings with values restricted to supported options.
|
||||
func (s Settings) Normalize() Settings {
|
||||
defaults := Default()
|
||||
|
||||
if s.ReadingSpeed < MinReadingSpeed {
|
||||
s.ReadingSpeed = MinReadingSpeed
|
||||
} else if s.ReadingSpeed > MaxReadingSpeed {
|
||||
s.ReadingSpeed = MaxReadingSpeed
|
||||
}
|
||||
|
||||
if s.FontSize < MinFontSize {
|
||||
s.FontSize = MinFontSize
|
||||
} else if s.FontSize > MaxFontSize {
|
||||
s.FontSize = MaxFontSize
|
||||
}
|
||||
|
||||
s.Theme = strings.ToLower(strings.TrimSpace(s.Theme))
|
||||
|
||||
if s.Theme != ThemeDark && s.Theme != ThemeLight {
|
||||
s.Theme = defaults.Theme
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
214
internal/settings/settings_test.go
Normal file
214
internal/settings/settings_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
got := Default()
|
||||
|
||||
if got.ReadingSpeed != 300 {
|
||||
t.Errorf("ReadingSpeed = %d, want 300", got.ReadingSpeed)
|
||||
}
|
||||
|
||||
if got.FontSize != 48 {
|
||||
t.Errorf("FontSize = %d, want 48", got.FontSize)
|
||||
}
|
||||
|
||||
if got.Theme != ThemeDark {
|
||||
t.Errorf("Theme = %q, want %q", got.Theme, ThemeDark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings Settings
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid defaults",
|
||||
settings: Default(),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "minimum values",
|
||||
settings: Settings{
|
||||
ReadingSpeed: MinReadingSpeed,
|
||||
FontSize: MinFontSize,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "maximum values",
|
||||
settings: Settings{
|
||||
ReadingSpeed: MaxReadingSpeed,
|
||||
FontSize: MaxFontSize,
|
||||
Theme: ThemeLight,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid reading speed",
|
||||
settings: Settings{
|
||||
ReadingSpeed: 50,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "reading speed too high",
|
||||
settings: Settings{
|
||||
ReadingSpeed: 1500,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid font size",
|
||||
settings: Settings{
|
||||
ReadingSpeed: 300,
|
||||
FontSize: 10,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid theme",
|
||||
settings: Settings{
|
||||
ReadingSpeed: 300,
|
||||
FontSize: 48,
|
||||
Theme: "purple",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.settings.Validate(); got != tt.want {
|
||||
t.Errorf("Validate() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input Settings
|
||||
want Settings
|
||||
}{
|
||||
{
|
||||
name: "valid settings unchanged",
|
||||
input: Default(),
|
||||
want: Default(),
|
||||
},
|
||||
{
|
||||
name: "speed below minimum clamped",
|
||||
input: Settings{
|
||||
ReadingSpeed: 50,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
want: Settings{
|
||||
ReadingSpeed: MinReadingSpeed,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "speed above maximum clamped",
|
||||
input: Settings{
|
||||
ReadingSpeed: 1200,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
want: Settings{
|
||||
ReadingSpeed: MaxReadingSpeed,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "font below minimum clamped",
|
||||
input: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: 10,
|
||||
Theme: ThemeLight,
|
||||
},
|
||||
want: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: MinFontSize,
|
||||
Theme: ThemeLight,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "font above maximum clamped",
|
||||
input: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: 120,
|
||||
Theme: ThemeLight,
|
||||
},
|
||||
want: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: MaxFontSize,
|
||||
Theme: ThemeLight,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid theme replaced",
|
||||
input: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: 48,
|
||||
Theme: "purple",
|
||||
},
|
||||
want: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: 48,
|
||||
Theme: ThemeDark,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "theme whitespace and case normalized",
|
||||
input: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: 48,
|
||||
Theme: " LIGHT ",
|
||||
},
|
||||
want: Settings{
|
||||
ReadingSpeed: 400,
|
||||
FontSize: 48,
|
||||
Theme: ThemeLight,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.input.Normalize()
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("Normalize() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAlwaysProducesValidSettings(t *testing.T) {
|
||||
tests := []Settings{
|
||||
{},
|
||||
{ReadingSpeed: -1, FontSize: -1, Theme: "invalid"},
|
||||
{ReadingSpeed: 2000, FontSize: 200, Theme: " BLUE "},
|
||||
{ReadingSpeed: 300, FontSize: 48, Theme: " LIGHT "},
|
||||
}
|
||||
|
||||
for _, input := range tests {
|
||||
got := input.Normalize()
|
||||
|
||||
if !got.Validate() {
|
||||
t.Errorf("Normalize(%+v) produced invalid settings: %+v",
|
||||
input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
internal/storage/storage.go
Normal file
66
internal/storage/storage.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Encode serializes a value into JSON.
|
||||
func Encode(value any) ([]byte, error) {
|
||||
return json.MarshalIndent(value, "", " ")
|
||||
}
|
||||
|
||||
// Decode deserializes JSON into the supplied value.
|
||||
func Decode(data []byte, target any) error {
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
// Save writes a value to a JSON file.
|
||||
func Save(path string, value any) error {
|
||||
data, err := Encode(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp(dir, ".settings-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempPath := file.Name()
|
||||
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
if _, err := file.Write(data); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tempPath, path)
|
||||
}
|
||||
|
||||
// Load reads a JSON file into the supplied target.
|
||||
func Load(path string, target any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Decode(data, target)
|
||||
}
|
||||
176
internal/storage/storage_test.go
Normal file
176
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testSettings struct {
|
||||
ReadingSpeed int `json:"readingSpeed"`
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
|
||||
func TestEncode(t *testing.T) {
|
||||
input := testSettings{
|
||||
ReadingSpeed: 300,
|
||||
Theme: "dark",
|
||||
}
|
||||
|
||||
got, err := Encode(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() returned error: %v", err)
|
||||
}
|
||||
|
||||
var decoded testSettings
|
||||
|
||||
if err := json.Unmarshal(got, &decoded); err != nil {
|
||||
t.Fatalf("encoded JSON is invalid: %v", err)
|
||||
}
|
||||
|
||||
if decoded != input {
|
||||
t.Errorf("decoded value = %+v, want %+v", decoded, input)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(got), "\n") {
|
||||
t.Error("Encode() should produce indented JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeNil(t *testing.T) {
|
||||
got, err := Encode(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(nil) returned error: %v", err)
|
||||
}
|
||||
|
||||
if string(got) != "null" {
|
||||
t.Errorf("Encode(nil) = %q, want %q", got, "null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeUnsupportedValue(t *testing.T) {
|
||||
_, err := Encode(make(chan int))
|
||||
|
||||
if err == nil {
|
||||
t.Error("Encode() expected an error for unsupported value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"readingSpeed": 400,
|
||||
"theme": "light"
|
||||
}`)
|
||||
|
||||
var got testSettings
|
||||
|
||||
if err := Decode(input, &got); err != nil {
|
||||
t.Fatalf("Decode() returned error: %v", err)
|
||||
}
|
||||
|
||||
expected := testSettings{
|
||||
ReadingSpeed: 400,
|
||||
Theme: "light",
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("Decode() = %+v, want %+v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInvalidJSON(t *testing.T) {
|
||||
input := []byte(`{"readingSpeed": }`)
|
||||
|
||||
var got testSettings
|
||||
|
||||
if err := Decode(input, &got); err == nil {
|
||||
t.Error("Decode() expected an error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeNilTarget(t *testing.T) {
|
||||
err := Decode([]byte(`{"readingSpeed":300}`), nil)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Decode() expected an error for nil target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEmptyJSON(t *testing.T) {
|
||||
var got testSettings
|
||||
|
||||
if err := Decode([]byte(``), &got); err == nil {
|
||||
t.Error("Decode() expected an error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
// NEW
|
||||
func TestEncodeDecode(t *testing.T) {
|
||||
original := testSettings{
|
||||
ReadingSpeed: 400,
|
||||
Theme: "dark",
|
||||
}
|
||||
|
||||
data, err := Encode(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded testSettings
|
||||
|
||||
if err := Decode(data, &decoded); err != nil {
|
||||
t.Fatalf("Decode() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Errorf("decoded = %+v, want %+v", decoded, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings", "settings.json")
|
||||
|
||||
original := testSettings{
|
||||
ReadingSpeed: 500,
|
||||
Theme: "light",
|
||||
}
|
||||
|
||||
if err := Save(path, original); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
var loaded testSettings
|
||||
|
||||
if err := Load(path, &loaded); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(loaded, original) {
|
||||
t.Errorf("loaded = %+v, want %+v", loaded, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "missing.json")
|
||||
|
||||
var target testSettings
|
||||
|
||||
err := Load(path, &target)
|
||||
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("Load() error = %v, want file-not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInvalidValue(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
|
||||
err := Save(path, make(chan int))
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Save() expected an error for an unsupported JSON value")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user