App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The document a television applies must always be complete. A missing key is a setting
|
||||
// with no value on the other end, which is how a launcher ends up with no rows.
|
||||
func TestNormalizePreferencesFillsEveryKnownKey(t *testing.T) {
|
||||
result := normalizePreferences(nil)
|
||||
if len(result) != len(preferenceCatalogue) {
|
||||
t.Fatalf("got %d keys, want %d", len(result), len(preferenceCatalogue))
|
||||
}
|
||||
for _, definition := range preferenceCatalogue {
|
||||
if _, ok := result[definition.Key]; !ok {
|
||||
t.Errorf("%s missing from a normalised document", definition.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreferencesDropsUnknownKeys(t *testing.T) {
|
||||
result := normalizePreferences(map[string]any{"embyToken": "secret", "showTitleLogo": false})
|
||||
if _, ok := result["embyToken"]; ok {
|
||||
t.Error("an unknown key survived normalisation")
|
||||
}
|
||||
if result["showTitleLogo"] != false {
|
||||
t.Errorf("showTitleLogo = %v, want false", result["showTitleLogo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreferencesRejectsIllegalValues(t *testing.T) {
|
||||
result := normalizePreferences(map[string]any{
|
||||
"homeCardDensity": "enormous",
|
||||
"showRatingsStrip": "yes",
|
||||
"forYouMinutes": 45,
|
||||
"homeSections": []any{"nonsense"},
|
||||
})
|
||||
if result["homeCardDensity"] != "standard" {
|
||||
t.Errorf("homeCardDensity = %v, want the default", result["homeCardDensity"])
|
||||
}
|
||||
if result["showRatingsStrip"] != true {
|
||||
t.Errorf("showRatingsStrip = %v, want the default", result["showRatingsStrip"])
|
||||
}
|
||||
if result["forYouMinutes"] != 0 {
|
||||
t.Errorf("forYouMinutes = %v, want the default", result["forYouMinutes"])
|
||||
}
|
||||
// Every named row was unknown, which leaves nothing to draw — the default stands in
|
||||
// rather than an empty launcher being honoured as a choice.
|
||||
want := []string{"continue", "favorites", "latest"}
|
||||
if !reflect.DeepEqual(result["homeSections"], want) {
|
||||
t.Errorf("homeSections = %v, want %v", result["homeSections"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// The order of a multi-select is the setting, not an implementation detail: it is the
|
||||
// order the rows appear in on the launcher.
|
||||
func TestNormalizePreferencesKeepsMultiOrderAndDedupes(t *testing.T) {
|
||||
result := normalizePreferences(map[string]any{
|
||||
"homeSections": []any{"latest", "continue", "latest", "unknown"},
|
||||
})
|
||||
want := []string{"latest", "continue"}
|
||||
if !reflect.DeepEqual(result["homeSections"], want) {
|
||||
t.Errorf("homeSections = %v, want %v", result["homeSections"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// Free-form row ids are stored newline-separated on the television, so an id containing
|
||||
// one would come back as two rows on the next sync.
|
||||
func TestNormalizePreferencesRejectsNewlinesInRowIds(t *testing.T) {
|
||||
result := normalizePreferences(map[string]any{
|
||||
"homeRowOrder": []any{"recommended", "bad\nid", " ", "recommended", "latest"},
|
||||
})
|
||||
want := []string{"recommended", "latest"}
|
||||
if !reflect.DeepEqual(result["homeRowOrder"], want) {
|
||||
t.Errorf("homeRowOrder = %v, want %v", result["homeRowOrder"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreferencesBoundsListLength(t *testing.T) {
|
||||
ids := make([]any, 0, maxListEntries+50)
|
||||
for i := 0; i < maxListEntries+50; i++ {
|
||||
ids = append(ids, string(rune('a'+i%26))+string(rune('a'+i/26)))
|
||||
}
|
||||
result := normalizePreferences(map[string]any{"homeHiddenRows": ids})
|
||||
if got := len(result["homeHiddenRows"].([]string)); got > maxListEntries {
|
||||
t.Errorf("kept %d ids, want at most %d", got, maxListEntries)
|
||||
}
|
||||
}
|
||||
|
||||
// A default that is a slice must be copied out, or a caller mutating one user's document
|
||||
// edits the catalogue for every user for the life of the process.
|
||||
func TestNormalizePreferencesDoesNotShareSliceDefaults(t *testing.T) {
|
||||
first := normalizePreferences(nil)
|
||||
first["homeSections"].([]string)[0] = "tampered"
|
||||
second := normalizePreferences(nil)
|
||||
if second["homeSections"].([]string)[0] != "continue" {
|
||||
t.Fatal("mutating one document changed the catalogue default")
|
||||
}
|
||||
}
|
||||
|
||||
// Numbers arrive from encoding/json as float64; a document that has been through the wire
|
||||
// must normalise identically to one built in Go.
|
||||
func TestNormalizePreferencesAcceptsJSONNumbers(t *testing.T) {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"forYouMinutes":60}`), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := normalizePreferences(decoded)["forYouMinutes"]; got != 60 {
|
||||
t.Errorf("forYouMinutes = %v, want 60", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The client applies whatever comes back, so a round trip through storage must be a
|
||||
// fixed point — otherwise two televisions could disagree about what they just agreed on.
|
||||
func TestNormalizePreferencesIsIdempotentThroughJSON(t *testing.T) {
|
||||
once := normalizePreferences(map[string]any{
|
||||
"homeSections": []any{"favorites"}, "homeCardDensity": "large",
|
||||
"hideWatchedMovies": true, "homeRowOrder": []any{"recommended"},
|
||||
})
|
||||
raw, err := json.Marshal(once)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
twice := decodePreferences(raw)
|
||||
rawOnce, _ := json.Marshal(once)
|
||||
rawTwice, _ := json.Marshal(twice)
|
||||
if string(rawOnce) != string(rawTwice) {
|
||||
t.Errorf("round trip changed the document:\n once: %s\ntwice: %s", rawOnce, rawTwice)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue is the contract the admin console and the television both read. A default
|
||||
// that is not itself a legal value would hand every new viewer something the editor
|
||||
// cannot represent.
|
||||
func TestPreferenceCatalogueDefaultsAreLegal(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, definition := range preferenceCatalogue {
|
||||
if seen[definition.Key] {
|
||||
t.Errorf("%s appears in the catalogue twice", definition.Key)
|
||||
}
|
||||
seen[definition.Key] = true
|
||||
if definition.Name == "" || definition.Area == "" {
|
||||
t.Errorf("%s needs a name and an area to render in the console", definition.Key)
|
||||
}
|
||||
normalised := normalizePreference(definition, defaultValue(definition))
|
||||
if !reflect.DeepEqual(normalised, defaultValue(definition)) {
|
||||
t.Errorf("%s default %v is not a legal value (normalises to %v)",
|
||||
definition.Key, definition.Default, normalised)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The skip interval is a number from a fixed list, and the television normalises it a
|
||||
// second time. Both ends have to agree on which values exist, or a viewer's choice comes
|
||||
// back as the default the moment they change some other setting.
|
||||
func TestNormalizePreferencesSeekInterval(t *testing.T) {
|
||||
if got := normalizePreferences(map[string]any{"seekIntervalSeconds": 30})["seekIntervalSeconds"]; got != 30 {
|
||||
t.Errorf("seekIntervalSeconds = %v, want 30", got)
|
||||
}
|
||||
// 15 is not offered; neither is a string. Both fall back rather than reaching a player
|
||||
// as a step size nothing on the television knows how to label.
|
||||
for _, value := range []any{15, "30", 0, nil} {
|
||||
got := normalizePreferences(map[string]any{"seekIntervalSeconds": value})["seekIntervalSeconds"]
|
||||
if got != 10 {
|
||||
t.Errorf("seekIntervalSeconds for %v = %v, want the default 10", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user