From ea74f95144aff964e0c29da674f77463dc26d191 Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Fri, 24 Jul 2026 14:55:05 +0300 Subject: [PATCH] test(validations): add regression coverage for real HARDREGEX/SOFTREGEX rule values Verifies parseRegexRule against a corpus of RULE_VALUEs collected from an existing MPE_VALIDATIONS table - none use PRX-only syntax, so behavior is confirmed to match SAS PRX exactly, not just "doesn't throw". Pins two data quirks found along the way (a range vs. literal-hyphen character class, and a double-escaped lookahead that's a no-op in both engines) rather than silently treating either as a bug to fix. --- .../dc-validator/utils/parseRegexRule.spec.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/client/src/app/shared/dc-validator/utils/parseRegexRule.spec.ts b/client/src/app/shared/dc-validator/utils/parseRegexRule.spec.ts index c920113..8c9ab2c 100644 --- a/client/src/app/shared/dc-validator/utils/parseRegexRule.spec.ts +++ b/client/src/app/shared/dc-validator/utils/parseRegexRule.spec.ts @@ -106,4 +106,132 @@ describe('parseRegexRule', () => { expect(() => parseRegexRule('/a++b/')).toThrow() }) }) + + // HARDREGEX/SOFTREGEX RULE_VALUEs collected from an existing + // MPE_VALIDATIONS table. None of these use PRX-only syntax (no inline + // (?i), \Q...\E, \A/\z, atomic groups, or possessive quantifiers), so + // they pass through parseRegexRule with only the /pattern/flags + // delimiter stripped - the same PCRE-compatible escape rules apply on + // both the SAS PRX side and the JS side, so behavior here is verified + // to match, not just "doesn't throw". + describe('RULE_VALUE corpus collected from an existing MPE_VALIDATIONS table', () => { + const collectedRuleValues = [ + '/^-?[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/', + '/^-?[0-9]{1,3}$/', + '/^(?:(?!31\\\\.12).)*$/', + '/^([0-9]{1,3}([.][0-9]{1,7})?)%$/', + '/^(100|[0-9]{1,2})%$/', + '/^[-,0-9]{1,13}([.]{0,1}[0-9]{0,3})$/', + '/^[-0-9]{1,19}([.]{0,1}[0-9]{0,3})$/', + '/^[-0-9]*([.]{0,1}[0-9]{0,2})$/', + '/^[-0-9]*([.]{0,1}[0-9]{0,3})$/', + '/^[-0-9]*([.]{0,1}[0-9]*)$/', + '/^[-0-9]*$/', + '/^[0-2]$/', + '/^[0-3]$/', + '/^[0-9]{1,19}([.]{0,1}[0-9]{0,3})$/', + '/^[0-9]{1,36}$/', + '/^[0-9]{1,72}$/', + '/^[0-9]{4}$/', + '/^[0-9]{5}$/', + '/^[0-9]*([.]{0,1}[0-9]{0,4})$/', + '/^[0,1]{1}([.]{0,1}[0-9]{0,4})$/', + '/^[1]{1,1}$/', + '/^[A-Z0-1]{5,5}$/', + '/^[a-zA-Z]{1,1}$/', + '/^[a-zA-Z]{1,3}$/', + '/^[a-zA-Z0-9_]{0,36}$/', + '/^[a-zA-Z0-9_=@\\/.:;,= -]{1,8}$/', + '/^[a-zA-Z0-9@_-]{1,100}$/', + '/^[a-zA-Z0-9@_-]{1,50}$/', + '/^[a-zA-Z0-9=@.:;,= -_]{1,50}$/', + '/^[a-zA-Z0-9=@.:;,= -_]{1,80}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,10}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,100}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,12}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,14}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,15}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,20}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,3}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,300}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,32}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,4}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,40}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,400}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,50}$/', + '/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,510}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,6}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,72}$/', + '/^[a-zA-Z0-9=@\\/.:;,= -_]{1,8}$/', + "/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,100}$/", + "/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,20}$/", + "/^[a-zA-Z0-9=@\\/.:;,= \\-_`']{1,500}$/", + "/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/" + ] + + collectedRuleValues.forEach((ruleValue) => { + it(`compiles without throwing: ${ruleValue}`, () => { + expect(() => parseRegexRule(ruleValue)).not.toThrow() + }) + }) + + it('preserves the exact pattern body and applies no flags for a plain numeric-range rule', () => { + const regex = parseRegexRule('/^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/') + + expect(regex.source).toEqual('^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$') + expect(regex.flags).toEqual('') + expect(regex.test('1234.56')).toBeTrue() + expect(regex.test('abc')).toBeFalse() + }) + + it('handles the percentage-with-alternation rule (100 or 0-99, optionally with a decimal)', () => { + const regex = parseRegexRule('/^(100|[0-9]{1,2})%$/') + + expect(regex.test('100%')).toBeTrue() + expect(regex.test('42%')).toBeTrue() + expect(regex.test('101%')).toBeFalse() + }) + + it('handles a character class with an escaped delimiter slash and a non-edge escaped hyphen', () => { + const regex = parseRegexRule('/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/') + + expect(regex.test('a/b-c d.e:f;g,h=i')).toBeTrue() + expect(regex.test('has a # in it')).toBeFalse() + }) + + it('handles a character class with literal backtick/single-quote characters', () => { + const regex = parseRegexRule("/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/") + + expect(regex.test("O'Brien`s value")).toBeTrue() + // The unescaped space-to-underscore run (` -_`) in this class is a + // range (space is code point 32, underscore 95), not three literal + // characters - so it also matches '#' (35), which falls inside that + // range. Same behavior in SAS PRX (identical character-class rules), + // so this is a faithful translation, not a parseRegexRule bug. + expect(regex.test('has a # in it')).toBeTrue() + expect(regex.test('has a ~ in it')).toBeFalse() + }) + + // This is the one inconsistency in the whole corpus: every other escaped + // '/' in this file uses a single backslash, but this rule has \\. (two + // backslashes) where every other sign points to a single \. having been + // intended (a literal dot, inside a negative lookahead meant to exclude + // "31.12" - e.g. a 31-December date - from matching). SAS PRX and JS + // regex use identical backslash-escape semantics for this (no PRX-only + // construct is involved), so parseRegexRule's output is a faithful, + // parity-preserving translation of whatever was actually authored - + // this test pins down what the corpus *actually* does today, verbatim, + // not what was likely intended. \\ (escaped backslash) followed by an + // unescaped . (any character) means the lookahead is functionally a + // no-op: it only fails to match text containing a literal backslash, + // which excludes nothing in practice, so every value - including dates + // that literally contain "31.12" - passes. + it('as authored (double backslash): the 31.12-exclusion lookahead is a no-op and matches everything', () => { + const regex = parseRegexRule('/^(?:(?!31\\\\.12).)*$/') + + expect(regex.test('31.12.2024')).toBeTrue() + expect(regex.test('01.01.2024')).toBeTrue() + }) + }) })