test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Silo Server Developer
2026-05-28 17:45:38 +02:00
co-authored by Claude Opus 4.7
parent 8ff06ed008
commit aff528358e
2 changed files with 34 additions and 10 deletions
+5
View File
@@ -349,6 +349,8 @@ type scoredMatchCandidate struct {
// a TMDB row that weren't merged because each carries only its own provider's
// ID). Candidates that share a canonical provider key but carry different values
// are considered distinct shows and cause the function to return false.
// Candidates with Year == 0 are treated as year-mismatched (a provider that
// omitted the year yields false here) — conservative by design.
func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool {
// Year==0 means the provider didn't supply a release year, so we cannot
// claim the candidates refer to the *same* show via year-equality. Without
@@ -410,6 +412,9 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
// year is high-confidence even when the fuzzy title score sits in the 55-69
// band (short/numeric/alternate titles). Accept the top-ranked candidate
// without lowering the score thresholds.
// Residual risk: two different shows with an identical title+year and no
// provider IDs would both pass; accepted as low-risk given the title+year+type
// corroboration.
if hints.Year != 0 && best.candidate.Year == hints.Year &&
candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) &&
candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) {
@@ -38,20 +38,39 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test
}
func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) {
// Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path).
// candidatesAreSingleDistinctShow returns false (titles differ), so the new rule
// must NOT fire; falls through to the existing tie-break which returns nil because
// DetailScore is 0. Guards against over-accepting distinct results.
// Two genuinely different shows that BOTH score >=55 (so the new rule IS evaluated,
// not short-circuited by the <55 floor): candidatesAreSingleDistinctShow must return
// false (titles differ), so the new rule does NOT fire and behavior falls through to
// the existing tie-break (which returns nil here because DetailScore is 0). Guards
// against over-accepting distinct results.
//
// "The Show Special" and "The Show Extra" both score 38 against hint "The Show" /
// 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 =>
// tie-break; inferTitleSimilarity between the two candidates is 0 => not same show.
hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"}
// Each candidate shares 7 tokens with the hint plus one distinct trailing word, so
// each is coherent with the hint (Jaccard 7/8 = 0.875 >= 0.85 => sim 0.8 => +28) and
// scores 28 + 20(year) + 24(2 sources) + 5 + 1(1 id) = 78 (>=55, reaches the guard).
// The two candidates differ from each other (Jaccard 7/9 = 0.78 < 0.85 => sim 0), so
// candidatesAreSingleDistinctShow returns false. Equal scores => gap 0 < 15 => tie-break.
hints := &MatchHints{Title: "The Real History of the World War", Year: 2010, Type: "series"}
cands := []MatchCandidate{
{Title: "The Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}},
{Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}},
{Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}},
{Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}},
}
if _, ok := selectInitialMatchCandidate(hints, cands); ok {
t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule")
}
}
func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testing.T) {
// Same title+year but different tmdb IDs => two distinct shows; must NOT auto-accept.
// Each scores 65 (45 exact title + 20 year) + 5 + 1(richness) = ... actually
// 45+20+5+1 = 71 (no sources, one provider ID), well above the 55 floor, so the new
// branch is reached. candidatesAreSingleDistinctShow must return false because the two
// candidates carry the same canonical provider key (tmdb) with conflicting values.
hints := &MatchHints{Title: "Alpha", Year: 2022, Type: "movie"}
cands := []MatchCandidate{
{Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}},
{Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}},
}
if _, ok := selectInitialMatchCandidate(hints, cands); ok {
t.Fatal("conflicting tmdb IDs must not be auto-accepted")
}
}