package api import ( "encoding/json" "reflect" "strings" "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 TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) { if got := normalizePreferences(map[string]any{"profileInitials": " mc "})["profileInitials"]; got != "MC" { t.Errorf("profileInitials = %v, want MC", got) } for _, value := range []any{"MAT", "M\nC", 12} { if got := normalizePreferences(map[string]any{"profileInitials": value})["profileInitials"]; got != "" { t.Errorf("profileInitials for %v = %v, want automatic fallback", value, got) } } } // A short name is a person's name, so unlike the initials beside it in the catalogue it // keeps the case it was typed in. Folding it would have the launcher greeting somebody as // MATT, which is the whole reason Uppercase is per-definition rather than per-kind. func TestNormalizePreferencesKeepsShortNameCaseAndBoundsIt(t *testing.T) { if got := normalizePreferences(map[string]any{"shortName": " Matt "})["shortName"]; got != "Matt" { t.Errorf("shortName = %v, want Matt", got) } long := strings.Repeat("a", shortNameMaxLength+1) for _, value := range []any{long, "Ma\ntt", 12} { if got := normalizePreferences(map[string]any{"shortName": value})["shortName"]; got != "" { t.Errorf("shortName for %v = %v, want the account-name fallback", value, got) } } if got := normalizePreferences(nil)["shortName"]; got != "" { t.Errorf("default shortName = %v, want blank", got) } } // The short name is admin-owned like the initials, so a television saving an unrelated // setting must not be what quietly clears it. func TestDevicePreferenceWritePreservesAdminShortName(t *testing.T) { stored, err := json.Marshal(normalizePreferences(map[string]any{"shortName": "Matt"})) if err != nil { t.Fatal(err) } merged := preserveAdminPreferences(map[string]any{"showTitleLogo": false}, stored) if normalizePreferences(merged)["shortName"] != "Matt" { t.Errorf("shortName = %v, want preserved Matt", normalizePreferences(merged)["shortName"]) } } func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) { stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"})) if err != nil { t.Fatal(err) } merged := preserveAdminPreferences(map[string]any{ "profileInitials": "XX", "showTitleLogo": false, }, stored) normalised := normalizePreferences(merged) if normalised["profileInitials"] != "MC" { t.Errorf("profileInitials = %v, want preserved MC", normalised["profileInitials"]) } if normalised["showTitleLogo"] != false { t.Errorf("showTitleLogo = %v, want device change false", normalised["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) } } func TestNormalizePreferencesFoldsLegacyGenreIntoMetadataInformation(t *testing.T) { result := normalizePreferences(map[string]any{ "metadataHeroContentOrder": []any{"title", "ratings", "genres", "summary"}, }) want := []string{"title", "ratings", "facts", "summary"} if !reflect.DeepEqual(result["metadataHeroContentOrder"], want) { t.Errorf("metadataHeroContentOrder = %v, want %v", result["metadataHeroContentOrder"], 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) } } }