293 lines
11 KiB
Go
293 lines
11 KiB
Go
package api
|
||
|
||
import "testing"
|
||
|
||
// The credits rule, pinned against the same cases as the television's copy (`CreditsTest`
|
||
// in app/src/test). The two exist separately because with no gateway there is nobody to
|
||
// ask, and the picture must not start shrinking at a different moment depending on whether
|
||
// the container is up — so when one of these changes, the other has to change with it.
|
||
//
|
||
// The cases come from a survey of a real 20,000-item library, and the shape of that survey is
|
||
// why the rule looks the way it does. Emby 4.10 wrote **no `CreditsStart` at all** — only
|
||
// `Chapter`, `IntroStart` and `IntroEnd` — while 216 items carried a chapter *named* like
|
||
// credits at 90–98% of runtime. Two of them carried "Opening Credits" at 0–1%, which is the
|
||
// case the position floor exists for and the one worth never regressing.
|
||
|
||
func named(seconds int64, name string) embyChapter {
|
||
return embyChapter{
|
||
StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
|
||
MarkerType: "Chapter",
|
||
Name: name,
|
||
}
|
||
}
|
||
|
||
// A two-thousand-second episode, so a percentage of runtime reads as a round number.
|
||
const testRuntimeMs = 2_000_000
|
||
|
||
func TestCreditsFromChapters(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
chapters []embyChapter
|
||
runtimeMs int64
|
||
want int64
|
||
ok bool
|
||
}{
|
||
{
|
||
// A real episode as the server holds it: chapters, the intro pair, more
|
||
// chapters, and the credits marker near the end.
|
||
name: "a real episode",
|
||
chapters: []embyChapter{
|
||
chapter(0, "Chapter"),
|
||
chapter(463, "IntroStart"),
|
||
chapter(583, "IntroEnd"),
|
||
chapter(1200, "Chapter"),
|
||
chapter(1900, "CreditsStart"),
|
||
},
|
||
want: 1_900_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// A film, which commonly has the credits marker and no intro at all. This is
|
||
// the case that would have been lost had the two features shared one flag.
|
||
name: "credits with no intro",
|
||
chapters: []embyChapter{
|
||
chapter(0, "Chapter"),
|
||
chapter(1400, "Chapter"),
|
||
chapter(1850, "CreditsStart"),
|
||
},
|
||
want: 1_850_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
name: "no markers at all",
|
||
chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
|
||
},
|
||
{
|
||
name: "no chapters at all",
|
||
chapters: nil,
|
||
},
|
||
{
|
||
// An intro pair is a different feature and must never be read as credits.
|
||
name: "intro markers are not credits",
|
||
chapters: []embyChapter{
|
||
chapter(463, "IntroStart"),
|
||
chapter(583, "IntroEnd"),
|
||
},
|
||
},
|
||
{
|
||
// A marker at zero says the whole file is credits, which is not something Emby
|
||
// means and not something worth shrinking a picture for.
|
||
name: "a marker at the very beginning",
|
||
chapters: []embyChapter{chapter(0, "CreditsStart"), chapter(300, "Chapter")},
|
||
},
|
||
{
|
||
// The last marker wins, where the intro rule takes the first. Two starts mean
|
||
// the markers are untrustworthy, and the two features are damaged in opposite
|
||
// directions: an intro skip that fires late throws somebody past the story, so
|
||
// the earlier marker is safer there; the credits pane firing early runs the last
|
||
// scene past somebody at double speed, so the later marker is safer here.
|
||
name: "two starts, the later one wins",
|
||
chapters: []embyChapter{
|
||
chapter(1700, "CreditsStart"),
|
||
chapter(1900, "CreditsStart"),
|
||
},
|
||
want: 1_900_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// Order in the array is not trusted to be sorted, so a later marker earlier in
|
||
// the list still loses to the one further into the film.
|
||
name: "the later marker wins whatever order they arrive in",
|
||
chapters: []embyChapter{
|
||
chapter(1900, "CreditsStart"),
|
||
chapter(1700, "CreditsStart"),
|
||
},
|
||
want: 1_700_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// Squid Game, as the library actually holds it: no marker of any kind, one
|
||
// ordinary chapter named "Credits" at 90% of runtime. This is where every bit of
|
||
// this feature's coverage comes from today.
|
||
name: "a chapter named Credits, which is all Emby 4.10 gives",
|
||
chapters: []embyChapter{
|
||
chapter(0, "Chapter"),
|
||
chapter(463, "IntroStart"),
|
||
chapter(583, "IntroEnd"),
|
||
named(1800, "Credits"),
|
||
},
|
||
want: 1_800_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
name: "a chapter named End Credits",
|
||
chapters: []embyChapter{named(1920, "End Credits")},
|
||
want: 1_920_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// THE case. Belfast carries "Opening Credits" at 1% of runtime and Game of Thrones
|
||
// at 0%. Matching a name without testing the position starts the pane in the first
|
||
// minute of a film and runs its opening at double speed — the worst thing this
|
||
// feature could do, and the reason creditsMinimumPositionFraction exists.
|
||
name: "Opening Credits at the start of a film is never the credit roll",
|
||
chapters: []embyChapter{
|
||
named(20, "Opening Credits"),
|
||
chapter(600, "Chapter"),
|
||
},
|
||
},
|
||
{
|
||
// Belfast in full: both chapters present. The opening one must be rejected and the
|
||
// closing one found, which the position floor does on its own.
|
||
name: "an opening and a closing credit chapter in one film",
|
||
chapters: []embyChapter{
|
||
named(20, "Opening Credits"),
|
||
chapter(600, "Chapter"),
|
||
named(1900, "End Credits"),
|
||
},
|
||
want: 1_900_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// "The Pitt" carries both, seconds apart, describing one roll. The *earliest*
|
||
// qualifying chapter wins here — the opposite of the marker rule above — because
|
||
// the roll begins at the first of them and taking the last would skip part of it.
|
||
name: "two credits chapters describing one roll take the earlier",
|
||
chapters: []embyChapter{
|
||
named(1920, "End Credits"),
|
||
named(1900, "Credits"),
|
||
},
|
||
want: 1_900_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// Anything in the first three quarters is refused however it is worded. A position
|
||
// test catches wordings nobody thought of; a list of words only catches the listed.
|
||
name: "a credits-named chapter too early to be the roll",
|
||
chapters: []embyChapter{named(900, "Credits")},
|
||
},
|
||
{
|
||
// An explicit marker outranks a name, and is honoured even with no runtime to
|
||
// measure against: it is Emby asserting a position rather than this inferring one.
|
||
name: "a marker is honoured when the runtime is unknown",
|
||
chapters: []embyChapter{chapter(1900, "CreditsStart")},
|
||
runtimeMs: -1,
|
||
want: 1_900_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// A name is not. Without a runtime there is no way to tell an opening credit
|
||
// sequence from a closing one, and guessing is what this whole guard refuses.
|
||
name: "a name is refused when the runtime is unknown",
|
||
chapters: []embyChapter{named(1900, "End Credits")},
|
||
runtimeMs: -1,
|
||
},
|
||
{
|
||
// A marker below the floor is a mis-detection whoever wrote it, so it gives way to
|
||
// a name that does qualify rather than being honoured on authority.
|
||
name: "a marker below the floor falls through to a name that qualifies",
|
||
chapters: []embyChapter{
|
||
chapter(200, "CreditsStart"),
|
||
named(1900, "End Credits"),
|
||
},
|
||
want: 1_900_000,
|
||
ok: true,
|
||
},
|
||
{
|
||
// The intro's own chapters are named "Intro Start"/"Intro End" in this library, and
|
||
// an episode whose titles run late must never have them read as a credit roll.
|
||
name: "chapters named for the intro are never credits",
|
||
chapters: []embyChapter{
|
||
named(1800, "Intro Start"),
|
||
named(1900, "Intro End"),
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
// 0 means "the ordinary case, use the default"; -1 means "deliberately
|
||
// unknown", which is a case of its own rather than an absent field.
|
||
runtime := tc.runtimeMs
|
||
switch runtime {
|
||
case 0:
|
||
runtime = testRuntimeMs
|
||
case -1:
|
||
runtime = 0
|
||
}
|
||
got, ok := creditsFromChapters(tc.chapters, runtime)
|
||
if ok != tc.ok {
|
||
t.Fatalf("available = %v, want %v (start %d)", ok, tc.ok, got)
|
||
}
|
||
if ok && got != tc.want {
|
||
t.Fatalf("start = %d, want %d", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// The two halves of one reading are independent. A film with credits and no intro must not
|
||
// report an intro starting at zero, which is what a shared flag would have produced.
|
||
func TestMarkersResponseKeepsTheHalvesApart(t *testing.T) {
|
||
response := markersResponse(chapterMarkers{creditsStart: 6_840_000, creditsFound: true})
|
||
if response.Available || response.StartMs != 0 || response.EndMs != 0 {
|
||
t.Fatalf("intro = %+v, want an absent intro on a title that has none", response)
|
||
}
|
||
if !response.CreditsAvailable || response.CreditsStartMs != 6_840_000 {
|
||
t.Fatalf("credits = %+v, want the marker that was found", response)
|
||
}
|
||
|
||
response = markersResponse(chapterMarkers{
|
||
intro: introSegment{StartMs: 463_000, EndMs: 583_000}, introFound: true,
|
||
})
|
||
if response.CreditsAvailable || response.CreditsStartMs != 0 {
|
||
t.Fatalf("credits = %+v, want none on a title with only an intro", response)
|
||
}
|
||
}
|
||
|
||
// An operator turning one feature off must not take the other with it, and must not poison
|
||
// the cache: masking happens on the way out, so the entry behind it still holds the truth.
|
||
func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) {
|
||
full := introResponse{
|
||
Available: true, StartMs: 463_000, EndMs: 583_000,
|
||
CreditsAvailable: true, CreditsStartMs: 2_704_000,
|
||
}
|
||
|
||
withoutIntro := maskMarkers(full, false, true)
|
||
if withoutIntro.Available || withoutIntro.StartMs != 0 || withoutIntro.EndMs != 0 {
|
||
t.Fatalf("intro = %+v, want it withheld", withoutIntro)
|
||
}
|
||
if !withoutIntro.CreditsAvailable || withoutIntro.CreditsStartMs != 2_704_000 {
|
||
t.Fatal("turning the skip button off must not cost the credits pane as well")
|
||
}
|
||
|
||
withoutCredits := maskMarkers(full, true, false)
|
||
if withoutCredits.CreditsAvailable || withoutCredits.CreditsStartMs != 0 {
|
||
t.Fatalf("credits = %+v, want them withheld", withoutCredits)
|
||
}
|
||
if !withoutCredits.Available || withoutCredits.StartMs != 463_000 {
|
||
t.Fatal("turning the credits pane off must not cost the skip button as well")
|
||
}
|
||
}
|
||
|
||
// The toggle a television is told to obey has to be one this build knows.
|
||
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
|
||
definition, ok := preferenceDefinitionFor("speedUpCredits")
|
||
if !ok {
|
||
t.Fatal("speedUpCredits is missing from the preference catalogue")
|
||
}
|
||
if definition.Kind != preferenceToggle {
|
||
t.Fatalf("kind = %v, want a toggle", definition.Kind)
|
||
}
|
||
if definition.Default != true {
|
||
t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+
|
||
"is visible and reversible in a way an automatic seek is not", definition.Default)
|
||
}
|
||
|
||
// An illegal value must come back as the default rather than reaching a player.
|
||
normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
|
||
if normalised["speedUpCredits"] != true {
|
||
t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
|
||
}
|
||
}
|