Files
iptvnator/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts
T
4gray 78df3e7dbb fix(portals): match Greek titles whichever sigma the provider typed (#1310)
Greek Σ has two lowercase forms — medial σ and word-final ς — and neither the
candidate query nor the confirmation treated them as one letter.

The GLOB scan built each character's class from a one-way reach that only
arrived at ς when it started from ς, so a request for "ΑΣ" never admitted a
stored "Ας". Classes are now built from a fold group — every character sharing
an uppercase form — derived by scanning the cased ranges at module load the way
ACCENTED_BY_BASE already is. It generalises past sigma on its own: dotless ı
folds with i, long ſ with s, historic Cyrillic letterforms with В Д О С Т Ъ Ѣ.
Only the 24 groups of 767 that a per-character fold would miss are kept.

Admitting the row was only half of it. normalizeTitleKeys then compared "ασ"
against "ας" and discarded it, because toLowerCase picks the sigma form by
position. Both SQL tiers already folded them together — SQLite's trigram
tokenizer does full Unicode folding natively, unlike LOWER() — so the JS
confirmation was the only tier that did not, making this a pre-existing gap on
the FTS path as well. Normalization now rewrites ς to σ after lowercasing,
which is what Unicode case folding does.

Guards unchanged: a case mapping that changes length (ß → SS, İ) or a GLOB
metacharacter still returns null rather than a partial pattern.
2026-07-29 23:14:59 +02:00

241 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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('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',
];
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);
}
}
);
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);
});
});