@@ -537,4 +537,236 @@ public function testNormalizeDirtyYtsFilename(): void
537537 $ this ->assertSame ('Three Wise Men And A Baby ' , $ result ['title ' ]);
538538 $ this ->assertSame (2022 , $ result ['year ' ]);
539539 }
540+
541+ // --- Step 13.1: configurable noise-suffix stripping ---------------------
542+
543+ /** A trailing "Directors Cut" edition phrase is peeled off the title. */
544+ public function testNormalizeStripsDirectorsCutSuffix (): void
545+ {
546+ $ result = SceneFilenameNormalizer::normalize ('Blade Runner Directors Cut ' );
547+
548+ $ this ->assertSame ('Blade Runner ' , $ result ['title ' ]);
549+ $ this ->assertNull ($ result ['year ' ]);
550+ }
551+
552+ /** Edition noise after a detected year does not pollute the parsed title. */
553+ public function testNormalizeStripsExtendedCutAfterYear (): void
554+ {
555+ $ result = SceneFilenameNormalizer::normalize ('Aliens 1986 Extended Cut 1080p ' );
556+
557+ $ this ->assertSame ('Aliens ' , $ result ['title ' ]);
558+ $ this ->assertSame (1986 , $ result ['year ' ]);
559+ }
560+
561+ /** A combined "UNCUT & UNRATED" suffix is fully removed. */
562+ public function testNormalizeStripsUncutAndUnratedSuffix (): void
563+ {
564+ $ result = SceneFilenameNormalizer::normalize ('Dune UNCUT & UNRATED ' );
565+
566+ $ this ->assertSame ('Dune ' , $ result ['title ' ]);
567+ $ this ->assertNull ($ result ['year ' ]);
568+ }
569+
570+ /** An "ALTERNATE ENDING" suffix is removed without touching the title number. */
571+ public function testNormalizeStripsAlternateEndingSuffix (): void
572+ {
573+ $ result = SceneFilenameNormalizer::normalize ('District 9 ALTERNATE ENDING ' );
574+
575+ $ this ->assertSame ('District 9 ' , $ result ['title ' ]);
576+ $ this ->assertNull ($ result ['year ' ]);
577+ }
578+
579+ /** A bare trailing "YIFY" aggregator tag is removed. */
580+ public function testNormalizeStripsTrailingYify (): void
581+ {
582+ $ result = SceneFilenameNormalizer::normalize ('Foo YIFY ' );
583+
584+ $ this ->assertSame ('Foo ' , $ result ['title ' ]);
585+ $ this ->assertNull ($ result ['year ' ]);
586+ }
587+
588+ /** A single-token noise word that IS the whole title must NOT empty it. */
589+ public function testNormalizeNoiseTokenDoesNotEmptyTitle (): void
590+ {
591+ $ result = SceneFilenameNormalizer::normalize ('DC ' );
592+
593+ $ this ->assertSame ('DC ' , $ result ['title ' ]);
594+ $ this ->assertNull ($ result ['year ' ]);
595+ }
596+
597+ /** Stripping noise suffixes must never mutate the `raw` original filename. */
598+ public function testNormalizeNoiseStrippingPreservesRaw (): void
599+ {
600+ $ original = 'Blade Runner Directors Cut ' ;
601+ $ result = SceneFilenameNormalizer::normalize ($ original );
602+
603+ $ this ->assertSame ($ original , $ result ['raw ' ]);
604+ }
605+
606+ /** Stacked trailing edition suffixes are peeled iteratively. */
607+ public function testNormalizeStripsStackedNoiseSuffixes (): void
608+ {
609+ $ result = SceneFilenameNormalizer::normalize ('Highlander Remastered Directors Cut ' );
610+
611+ $ this ->assertSame ('Highlander ' , $ result ['title ' ]);
612+ $ this ->assertNull ($ result ['year ' ]);
613+ }
614+
615+ // --- Step 13.1 (TestEngineer edge cases) --------------------------------
616+
617+ /**
618+ * Three+ stacked trailing edition suffixes are all peeled in one normalize pass.
619+ */
620+ public function testNormalizeStripsThreeStackedNoiseSuffixes (): void
621+ {
622+ $ result = SceneFilenameNormalizer::normalize ('Highlander Uncut Remastered Directors Cut ' );
623+
624+ $ this ->assertSame ('Highlander ' , $ result ['title ' ]);
625+ $ this ->assertNull ($ result ['year ' ]);
626+ }
627+
628+ /**
629+ * Noise-suffix matching is case-insensitive regardless of input casing.
630+ *
631+ * @dataProvider provideCaseVariantSuffixes
632+ */
633+ public function testNormalizeStripsNoiseSuffixCaseInsensitively (string $ filename ): void
634+ {
635+ $ result = SceneFilenameNormalizer::normalize ($ filename );
636+
637+ $ this ->assertSame ('Blade Runner ' , $ result ['title ' ], "for input: {$ filename }" );
638+ $ this ->assertNull ($ result ['year ' ]);
639+ }
640+
641+ /**
642+ * @return array<string, array{string}>
643+ */
644+ public static function provideCaseVariantSuffixes (): array
645+ {
646+ return [
647+ 'lowercase ' => ['Blade Runner directors cut ' ],
648+ 'uppercase ' => ['Blade Runner DIRECTORS CUT ' ],
649+ 'titlecase ' => ['Blade Runner Directors Cut ' ],
650+ 'mixed-case ' => ['Blade Runner DiReCtOrS cUt ' ],
651+ ];
652+ }
653+
654+ /**
655+ * The separator between title and noise suffix may be a space, dash, dot, or
656+ * underscore (the ` -._` set the regex peels) — all variants are stripped.
657+ *
658+ * Uses "Directors Cut" (a pure NOISE_SUFFIX phrase, NOT a QUALITY_TOKEN) so the
659+ * assertion isolates the Step 13.1 end-anchored peel from the pre-existing
660+ * token-level quality stripping in the no-year branch.
661+ *
662+ * @dataProvider provideSeparatorVariants
663+ */
664+ public function testNormalizeStripsNoiseSuffixAcrossSeparatorVariants (string $ filename ): void
665+ {
666+ $ result = SceneFilenameNormalizer::normalize ($ filename );
667+
668+ $ this ->assertSame ('Highlander ' , $ result ['title ' ], "for input: {$ filename }" );
669+ $ this ->assertNull ($ result ['year ' ]);
670+ }
671+
672+ /**
673+ * @return array<string, array{string}>
674+ */
675+ public static function provideSeparatorVariants (): array
676+ {
677+ return [
678+ // Dotted scene style: "Highlander.Directors.Cut".
679+ 'dot-separated ' => ['Highlander.Directors.Cut ' ],
680+ // Underscore-delimited release name.
681+ 'underscore-separated ' => ['Highlander_Directors_Cut ' ],
682+ // Dash-attached edition tag.
683+ 'dash-separated ' => ['Highlander - Directors Cut ' ],
684+ // Plain space.
685+ 'space-separated ' => ['Highlander Directors Cut ' ],
686+ ];
687+ }
688+
689+ /**
690+ * A noise suffix that follows a bracketed (YYYY) year is peeled off while the
691+ * bracketed year is still correctly extracted.
692+ */
693+ public function testNormalizeStripsNoiseSuffixAfterBracketedYear (): void
694+ {
695+ $ result = SceneFilenameNormalizer::normalize ('Blade Runner Directors Cut (1982) ' );
696+
697+ $ this ->assertSame ('Blade Runner ' , $ result ['title ' ]);
698+ $ this ->assertSame (1982 , $ result ['year ' ]);
699+ }
700+
701+ /**
702+ * An "Extended" edition tag appearing before a bracketed (YYYY) is also peeled,
703+ * exercising the bracketed-year branch's stripNoiseSuffixes() call.
704+ */
705+ public function testNormalizeStripsExtendedBeforeBracketedYear (): void
706+ {
707+ $ result = SceneFilenameNormalizer::normalize ('Aliens Extended (1986) ' );
708+
709+ $ this ->assertSame ('Aliens ' , $ result ['title ' ]);
710+ $ this ->assertSame (1986 , $ result ['year ' ]);
711+ }
712+
713+ /**
714+ * A legitimate title that merely CONTAINS a noise word mid-string (not at the
715+ * trailing edge) must NOT be stripped — only end-anchored matches are peeled.
716+ *
717+ * @dataProvider provideMidStringNoiseWordTitles
718+ */
719+ public function testNormalizeDoesNotStripMidStringNoiseWord (string $ filename , string $ expectedTitle ): void
720+ {
721+ $ result = SceneFilenameNormalizer::normalize ($ filename );
722+
723+ $ this ->assertSame ($ expectedTitle , $ result ['title ' ], "for input: {$ filename }" );
724+ }
725+
726+ /**
727+ * Each input uses a noise word that is NOT also a QUALITY_TOKEN (so the
728+ * pre-existing token-level quality stripping cannot remove it), proving the
729+ * Step 13.1 end-anchored peel leaves mid-string occurrences untouched.
730+ *
731+ * @return array<string, array{string, string}>
732+ */
733+ public static function provideMidStringNoiseWordTitles (): array
734+ {
735+ return [
736+ // "Uncut" as a leading title word followed by more title.
737+ 'uncut-leading ' => ['Uncut Gems ' , 'Uncut Gems ' ],
738+ // "Directors Cut" phrase mid-string, with a real word after it.
739+ 'directors-mid ' => ['The Directors Cut Saga ' , 'The Directors Cut Saga ' ],
740+ // "Cut" embedded in a longer non-edition word must not partial-match.
741+ 'cut-substring ' => ['The Cutting Edge ' , 'The Cutting Edge ' ],
742+ // "DC" as a substring of a larger word must not strip.
743+ 'dc-substring ' => ['Abducted ' , 'Abducted ' ],
744+ // "Uncut" embedded mid-word must not partial-match.
745+ 'uncut-substring ' => ['Uncuttable Bonds ' , 'Uncuttable Bonds ' ],
746+ ];
747+ }
748+
749+ /**
750+ * A mid-string noise word does not block stripping a genuine trailing one:
751+ * "Uncut Gems Directors Cut" keeps "Uncut Gems" but drops the trailing edition.
752+ */
753+ public function testNormalizeStripsTrailingButKeepsMidStringNoiseWord (): void
754+ {
755+ $ result = SceneFilenameNormalizer::normalize ('Uncut Gems Directors Cut ' );
756+
757+ $ this ->assertSame ('Uncut Gems ' , $ result ['title ' ]);
758+ $ this ->assertNull ($ result ['year ' ]);
759+ }
760+
761+ /**
762+ * A title whose final word legitimately ends with letters that are NOT a
763+ * word-boundary match for a noise token (e.g. "...Extendedness") is preserved,
764+ * confirming the `\b` word-boundary anchor.
765+ */
766+ public function testNormalizeRespectsWordBoundaryOnTrailingToken (): void
767+ {
768+ $ result = SceneFilenameNormalizer::normalize ('The Great Uncutting ' );
769+
770+ $ this ->assertSame ('The Great Uncutting ' , $ result ['title ' ]);
771+ }
540772}
0 commit comments