mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
* docs(tmdb): replace catalog titles in matching examples with stand-ins The Cyrillic and Arabic examples that document title folding were taken straight from a user's own portal catalog while the folding bugs were being diagnosed, and they spread from there into comments, fixtures, assertions and the architecture doc. A public repository is not the place for someone's viewing inventory, and the TMDB ids pinned alongside them identify the exact shows. Swap in stand-ins that reproduce the property under test rather than the title: a word whose "й" folds away, one whose "ё" does, a two-word title carrying a season marker, an Arabic phrase whose initial hamza decomposes into a separate word. Every replacement was run through the real `normalizeTitle` and `cleanTitleForSearch` before being written, so the folded keys, the wire queries and the cache lookup keys are the same shape as before — "Лейка" and "Леика" still meet on one key while staying two different searches, and "AR| أمثلة تجريبية" still keys as a hamza split into a space. Real TMDB ids become synthetic ones. One channel fixture that read as a film title becomes a plain channel name. Deliberately left alone: the leading-tag rule's "Akira | 1988" / "Момо | Momo" examples in `title-normalization.util.ts`. Those are not an inventory — they are the evidence for a regex decision measured over 1.27M catalog titles, and inventing replacements would document a measurement that never happened. No release note: comments, fixtures and docs only, with no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(tmdb): label the folding stand-ins as illustrative, not as history Review caught that the substitution left synthetic titles inside sentences that assert observed fact: the architecture doc claimed a particular folded query "returns zero results while `Лейка` returns the show" and named the stand-ins as the titles that were searched, missed and negatively cached, and several comments read the same way. The titles never existed, so the reading is wrong in the one direction that matters — a future reader debugging a regression would take them for production evidence. Keep the claim that is actually established, which is a class-level one: under the old single-form design every Cyrillic title carrying "й"/"ё" and every Arabic title carrying a hamza form was searched folded, missed, and cached as missing for the negative TTL. Present the strings themselves as illustrative stand-ins chosen to fold the same way. The verified invariant — what NFD does to those letters, and what the two tiers therefore produce — is unchanged and still assertable, because it is a property of the text rather than of any title. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(tmdb): finish the sweep — a missed cache key and a last observed-fact claim Two spots the first pass left behind: - `tmdb-search-cache-cleanup.spec.ts` kept a catalog-derived lookup key verbatim. Only the TMDB id beside it had been swapped, because the sweep matched the title in its display casing and the key stores it lowercased — so one item of the inventory stayed in the repository, twice. - `SearchTitleVariant`'s doc comment still asserted that one specific folded string finds nothing on TMDB. State the mechanism instead: folding rewrites the letters the search matches on, so the folded form finds nothing there. `content-search.util.sqlite.spec.ts` gets a channel fixture that no longer reads as a film title. `search-text-fold.util.spec.ts` is deliberately left alone. Its "Ёлки" is a common noun sitting in a Unicode corpus beside "Amélie", "İnşaat" and "Ά", not an inventory entry — and its rows are precomposed/decomposed PAIRS (U+0401 against U+0415 U+0308). A textual substitution rewrites only the composed half and silently turns the pair into two different words; doing it failed that spec, which is what a fixture encoding an invisible property is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(search): finish the fold fixtures by rewriting both halves of the pair The last two occurrences lived in the Unicode fold corpus, which an earlier attempt left alone after a textual substitution failed the spec. The reason it failed is the point: one row is a precomposed/decomposed PAIR — U+0401 against U+0415 U+0308 — asserting that both spellings fold to one string. Replacing the visible text rewrites only the composed half and silently turns the pair into two different words, which is not a thing a reader or a regex can see. Rewrite both halves by code point instead, keeping the decomposed half decomposed: U+0415 U+0308 + the new stem. Verified by decoding every quoted string in the file afterwards, and the spec passes (534/534). A repository-wide sweep now finds no occurrence of the replaced titles in either normalization form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
511 lines
22 KiB
TypeScript
511 lines
22 KiB
TypeScript
import {
|
||
cleanTitleForSearch,
|
||
normalizeTitle,
|
||
normalizeTitleKeys,
|
||
titleYearsCompatible,
|
||
} from './title-normalization.util';
|
||
|
||
describe('normalizeTitleKeys', () => {
|
||
it('keeps a trailing year in the exact form and strips it in base', () => {
|
||
expect(normalizeTitleKeys('Blade Runner 2049')).toEqual({
|
||
exact: 'blade runner 2049',
|
||
base: 'blade runner',
|
||
trailingYear: 2049,
|
||
});
|
||
});
|
||
|
||
it('returns identical tiers when there is no trailing year', () => {
|
||
expect(normalizeTitleKeys('Blade Runner')).toEqual({
|
||
exact: 'blade runner',
|
||
base: 'blade runner',
|
||
trailingYear: null,
|
||
});
|
||
});
|
||
|
||
it('strips quality tags and bracket groups on both tiers', () => {
|
||
expect(normalizeTitleKeys('The Matrix 1999 [4K] (Remastered)')).toEqual(
|
||
{
|
||
exact: 'the matrix 1999',
|
||
base: 'the matrix',
|
||
trailingYear: 1999,
|
||
}
|
||
);
|
||
});
|
||
|
||
it('never strips a year that IS the whole title', () => {
|
||
expect(normalizeTitleKeys('2012')).toEqual({
|
||
exact: '2012',
|
||
base: '2012',
|
||
trailingYear: null,
|
||
});
|
||
});
|
||
|
||
it('keeps leading/mid-title years (only trailing years are tags)', () => {
|
||
expect(normalizeTitle('2001: A Space Odyssey')).toBe(
|
||
'2001 a space odyssey'
|
||
);
|
||
});
|
||
|
||
it('only strips UPPERCASE language prefixes', () => {
|
||
expect(normalizeTitle('EN - The Boys s05')).toBe('the boys');
|
||
expect(normalizeTitle('It: Chapter Two')).toBe('it chapter two');
|
||
});
|
||
|
||
it('strips season suffixes on both tiers', () => {
|
||
expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys');
|
||
expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны');
|
||
});
|
||
|
||
it('strips number-first season suffixes ("2 season", "2 сезон")', () => {
|
||
expect(normalizeTitle('The Mandalorian 2 Season')).toBe(
|
||
'the mandalorian'
|
||
);
|
||
expect(normalizeTitle('The Mandalorian 2nd Season')).toBe(
|
||
'the mandalorian'
|
||
);
|
||
expect(normalizeTitle('Мандалорец 2 сезон')).toBe('мандалорец');
|
||
expect(normalizeTitle('Пацаны 2-й сезон')).toBe('пацаны');
|
||
});
|
||
|
||
it('keeps plural "seasons" endings — only singular markers are tags', () => {
|
||
expect(normalizeTitle('Best of 2 Seasons')).toBe('best of 2 seasons');
|
||
});
|
||
|
||
it('folds both lowercase spellings of Greek sigma together', () => {
|
||
// `toLowerCase` picks the form by position — "ΑΣ" becomes "ας" while
|
||
// an already-lowercase "ασ" stays medial — so one word arrives here
|
||
// spelled two ways. Both SQL tiers fold them (SQLite's trigram
|
||
// tokenizer natively, the scan's GLOB classes in JavaScript), so
|
||
// leaving them apart here admits a candidate and then discards it.
|
||
expect(normalizeTitle('ΑΣ')).toBe(normalizeTitle('Ας'));
|
||
expect(normalizeTitle('ασ')).toBe(normalizeTitle('ας'));
|
||
expect(normalizeTitle('ΑΣ')).toBe(normalizeTitle('ασ'));
|
||
expect(normalizeTitle('Ο Άρχοντας')).toBe(normalizeTitle('ο αρχοντασ'));
|
||
});
|
||
|
||
it('does not fold sigma into unrelated Greek letters', () => {
|
||
// The fold is one letter's two lowercase forms, not a general
|
||
// loosening of Greek — these must stay different titles.
|
||
expect(normalizeTitle('ΑΣ')).not.toBe(normalizeTitle('ΑΝ'));
|
||
expect(normalizeTitle('ΟΣ')).not.toBe(normalizeTitle('ΑΣ'));
|
||
});
|
||
});
|
||
|
||
describe('provider tag stripping', () => {
|
||
it('strips wrapped pipe tags', () => {
|
||
expect(normalizeTitle('|DE| ARD')).toBe('ard');
|
||
expect(normalizeTitle('|MULTI| Fallout - 4K')).toBe('fallout');
|
||
expect(normalizeTitle('|EXYU| The Pitt')).toBe('the pitt');
|
||
});
|
||
|
||
it('strips long and compound leading tags', () => {
|
||
expect(normalizeTitle('EXYU| Fallout')).toBe('fallout');
|
||
expect(normalizeTitle('MULTI| Breaking Bad')).toBe('breaking bad');
|
||
expect(normalizeTitle('4K-DE - The Pitt (2025) (US)')).toBe('the pitt');
|
||
expect(normalizeTitle('AR-SUBS - Fallout (2024) (US)')).toBe('fallout');
|
||
expect(normalizeTitle('4K-OSN+ - The Last of Us (2023)')).toBe(
|
||
'the last of us'
|
||
);
|
||
});
|
||
|
||
it('never treats numeric fragments as leading tags', () => {
|
||
expect(normalizeTitle('1917 - Behind the Lines')).toBe(
|
||
'1917 behind the lines'
|
||
);
|
||
});
|
||
|
||
it('reads pipe lookalikes as the pipe they look like', () => {
|
||
// `│`, `¦` and `|` are indistinguishable from `|` in a catalog, so
|
||
// the same tag must not survive in one playlist and vanish in
|
||
// another — the two copies would never match as the same film.
|
||
expect(normalizeTitle('EN │ Fallout')).toBe('fallout');
|
||
expect(normalizeTitle('DE ¦ Fallout')).toBe('fallout');
|
||
expect(normalizeTitle('MULTI|Fallout')).toBe('fallout');
|
||
});
|
||
|
||
it('strips a pipe tag welded to the title', () => {
|
||
// "|FR|VO|Le dernier empereur" — the wrapped tag goes first, then
|
||
// "VO|" with no space after it.
|
||
expect(normalizeTitle('|FR|VO|Le dernier empereur')).toBe(
|
||
'le dernier empereur'
|
||
);
|
||
expect(normalizeTitle('EN|Fallout')).toBe('fallout');
|
||
});
|
||
|
||
it('keeps a name that only looks like a tag before a pipe', () => {
|
||
// Pins the UPPERCASE rule on the pipe branch. Relaxing it there is
|
||
// tempting — nothing but a tag precedes a pipe, surely — but measured
|
||
// against 1.27M real catalog titles a case-insensitive (or Cyrillic)
|
||
// pipe rule corrupted 349 keys and rescued none: "name | year" and
|
||
// the Russian "localized | original" convention both put the film's
|
||
// own name in the tag position.
|
||
// The base tier drops the trailing year, so the name is what must
|
||
// survive; the exact tier shows the whole string it came from.
|
||
expect(normalizeTitle('Akira | 1988')).toBe('akira');
|
||
expect(normalizeTitleKeys('Akira | 1988').exact).toBe('akira 1988');
|
||
expect(normalizeTitle('Coco | 2017')).toBe('coco');
|
||
expect(normalizeTitle('Момо | Momo')).toBe('момо momo');
|
||
expect(normalizeTitle('Мумия | The Mummy')).toBe('мумия the mummy');
|
||
});
|
||
|
||
it('keeps bare 4-5 char words before a spaced dash (real titles)', () => {
|
||
expect(normalizeTitle('DUNE - Part Two')).toBe('dune part two');
|
||
expect(normalizeTitle('ALIEN - Covenant')).toBe('alien covenant');
|
||
});
|
||
|
||
it('strips underscore and double-dash suffix tags', () => {
|
||
expect(normalizeTitle('Fallout_eng')).toBe('fallout');
|
||
expect(normalizeTitle('Breaking Bad (US)_msub')).toBe('breaking bad');
|
||
expect(normalizeTitle('The Pitt (2025)_sub')).toBe('the pitt');
|
||
expect(normalizeTitle('The Last of Us--esp')).toBe('the last of us');
|
||
});
|
||
|
||
it('keeps underscore-as-space titles intact', () => {
|
||
expect(normalizeTitle('The_Last_of_Us')).toBe('the last of us');
|
||
});
|
||
|
||
it('keeps sole-underscore titles whose tail is not a known tag', () => {
|
||
expect(normalizeTitle('Mr_Robot')).toBe('mr robot');
|
||
expect(normalizeTitle('Cowboy_Bebop')).toBe('cowboy bebop');
|
||
expect(normalizeTitle('Mrs_Davis')).toBe('mrs davis');
|
||
});
|
||
|
||
it('strips joined dash tags only for case-uniform vocabulary tokens', () => {
|
||
expect(normalizeTitle('Breaking Bad-eng')).toBe('breaking bad');
|
||
expect(normalizeTitle('The Last of Us-DE')).toBe('the last of us');
|
||
expect(normalizeTitle('The Pitt (2025)-it')).toBe('the pitt');
|
||
expect(normalizeTitle('Spider-Man')).toBe('spider man');
|
||
expect(normalizeTitle('Kick-It')).toBe('kick it');
|
||
});
|
||
|
||
it('keeps English hyphenated word endings that collide with codes', () => {
|
||
expect(normalizeTitle('drive-in')).toBe('drive in');
|
||
expect(normalizeTitle('Plug-in')).toBe('plug in');
|
||
});
|
||
|
||
it('strips bare trailing UPPERCASE vocabulary tags', () => {
|
||
expect(normalizeTitle('The Pitt (2025) DE')).toBe('the pitt');
|
||
expect(normalizeTitle('Breaking Bad ES')).toBe('breaking bad');
|
||
expect(normalizeTitle('EN| Breaking Bad SUB')).toBe('breaking bad');
|
||
expect(normalizeTitle('The Last of Us (2023) AF')).toBe(
|
||
'the last of us'
|
||
);
|
||
});
|
||
|
||
it('never strips trailing tags that could be real endings', () => {
|
||
expect(normalizeTitle('Rocky II')).toBe('rocky ii');
|
||
expect(normalizeTitle('Made in USA')).toBe('made in usa');
|
||
expect(normalizeTitle('NCIS: LA')).toBe('ncis la');
|
||
expect(normalizeTitle('Making It')).toBe('making it');
|
||
expect(normalizeTitle('THE LAST OF US')).toBe('the last of us');
|
||
});
|
||
|
||
const pittCorpus = [
|
||
'The Pitt (2025)_sub',
|
||
'The Pitt (2025)-it',
|
||
'The Pitt (2025)',
|
||
'The Pitt (Hindi)',
|
||
'The Pitt (2025) 4K',
|
||
'The Pitt (2025) DE',
|
||
'The Pitt (2025) ES',
|
||
'The Pitt (2025) FR',
|
||
'The Pitt (2025)_eng',
|
||
'The Pitt [MULTI-SUB]',
|
||
'The Pitt (2025) (4K DV)',
|
||
'GR - The Pitt',
|
||
'4K-DE - The Pitt (2025) (US)',
|
||
'4K-TR - The Pitt (2025) (US)',
|
||
'AR-SUBS - The Pitt (2025) (US)',
|
||
'DE - The Pitt (2025) (US)',
|
||
'ALB| The Pitt',
|
||
'EXYU| The Pitt',
|
||
'|ALB| The Pitt',
|
||
'|DE| The Pitt',
|
||
];
|
||
|
||
const falloutCorpus = [
|
||
'Fallout',
|
||
'DE - Fallout (2024)',
|
||
'Fallout (2024) - 4K',
|
||
'Fallout (2024) FR-EN',
|
||
'Fallout (2024) Multi',
|
||
'Fallout (2024)_fr',
|
||
'Fallout_esp',
|
||
'Fallout (4K)',
|
||
'4K-AMZ - Fallout (2024) (US)',
|
||
'AL - Fallout (2024)',
|
||
'AMZ - Fallout (2024) (US)',
|
||
'AR-DE - Fallout (US)',
|
||
'LA - Fallout',
|
||
'EN| Fallout - 4K',
|
||
'MULTI| Fallout - 4K',
|
||
'Fallout ( مدبلج )',
|
||
'Fallout (Telugu)',
|
||
'|EN| Fallout - 4K',
|
||
'|MULTI| Fallout',
|
||
'|TR| Fallout',
|
||
];
|
||
|
||
const lastOfUsCorpus = [
|
||
'The Last of Us',
|
||
'The Last Of Us',
|
||
'The Last of Us (2023) 4K',
|
||
'The Last of Us (2023) AF',
|
||
'The Last of Us_tr',
|
||
'The Last of Us--esp',
|
||
'The Last of Us-DE',
|
||
'The Last of Us-esp',
|
||
'The Last of Us [L]',
|
||
'The Last of Us ( HD )',
|
||
'4K-OSN+ - The Last of Us (2023)',
|
||
'IS - The Last of Us (2023) (US)',
|
||
'RU - The Last of Us',
|
||
'ALB| The Last of Us',
|
||
];
|
||
|
||
const breakingBadCorpus = [
|
||
'Breaking Bad',
|
||
'Breaking Bad (2008)_fr',
|
||
'Breaking Bad (US)_msub',
|
||
'Breaking Bad_it',
|
||
'Breaking Bad-DE',
|
||
'Breaking Bad-eng',
|
||
'Breaking Bad ( عائلي )',
|
||
'Breaking Bad (Pure)',
|
||
'Breaking Bad - Multi',
|
||
'Breaking Bad ES',
|
||
'AR-DE - Breaking Bad',
|
||
'EN| Breaking Bad SUB',
|
||
'MULTI| Breaking Bad',
|
||
'AR| Breaking Bad',
|
||
// Pipe lookalikes and a lowercase tag: identical on screen to the
|
||
// forms above, so they have to reach the same key.
|
||
'EN │ Breaking Bad',
|
||
'DE ¦ Breaking Bad',
|
||
'FR|Breaking Bad',
|
||
];
|
||
|
||
it.each([
|
||
['the pitt', pittCorpus],
|
||
['fallout', falloutCorpus],
|
||
['the last of us', lastOfUsCorpus],
|
||
['breaking bad', breakingBadCorpus],
|
||
])(
|
||
'normalizes every observed provider variant of "%s" to one key',
|
||
(expected, corpus) => {
|
||
for (const name of corpus) {
|
||
expect(normalizeTitleKeys(name).base).toBe(expected);
|
||
}
|
||
}
|
||
);
|
||
|
||
describe("leading tag vs. the film's own name", () => {
|
||
// "IT - 65 (2023)" and "AKA - 2023" are the same shape: 2-5 uppercase
|
||
// characters, a separator, digits. Only the token's MEANING separates
|
||
// the Italian copy of the film "65" from the film "AKA" followed by
|
||
// its year, so the strip is vocabulary-gated whenever nothing but
|
||
// digits would survive it.
|
||
|
||
it('keeps a name that only looks like a tag before its year', () => {
|
||
// Measured on the live catalog: these all normalized to a bare
|
||
// year, so AKA/BDE/BRO/OUT/WIL/IF shared the single key "2023"
|
||
// and were offered to each other as alternative sources.
|
||
expect(normalizeTitle('AKA - 2023')).toBe('aka');
|
||
expect(normalizeTitle('AKA | 2023')).toBe('aka');
|
||
expect(normalizeTitle('IO - 2019')).toBe('io');
|
||
expect(normalizeTitle('ARQ - 2016')).toBe('arq');
|
||
expect(normalizeTitle('UFO - 2012')).toBe('ufo');
|
||
expect(normalizeTitle('LBJ - 2017')).toBe('lbj');
|
||
expect(normalizeTitle('EO - 2022')).toBe('eo');
|
||
expect(normalizeTitle('BDE - 2023')).toBe('bde');
|
||
expect(normalizeTitle('VFW - 2019 (4K HDR)')).toBe('vfw');
|
||
expect(normalizeTitleKeys('AKA - 2023').exact).toBe('aka 2023');
|
||
});
|
||
|
||
it('still strips real tags before a numeric film title', () => {
|
||
// The opposite shape, and the reason a shape-only guard was
|
||
// rejected: here the tag is real and the film's NAME is numeric.
|
||
expect(normalizeTitle('IT - 65 (2023)')).toBe('65');
|
||
expect(normalizeTitle('|FR|VO| 300 (2007)')).toBe('300');
|
||
expect(normalizeTitle('|FR|VO| 1922')).toBe('1922');
|
||
expect(normalizeTitle('OSN - 1917 - 2019')).toBe('1917');
|
||
expect(normalizeTitle('EN - 42 - 2013')).toBe('42');
|
||
expect(normalizeTitle('NRC - 7500 (2019)')).toBe('7500');
|
||
expect(normalizeTitle('EXYU| 3022')).toBe('3022');
|
||
});
|
||
|
||
it('strips streaming-provider tags before numeric series titles', () => {
|
||
// Found only in the series catalog: a movie-only vocabulary
|
||
// dropped these copies of 1923/1883/24/99 out of their groups.
|
||
expect(normalizeTitle('AMZ - 99 (2024)')).toBe('99');
|
||
expect(normalizeTitle('D+ - 24 (2001) (US)')).toBe('24');
|
||
expect(normalizeTitle('D+ - 9-1-1 (2018) (US)')).toBe('9 1 1');
|
||
expect(normalizeTitle('P+ - 1883 (2021)')).toBe('1883');
|
||
});
|
||
|
||
it('reads a compound tag by its head, so names survive', () => {
|
||
// "4K-<lang>" pairings are open-ended, so the head carries the
|
||
// meaning — and it is also what tells a compound tag apart from
|
||
// a hyphenated name.
|
||
expect(normalizeTitle('4K-FR - 1992 (2024)')).toBe('1992');
|
||
expect(normalizeTitle('AR-SUBS - 180 (2026)')).toBe('180');
|
||
expect(normalizeTitle('SO-IN - 65 (2023)')).toBe('65');
|
||
expect(normalizeTitle('INU-OH - 2022')).toBe('inu oh');
|
||
expect(normalizeTitle('PC-4L - 2020')).toBe('pc 4l');
|
||
});
|
||
|
||
it('asks the real pipeline what survives, not a list of its rules', () => {
|
||
// Every later stage removes something, so a guard that predicts
|
||
// them is a list to keep in sync. These are one case per stage,
|
||
// and all of them fall out of running the pipeline and looking:
|
||
expect(normalizeTitle('|TA| RRR - HEVC')).toBe('rrr'); // quality
|
||
expect(normalizeTitle('CAT - Multi ENG')).toBe('cat'); // trailing
|
||
expect(normalizeTitle('IF - 2024_sub')).toBe('if'); // underscore
|
||
expect(normalizeTitle('AKA --xyz')).toBe('aka'); // double dash
|
||
expect(normalizeTitle('CAT - 2022 S01')).toBe('cat 2022'); // season
|
||
// …while a known tag before a numeric title still strips on every
|
||
// one of those paths.
|
||
expect(normalizeTitle('P+ - 1923 S01')).toBe('1923');
|
||
expect(normalizeTitle('IT - 65 s01')).toBe('65');
|
||
expect(normalizeTitle('EX - 1917 (2019) 4K')).toBe('1917');
|
||
});
|
||
|
||
it('does not let a quality suffix smuggle the strip through', () => {
|
||
// The suffix has letters only until QUALITY_TAGS removes them, so
|
||
// testing the raw remainder would strip the name and normalize
|
||
// "RRR - HEVC" to the EMPTY key — while "RRR - 2022" keys as
|
||
// "rrr", hiding one copy of the film from the other.
|
||
expect(normalizeTitle('|TA| RRR - HEVC')).toBe('rrr');
|
||
expect(normalizeTitle('CAT - Multi')).toBe('cat');
|
||
expect(normalizeTitle('RRR - 2022 - 4K')).toBe('rrr');
|
||
expect(normalizeTitle('VFW - 2019 UHD')).toBe('vfw');
|
||
// A TRAILING language tag is dropped a few lines later too, so it
|
||
// is no more a word than a quality tag is. Real catalog titles:
|
||
// "sub" made the remainder look meaningful and the key came out
|
||
// as the bare year this whole guard exists to prevent.
|
||
expect(normalizeTitle('IF - 2024_sub')).toBe('if');
|
||
expect(normalizeTitle('O2 - 2024_sub')).toBe('o2');
|
||
expect(normalizeTitle('UFO - 2022_sub')).toBe('ufo');
|
||
expect(normalizeTitle('CAT - Multi ENG')).toBe('cat');
|
||
// …but a tag word next to a real one is just part of the title.
|
||
expect(normalizeTitle('EN - Sub Zero')).toBe('sub zero');
|
||
// …and a real tag before a numeric title still strips, because it
|
||
// now goes through the vocabulary instead of the raw-letter test.
|
||
expect(normalizeTitle('EX - 1917 (2019) 4K')).toBe('1917');
|
||
expect(normalizeTitle('NF - 1899 4K (2022)')).toBe('1899');
|
||
expect(normalizeTitle('EN - 180 - 2026 4K')).toBe('180');
|
||
});
|
||
|
||
it('leaves the wrapped-pipe form ungated', () => {
|
||
// A film name is never wrapped in pipes on both sides: all 174
|
||
// letterless cases in the catalog were genuine tags.
|
||
expect(normalizeTitle('|EN| 65')).toBe('65');
|
||
expect(normalizeTitle('|DE| 2067')).toBe('2067');
|
||
});
|
||
|
||
it('no longer normalizes a title down to a degenerate key', () => {
|
||
// Both used to collapse into piles of unrelated junk — "" with 72
|
||
// other members, and "2" with everything named after a numeral.
|
||
expect(normalizeTitle('DSP: (2022)')).toBe('dsp');
|
||
expect(normalizeTitle('HIT: 2 (2022)')).toBe('hit 2');
|
||
expect(normalizeTitle('EGO - (Erkeğe Güven Olmaz)')).toBe('ego');
|
||
});
|
||
});
|
||
|
||
it('keeps localized subtitles (indistinguishable from real ones)', () => {
|
||
expect(normalizeTitle('Breaking Bad: A Química do Mal')).toBe(
|
||
'breaking bad a quimica do mal'
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('titleYearsCompatible', () => {
|
||
it('accepts unknown years and ±1 tolerance', () => {
|
||
expect(titleYearsCompatible(null, 2049)).toBe(true);
|
||
expect(titleYearsCompatible(1999, undefined)).toBe(true);
|
||
expect(titleYearsCompatible(1999, 2000)).toBe(true);
|
||
});
|
||
|
||
it('rejects contradicting years', () => {
|
||
expect(titleYearsCompatible(1982, 2049)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('cleanTitleForSearch', () => {
|
||
it('keeps Cyrillic letters that folding would rewrite', () => {
|
||
// NFD splits "й" into "и" + a breve and "ё" into "е" + a diaeresis;
|
||
// the comparison key drops both marks, and TMDB answers that folded
|
||
// spelling with nothing. The titles here are illustrative stand-ins
|
||
// chosen to fold the same way.
|
||
expect(normalizeTitle('Лейка (10 серий)')).toBe('леика');
|
||
expect(cleanTitleForSearch('Лейка (10 серий)')).toBe('Лейка');
|
||
expect(cleanTitleForSearch('Ёжик 2010')).toBe('Ёжик');
|
||
expect(cleanTitleForSearch('Тестовый Сериал s02')).toBe(
|
||
'Тестовый Сериал'
|
||
);
|
||
});
|
||
|
||
it('keeps Arabic hamza forms that folding splits into two words', () => {
|
||
// "أ" decomposes into a bare alef + U+0654, which is outside the
|
||
// stripped mark range and so becomes a SPACE in the key — splitting
|
||
// the word in two, which is not a spelling TMDB indexes.
|
||
expect(normalizeTitle('AR| أمثلة تجريبية')).toBe('ا مثلة تجريبية');
|
||
expect(cleanTitleForSearch('AR| أمثلة تجريبية')).toBe('أمثلة تجريبية');
|
||
});
|
||
|
||
it('keeps Latin diacritics and casing', () => {
|
||
expect(cleanTitleForSearch('Amélie (2001)')).toBe('Amélie');
|
||
expect(cleanTitleForSearch('THE LAST OF US')).toBe('THE LAST OF US');
|
||
});
|
||
|
||
it('recomposes decomposed input and keeps marks attached to their letter', () => {
|
||
// Providers ship "Dönüş" as "o" + U+0308; the key folds it away but
|
||
// the query must not split the word on the combining mark.
|
||
expect(
|
||
cleanTitleForSearch(
|
||
'1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR'
|
||
)
|
||
).toBe('1942 ye Dönüş');
|
||
expect(
|
||
normalizeTitle('1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR')
|
||
).toBe('1942 ye donus');
|
||
// No precomposed form exists for "ọ̀": the mark stays on the letter
|
||
expect(cleanTitleForSearch('AR - Ìjọ̀gbọ̀n (2023)')).toBe('Ìjọ̀gbọ̀n');
|
||
expect(normalizeTitle('AR - Ìjọ̀gbọ̀n (2023)')).toBe('ijogbon');
|
||
});
|
||
|
||
it('applies the same tag, bracket, season and year stripping as the key', () => {
|
||
expect(cleanTitleForSearch('EN - The Matrix (1999) 4K')).toBe(
|
||
'The Matrix'
|
||
);
|
||
expect(cleanTitleForSearch('|DE| Breaking Bad-DE')).toBe(
|
||
'Breaking Bad'
|
||
);
|
||
expect(cleanTitleForSearch('The Boys s05')).toBe('The Boys');
|
||
expect(cleanTitleForSearch('Пацаны 2 сезон')).toBe('Пацаны');
|
||
expect(cleanTitleForSearch('The Matrix 1999')).toBe('The Matrix');
|
||
// A leading token that is the film's own name survives on both tiers
|
||
expect(cleanTitleForSearch('AKA - 2023')).toBe('AKA');
|
||
expect(normalizeTitle('AKA - 2023')).toBe('aka');
|
||
});
|
||
|
||
it('normalizes to the same key as the text it was derived from', () => {
|
||
for (const raw of [
|
||
'Лейка (10 серий)',
|
||
'EN - The Matrix (1999) 4K',
|
||
'Ёжик 2010',
|
||
'Amélie (2001)',
|
||
'|FR|VO|Le dernier empereur',
|
||
]) {
|
||
expect(normalizeTitle(cleanTitleForSearch(raw))).toBe(
|
||
normalizeTitle(raw)
|
||
);
|
||
}
|
||
});
|
||
|
||
it('returns an empty string for empty input', () => {
|
||
expect(cleanTitleForSearch('')).toBe('');
|
||
expect(cleanTitleForSearch(null)).toBe('');
|
||
});
|
||
});
|