Merge pull request 'feat(validator): compare MINVAL and MAXVAL in SAS's order, with the demo table and clip' (#323) from mocks/rules-demo-table into main
Release / Build-production-and-ng-test (push) Successful in 4m46s
Release / Build-and-test-development (push) Successful in 24m0s
Release / release (push) Successful in 8m57s

Reviewed-on: #323
This commit was merged in pull request #323.
This commit is contained in:
2026-09-23 22:35:35 +00:00
8 changed files with 6833 additions and 853 deletions
@@ -0,0 +1,592 @@
// Clip script: special missings inside Data Controller's validation rules.
//
// This is not a test - it is the recording script for the companion clip, and
// it deliberately pauses between steps so each beat is readable on video. It
// lives in cypress/clips (outside cypress/e2e) so the default spec pattern does
// not pick it up in CI.
//
// Record it with:
//
// npx cypress run --spec cypress/clips/special-missings-clip.cy.ts \
// --config video=true,viewportWidth=1280,viewportHeight=720
//
// The table is TESTDATA.DEMO_01 (seven columns, one rule each):
// ID (NOTNULL), AMOUNT (MINVAL 1), SCORE (MAXVAL 100),
// REF (SOFTREGEX /^[0-9]+$/), STATUS (SOFTSELECT TESTDATA.DEMO_01.STATUS),
// RATING (no rule), LAST_REVIEWED (date9.)
//
// One editing session, one submission. The rule scenes are played on row 1 and
// each value is put back afterwards, so the DIFF that follows carries only the
// two changes the clip is about; the review screens are reached through the
// app's own navigation rather than cy.visit, so nothing reloads mid-clip.
//
// The beats are deliberately short - the take is played back at ~1.4x on the
// way out, so a hold that looks tight here reads as a normal pause on the
// finished clip.
// Caption track: each mark opens a caption and carries the text it shows.
// The encoder turns consecutive marks into subtitle cues, so the captions are
// timed by the recording itself rather than by guessed offsets.
const BEATS = '/tmp/clip-beats.tsv'
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
// A beat long enough to read on video.
const beat = (ms = 1500) => cy.wait(ms)
context('special missings clip (DEMO_01)', function () {
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// The mock estate carries a valid licence key (mock-storage/licence.json),
// so the app activates directly with no free-tier banner in shot.
cy.get('.nav-tree', { timeout: longerCommandTimeout }).should('exist')
})
it('records the demo in one editing session', () => {
cy.writeFile(BEATS, '', { flag: 'w' })
// ---- Scene 1: the table and its rules ----------------------------------
openTableFromTree('testdata', 'demo_01')
beat(1200)
clickOnEdit(() => {
// Seven columns, five rules: ID is the key, AMOUNT the floor, SCORE the
// ceiling, REF the pattern, STATUS the dropdown, and RATING and
// LAST_REVIEWED carry nothing.
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).should(
'have.length.greaterThan',
3
)
mark(
'grid',
'TESTDATA.DEMO_01 - eight columns, six rules. ID is the key, and RATING and LAST_REVIEWED carry none.'
)
beat(2600)
// ---- Scene 2: typing a special missing ------------------------------
// RATING has no rule, so this shows entry on its own. Every beat asserts
// the settled cell state before holding, so the recording always rests on
// the outcome (flagged or accepted) rather than on a blind pause that
// might land before the rule engine has run.
mark(
'entry',
'A numeric cell takes a special missing as a letter or an underscore, with or without a period - row 2 already holds .a.'
)
typeAndHold(0, 'RATING', 'a', 'accepted') // a single letter is taken
mark(
'reject',
'Two letters, or a letter mixed with a number, are refused. Red means it will not submit.'
)
typeAndHold(0, 'RATING', 'AB', 'rejected', 2200) // two letters are not
typeAndHold(0, 'RATING', '1a', 'rejected', 2200) // nor a number and a letter
typeAndHold(0, 'RATING', 'a', 'accepted') // leave it as a special missing
// ---- Scene 3: NOTNULL refuses both --------------------------------
// ID is the key, so NOTNULL applies. A special missing is not a value
// here: like a blank, it fails the rule (a real SAS NOT NULL constraint
// rejects a special missing as well), and only a number satisfies it -
// the original key, put back so the row carries no change into the DIFF.
mark(
'notnull_blank',
"ID is the table's primary key, so NOT NULL is applied to it automatically - a blank fails..."
)
typeAndHold(0, 'ID', '', 'rejected', 2200) // a blank fails NOTNULL
mark(
'notnull_missing',
'...and so does a special missing: a missing is not a value to NOT NULL.'
)
typeAndHold(0, 'ID', 'A', 'rejected', 2200) // so does a special missing
mark(
'notnull_number',
'A number satisfies it, and the row key goes back.'
)
typeAndHold(0, 'ID', '1', 'accepted') // a number is what it wants
// ---- Scene 4: the pattern still applies -----------------------------
// REF carries SOFTREGEX /^[0-9]+$/, and a special missing is not exempt
// from it. The amber cell is a soft rule warning rather than a block,
// which the caption on this beat says out loud - the pattern itself is
// only visible in the cell's native title, which a screencast does not
// capture.
mark(
'softregex',
'REF carries SOFTREGEX /^[0-9]+$/. Amber is a soft warning - it warns, it does not block.'
)
typeIntoCell(0, 'REF', 'A')
getCellByHeaderAndRow(0, 'REF').should('have.class', 'dc-warning-cell')
beat(3400)
typeIntoCell(0, 'REF', '1001') // put the reference back
getCellByHeaderAndRow(0, 'REF').should(
'not.have.class',
'dc-warning-cell'
)
beat(600)
// ---- Scene 5: the dropdown lists the missing -------------------------
// STATUS carries a SOFTSELECT whose list is taken from the column itself
// (the library.member.column form), and that column holds a special
// missing - so the dropdown offers it alongside the ordinary values, as
// the bare letter SAS produces for it.
mark(
'dropdown',
"STATUS carries a SOFTSELECT, and its list is the column's own values."
)
openDropdown(0, 'STATUS')
.should('have.length', 4)
.then(($items: any) => {
const texts = [...$items].map((td: any) => td.innerText.trim())
expect(texts).to.include('A')
})
beat(2400)
mark(
'dropdown_pick',
'So the special missing the column holds is offered as a bare letter, first - a missing sorts below every number.'
)
pickFromDropdown('A')
getCellByHeaderAndRow(0, 'STATUS').should('contain.text', 'A')
beat(1800)
typeIntoCell(0, 'STATUS', '1') // put the status back
beat(600)
// ---- Scene 6: a range rule compares in SAS order, missings included
mark(
'minval',
'AMOUNT has MINVAL 1. A missing sorts below every number, so it is below the floor.'
)
typeAndHold(0, 'AMOUNT', 'A', 'rejected', 2200) // a missing is below the floor
mark(
'maxval',
'SCORE has MAXVAL 100 - the same missing is below the ceiling, so it passes.'
)
typeAndHold(0, 'SCORE', 'A', 'accepted', 2200) // a missing is below the ceiling
mark(
'grade',
'GRADE takes MINVAL .A and MAXVAL .C. The missings have an order of their own: .B is inside the range, .D is outside it.'
)
typeAndHold(0, 'GRADE', '.B', 'accepted', 2400) // .B is between .A and .C
typeAndHold(0, 'GRADE', '.D', 'rejected', 2400) // .D is above .C
mark('abort', 'Submitting with an invalid cell aborts.')
// Submit while AMOUNT is invalid - the modal reports it.
submitTable(() => {
cy.get('.modal-body', { timeout: longerCommandTimeout }).should(
'contain.text',
'Invalid Values are Present'
)
beat(2400)
// Close the abort so the editor is clean for the next scene.
cy.get('clr-modal.clr-abort-modal .modal-footer button')
.contains('Close')
.click({ force: true })
beat(800)
})
// ---- Scene 7: put the test values back, then make the real change ----
mark(
'clean',
'Back to a clean row. Row 2 already holds .a, so this changes one special missing to another.'
)
typeIntoCell(0, 'AMOUNT', '120')
typeIntoCell(0, 'SCORE', '82')
typeIntoCell(0, 'GRADE', '.a')
typeIntoCell(0, 'RATING', '4')
beat(800)
// Row 2 (ID 2) already carries a special missing in RATING, so this is a
// change from one special missing to another, and the date column gives
// the formatted / unformatted switch something to switch.
typeAndHold(1, 'RATING', 'B', 'accepted')
typeIntoDateCell(1, 'LAST_REVIEWED', '2026-01-15')
beat(1500)
submitTable(() => {
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.click()
beat(2500)
})
})
// ---- Scene 8: the queue, then the DIFF (in-app navigation) ------------
mark('submitted', 'Submitted - the queue shows it waiting for approval.')
goToReviewNav()
beat(1800)
// The submit queue lists oldest first, so the row we just created is last.
cy.get('app-submitter clr-datagrid clr-dg-row', {
timeout: longerCommandTimeout
})
.should('exist')
.last()
.click({ force: true })
beat(1800)
cy.get('app-approve-details .card', { timeout: longerCommandTimeout })
.should('exist')
.should('be.visible')
beat(1000)
// The DIFF table is wider than the frame, so the two columns that matter
// (RATING and LAST_REVIEWED) sit off the right edge until it is scrolled.
scrollDiffToEnd()
beat(1200)
// Only those two cells changed: the rule scenes were put back, so the DIFF
// is the two changes the clip is about and nothing else.
getDiffCell('RATING')
.should('contain.text', '.b')
.should('have.class', 'ch')
getDiffCell('LAST_REVIEWED')
.should('contain.text', '15JAN2026')
.should('have.class', 'ch')
cy.get('app-approve-details .tableCont tbody tr td:not(.ch)').should(
'have.length.greaterThan',
3
)
mark(
'diff',
'The DIFF compares staged with base: one changed row, two changed cells.'
)
beat(2600)
// ---- Scene 9: the staged data -----------------------------------------
// What the approval is actually acting on: the staged row, still holding
// the special missing, before it reaches the base table.
mark(
'staged',
'The staged row, before approval - still holding the special missing.'
)
clickButton('VIEW STAGED DATA')
// 'Basic Submitted Details' is on the staged screen only - asserting
// 'Staged Data' alone would be satisfied by the button that was just
// clicked, which is how a beat can pass without ever leaving the DIFF.
cy.get('body', { timeout: longerCommandTimeout })
.should('contain.text', 'Basic Submitted Details')
.and('contain.text', 'Base Table')
beat(4200)
mark('staged_end', '')
// ---- Scene 10: the approver opens it, and switches the format ----------
goToReviewNav()
beat(1500)
openApproveTab()
beat(1500)
cy.get('app-approve clr-datagrid clr-dg-row a.color-green', {
timeout: longerCommandTimeout
})
.should('exist')
.last()
.click({ force: true })
beat(1800)
cy.get('#acceptBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
beat(1000)
// Same scroll as the submitter view, so the date column is in frame when
// the format is switched.
scrollDiffToEnd()
beat(1200)
mark(
'approve',
'The approver opens the same submission. Hovering a changed cell shows the value it replaced.'
)
// Hovering the two changed cells shows what each replaced - that is how the
// two special missings are told apart, not just the before/after of one.
hoverDiffCell('RATING', 'Original value is: .a')
beat(2800)
mark('hover_date', '...including the date it replaced.')
hoverDiffCell('LAST_REVIEWED', 'Original value is: 29FEB2024')
beat(2800)
mark(
'toggle',
'The formatted / unformatted switch shows the value as SAS stores it: 24121.'
)
cy.get('.formatted-values-toggle').should('have.text', 'Formatted').click()
cy.get('.formatted-values-toggle').should('have.text', 'Unformatted')
getDiffCell('LAST_REVIEWED').should('contain.text', '24121')
getDiffCell('RATING').should('contain.text', '.b')
beat(2800)
cy.get('.formatted-values-toggle').click()
cy.get('.formatted-values-toggle').should('have.text', 'Formatted')
getDiffCell('LAST_REVIEWED').should('contain.text', '15JAN2026')
beat(1000)
// ---- Scene 11: approve, and the change is in the history --------------
mark('accepted', 'Accepted - the history records it as APPROVED.')
cy.get('#acceptBtn').click()
cy.url({ timeout: longerCommandTimeout }).should(
'include',
'/review/history'
)
cy.get('app-history clr-datagrid clr-dg-row', {
timeout: longerCommandTimeout
})
.should('exist')
.first()
.should('contain.text', 'APPROVED')
beat(2800)
})
})
// ---------------------------------------------------------------------------
// Helpers (mirrored from the e2e specs so the clip drives the same UI paths)
// ---------------------------------------------------------------------------
/**
* Caption track. Each mark opens a caption and carries the text it shows; the
* encoder turns consecutive marks into subtitle cues, so the captions are timed
* by the recording itself rather than by guessed offsets.
*
* The timestamp has to be taken inside a `cy.then()`: Cypress evaluates a
* command's arguments when the command is *queued*, so `Date.now()` passed to
* `cy.writeFile` directly would give every mark the same value - the moment the
* spec body ran.
*/
const mark = (label: string, text: string) => {
cy.then(() => {
cy.writeFile(BEATS, `${label}\t${text}\t${Date.now()}\n`, { flag: 'a+' })
})
}
const typeIntoCell = (rowIndex: number, header: string, value: string) => {
getCellByHeaderAndRow(rowIndex, header)
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type(`${value}{enter}`)
})
}
/**
* A date-formatted column edits through an HTML date input, where cy.type()
* refuses anything but a bare YYYY-MM-DD string (so no {enter} in the same
* call). Set the value through the DOM and commit it with the Enter keydown
* that Handsontable listens for.
*/
const typeIntoDateCell = (rowIndex: number, header: string, value: string) => {
getCellByHeaderAndRow(rowIndex, header)
.dblclick({ force: true })
.then(() => {
cy.focused()
.then(($i: any) => {
const el = $i[0]
el.value = value
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
})
.trigger('keydown', { key: 'Enter', keyCode: 13, which: 13 })
})
}
/**
* Opens the selectbox on a cell and returns its list entries. Handsontable
* renders the arrow itself (`.htAutocompleteArrow`); the list is a
* `.handsontable.listbox` in the app document.
*/
const openDropdown = (rowIndex: number, header: string) => {
getCellByHeaderAndRow(rowIndex, header).within(() => {
cy.get('.htAutocompleteArrow').click({ force: true })
})
return cy.get('.handsontable.listbox td', { timeout: longerCommandTimeout })
}
/** Picks an entry from the open selectbox. */
const pickFromDropdown = (value: string) => {
cy.get('.handsontable.listbox td').contains(value).click({ force: true })
}
/** Reaches the review area through the app's own navigation (no reload). */
const goToReviewNav = () => {
cy.get('.nav-link', { timeout: longerCommandTimeout })
.contains('REVIEW')
.click({ force: true })
}
/** Opens the APPROVE tab within the review area. */
const openApproveTab = () => {
cy.get('.nav-link', { timeout: longerCommandTimeout })
.contains('APPROVE')
.click({ force: true })
}
/**
* Clicks a button by its visible text. The match is case-insensitive: the app
* uppercases button labels in CSS, so the rendered text and the DOM's
* textContent differ.
*/
const clickButton = (text: string) => {
cy.contains('button', new RegExp(text, 'i'), {
timeout: longerCommandTimeout
}).click({ force: true })
}
/**
* The DIFF table is wider than the recording frame, so scroll its container to
* the end - that is what puts the changed RATING and the date column on screen.
*/
const scrollDiffToEnd = () => {
cy.get('app-approve-details .tableCont', { timeout: longerCommandTimeout })
.should('exist')
.scrollTo('right', { duration: 1200 })
}
/**
* The DIFF on the review screen is a plain HTML table (`.tableCont`), not
* Handsontable: headers are `th`, cells `td`, and a changed cell also carries
* the `ch` class whose tooltip holds the value it replaced.
*/
const getDiffCell = (headerText: string) => {
return cy
.get('app-approve-details .tableCont thead tr th', {
timeout: longerCommandTimeout
})
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('app-approve-details .tableCont tbody tr')
.first()
.then(($tr: any) => $tr[0].childNodes[index])
.then((cell) => cy.get(cell))
})
}
/**
* Reveals the value a changed DIFF cell replaced, and waits until it is really
* on screen.
*
* Clarity shows the tooltip through CSS :hover, which a synthetic
* `trigger('mouseover')` does NOT activate - the tooltip stays
* `visibility: hidden`, and a `contain.text` assertion still passes because the
* text is in the DOM. So this moves the real mouse (cypress-real-events) and
* then asserts the computed style. The first real move after another action can
* be swallowed, hence the repeat; if the tooltip ever fails to appear the
* recording fails rather than quietly showing nothing.
*/
const hoverDiffCell = (headerText: string, expected: string) => {
getDiffCell(headerText).realHover()
beat(300)
getDiffCell(headerText).realHover()
getDiffCell(headerText)
.find('.tooltip-content')
.should(($t) => {
const style = getComputedStyle($t[0] as HTMLElement)
expect(style.visibility, 'tooltip visibility').to.eq('visible')
expect(Number(style.opacity), 'tooltip opacity').to.be.greaterThan(0)
expect($t[0].textContent || '', 'tooltip text').to.contain(expected)
})
}
/**
* Types a value into a cell and holds on the settled result. The rule engine
* flags the cell (htInvalid) once the edit commits, so asserting the expected
* state before the hold means the recording always rests on the outcome, and
* waits for it however long the engine takes.
*
* @param expected 'rejected' when the rule engine should flag the cell,
* 'accepted' when the value should settle unflagged.
*/
const typeAndHold = (
rowIndex: number,
header: string,
value: string,
expected: 'accepted' | 'rejected',
hold = 1800
) => {
typeIntoCell(rowIndex, header, value)
getCellByHeaderAndRow(rowIndex, header).should(
expected === 'rejected' ? 'have.class' : 'not.have.class',
'htInvalid'
)
beat(hold)
}
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const submitTable = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary')
.click()
.then(() => {
if (callback) callback()
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let libNode
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
libNode = node
break
}
}
cy.get(libNode).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
@@ -92,14 +92,49 @@ export class DcValidator {
this.rules.push({ ...EDIT_STATUS_COLUMN_RULE })
this.hiddenColumns.push(this.rules.length - 1)
this.dqrules = dqRules
this.dqrules = [...dqRules]
this.dqdata = dqData
this.primaryKeys = sasparams.PK.split(' ')
// A primary key is NOT NULL by definition, so it must reject a blank and
// a special missing (".A"-".Z", "._") even when the target table carries
// no physical NOT NULL constraint and MPE_VALIDATIONS has no NOTNULL rule
// for it. Synthesised here so every keyed table gets it, and so the grid,
// the edit-record modal and Excel upload validation all see the same rule.
this.addPrimaryKeyNotNullRules()
this.updateDqData()
this.setupValidations()
}
/**
* Adds a NOTNULL rule for each primary key column that does not already
* have one. A primary key identifies the row, so a blank or a special
* missing there is never valid.
*/
private addPrimaryKeyNotNullRules(): void {
for (const pk of this.primaryKeys) {
if (!pk) continue
// A buskey can name a column the table no longer has - do not
// synthesise a rule for a column that is not in the grid.
if (!this.rules.some((rule) => rule.data === pk)) continue
const hasNotNull = this.dqrules.some(
(rule) => rule.BASE_COL === pk && rule.RULE_TYPE === 'NOTNULL'
)
if (!hasNotNull) {
this.dqrules.push({
BASE_COL: pk,
RULE_TYPE: 'NOTNULL',
RULE_VALUE: '',
X: 1
})
}
}
}
registerCustomEditors() {
Handsontable.editors.registerEditor(
'autocomplete.custom',
@@ -283,7 +318,14 @@ export class DcValidator {
* So we will convert it before pushing to array.
*/
if (rule.type && rule.type === 'numeric') {
details.push(Number(data['RULE_DATA']))
// A special missing reaches us as a bare letter ("A", "_"), and
// the regular missing as "." - Number() would turn either into
// NaN, so a strict (HARDSELECT) dropdown could never accept a
// value the column actually holds. Keep them as they are.
const rawValue = data['RULE_DATA']
details.push(
isSpecialMissing(rawValue) ? rawValue : Number(rawValue)
)
} else {
details.push(data['RULE_DATA'])
}
@@ -118,7 +118,12 @@ describe('DC Validator', () => {
expect(dcValidator.getRule('SOME_TIME')).toBeUndefined()
// Test data quality functions
expect(dcValidator.getDqDetails()).toHaveSize(dqRules.length)
// dqRules + 2 synthesised rules: the SOFTSELECT rule updateDqData()
// derives for SOME_DROPDOWN out of dqdata, and the NOTNULL rule
// addPrimaryKeyNotNullRules() gives the primary key column (example_dqRules
// has none for it). Note the constructor copies dqRules rather than
// aliasing it, so this array is no longer mutated by construction.
expect(dcValidator.getDqDetails()).toHaveSize(dqRules.length + 2)
expect(dcValidator.getDqDetails('non_existant')).toHaveSize(0)
expect(dcValidator.getDqDetails('SOME_NUM')).toHaveSize(2)
expect(dcValidator.isDqCol('SOME_NUM')).toBeTrue()
@@ -133,6 +138,91 @@ describe('DC Validator', () => {
])
})
it('treats the primary key column as NOT NULL, even with no NOTNULL rule configured', () => {
const sasparams: SASParam = example_sasparams
const cols: Col[] = example_cols
const dqRules: DQRule[] = example_dqRules // no NOTNULL for PRIMARY_KEY_FIELD
const dqData: DQData[] = example_dqData
const $dataFormats: $DataFormats = example_dataformats
const dcValidator: DcValidator = new DcValidator(
sasparams,
$dataFormats,
cols,
dqRules,
dqData
)
const pkRules = dcValidator.getDqDetails('PRIMARY_KEY_FIELD')
expect(pkRules.some((rule) => rule.RULE_TYPE === 'NOTNULL')).toBeTrue()
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
// A primary key identifies the row, so neither a blank nor a special
// missing can satisfy it.
dcValidator.executeHotValidator(pkRule!, null, (valid: boolean) => {
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(pkRule!, 'A', (valid: boolean) => {
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(pkRule!, 5, (valid: boolean) => {
expect(valid).toBeTrue()
})
})
it('keeps a special missing in a numeric dropdown source, so a strict rule can match it', () => {
const dqData: DQData[] = [
{
BASE_COL: 'SOME_NUM',
RULE_VALUE: 'SOME_NUM',
RULE_DATA: 1,
SELECTBOX_ORDER: 1
},
{
BASE_COL: 'SOME_NUM',
RULE_VALUE: 'SOME_NUM',
RULE_DATA: 'A',
SELECTBOX_ORDER: 2
},
{
BASE_COL: 'SOME_NUM',
RULE_VALUE: 'SOME_NUM',
RULE_DATA: '_',
SELECTBOX_ORDER: 3
},
{
BASE_COL: 'SOME_NUM',
RULE_VALUE: 'SOME_NUM',
RULE_DATA: '.',
SELECTBOX_ORDER: 4
}
] as DQData[]
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
example_dqRules,
dqData
)
// SOME_NUM is numeric and carries a HARDSELECT_HOOK, so its dropdown
// source comes from dqdata. Number() would have NaN'd the special
// missings, leaving the strict membership test unable to match a value
// the column actually holds.
const source = dcValidator.getDqDropdownSource(
dcValidator.getRule('SOME_NUM')!
)
expect(source[0]).toEqual(1)
expect(source[1]).toEqual('A')
expect(source[2]).toEqual('_')
// the regular missing is a dropdown option too, not a NaN
expect(source[3]).toEqual('.')
expect(source.some((value) => Number.isNaN(value as number))).toBeFalse()
})
it('should test hot validator', () => {
const sasparams: SASParam = example_sasparams
const cols: Col[] = example_cols
@@ -163,21 +253,22 @@ describe('DC Validator', () => {
dcValidator.executeHotValidator(someNumRule!, 'ss', (valid: boolean) => {
expect(valid).toBeFalse()
})
//Special missings
// Special missings - a SAS NOT NULL (or primary key) constraint rejects
// these, so the rule must reject them too: they are NULL, not values.
dcValidator.executeHotValidator(someNumRule!, 's', (valid: boolean) => {
expect(valid).toBeTrue()
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(someNumRule!, '.s', (valid: boolean) => {
expect(valid).toBeTrue()
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(someNumRule!, '.', (valid: boolean) => {
expect(valid).toBeTrue()
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(someNumRule!, '..', (valid: boolean) => {
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(someNumRule!, '._', (valid: boolean) => {
expect(valid).toBeTrue()
expect(valid).toBeFalse()
})
// MINVAL, MAXVAL Validation
@@ -193,7 +284,7 @@ describe('DC Validator', () => {
expect(valid).toBeFalse()
})
dcValidator.executeHotValidator(shortNumRule!, 's', (valid: boolean) => {
expect(valid).toBeFalse() // Special missings are lowest numbers, if any MINVAL is set, special missing is always lower
expect(valid).toBeFalse() // Special missings are the lowest numbers, so any MINVAL is above them
})
// CASE validation
@@ -24,7 +24,44 @@ describe('DC Validator - dq validation', () => {
expect(dqValidate(dqRules, invalidValue)).toBeFalse()
expect(dqValidate(dqRules, invalidStringValue)).toBeFalse()
expect(dqValidate(dqRules, numericStringValue)).toBeTrue()
// A missing sorts below every number, so it is below the floor.
expect(dqValidate(dqRules, numericSpecialMissingValue)).toBeFalse()
expect(dqValidate(dqRules, null)).toBeFalse()
expect(dqValidate(dqRules, undefined)).toBeFalse()
})
it('should order the missing values in a range rule as SAS does', () => {
const missingRange: DQRule[] = [
{ BASE_COL: 'test', RULE_TYPE: 'MINVAL', RULE_VALUE: '.A', X: 0 },
{ BASE_COL: 'test', RULE_TYPE: 'MAXVAL', RULE_VALUE: '.C', X: 0 }
]
// Inside the range of missings
expect(dqValidate(missingRange, '.A')).toBeTrue()
expect(dqValidate(missingRange, '.B')).toBeTrue()
expect(dqValidate(missingRange, '.C')).toBeTrue()
expect(dqValidate(missingRange, 'b')).toBeTrue()
// Outside it - above the ceiling
expect(dqValidate(missingRange, '.D')).toBeFalse()
expect(dqValidate(missingRange, 'z')).toBeFalse()
// Outside it - below the floor. The regular missing sits between ._ and .A
expect(dqValidate(missingRange, '._')).toBeFalse()
expect(dqValidate(missingRange, null)).toBeFalse()
// Every number sorts above every missing, so it is above the ceiling
expect(dqValidate(missingRange, 0)).toBeFalse()
expect(dqValidate(missingRange, 5)).toBeFalse()
// A floor of .A alone still lets the numbers through: they are above it
const missingFloor: DQRule[] = [
{ BASE_COL: 'test', RULE_TYPE: 'MINVAL', RULE_VALUE: '.A', X: 0 }
]
expect(dqValidate(missingFloor, '.A')).toBeTrue()
expect(dqValidate(missingFloor, '.Z')).toBeTrue()
expect(dqValidate(missingFloor, 5)).toBeTrue()
expect(dqValidate(missingFloor, '._')).toBeFalse()
})
it('should validate MAXVAL value', () => {
@@ -49,7 +86,10 @@ describe('DC Validator - dq validation', () => {
expect(dqValidate(dqRules, invalidValue)).toBeFalse()
expect(dqValidate(dqRules, invalidStringValue)).toBeFalse()
expect(dqValidate(dqRules, numericStringValue)).toBeTrue()
// A missing sorts below every number, so it is below the ceiling
expect(dqValidate(dqRules, numericSpecialMissingValue)).toBeTrue()
expect(dqValidate(dqRules, null)).toBeTrue()
expect(dqValidate(dqRules, undefined)).toBeTrue()
})
it('should validate UPCASE value', () => {
@@ -111,6 +151,43 @@ describe('DC Validator - dq validation', () => {
expect(dqValidate(dqRules, invalidValue2)).toBeFalse()
})
it('should reject a special missing on a numeric column (a SAS NOT NULL constraint does)', () => {
const dqRules: DQRule[] = [
{
BASE_COL: 'test',
RULE_TYPE: 'NOTNULL',
RULE_VALUE: ' ',
X: 0
}
]
// The values a real SAS service delivers for a numeric column.
expect(dqValidate(dqRules, 'A', true)).toBeFalse()
expect(dqValidate(dqRules, '_', true)).toBeFalse()
expect(dqValidate(dqRules, '.', true)).toBeFalse()
// Ordinary numbers and numeric strings still pass.
expect(dqValidate(dqRules, 5, true)).toBeTrue()
expect(dqValidate(dqRules, '5', true)).toBeTrue()
})
it('should not reject a single letter on a character column', () => {
const dqRules: DQRule[] = [
{
BASE_COL: 'test',
RULE_TYPE: 'NOTNULL',
RULE_VALUE: ' ',
X: 0
}
]
// There is no special-missing concept on a character column - a lone
// letter is ordinary data.
expect(dqValidate(dqRules, 'A')).toBeTrue()
expect(dqValidate(dqRules, '_')).toBeTrue()
expect(dqValidate(dqRules, '.')).toBeTrue()
expect(dqValidate(dqRules, '', false)).toBeFalse()
})
it('should return true if rule not found', () => {
const validValue = 5
@@ -1,8 +1,49 @@
import { DQRule } from '../models/dq-rules.model'
import { specialMissingNumericValidator } from './hot-custom-validators'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
import { parseRegexRule } from '../utils/parseRegexRule'
/**
* A SAS numeric variable's values have a total order, and its missing values sit
* below every non-missing value. The missing values are themselves ordered:
* `._` is the lowest, then the regular missing, then `.A` through `.Z`.
*
* A range rule compares in that order. That is what makes a range of missings
* meaningful - with `MINVAL .A` and `MAXVAL .C`, `.B` is inside the range and `.D`
* is outside it - and it is also why a special missing fails a numeric floor: it
* sorts below every number.
*
* The key is a pair: the class (0 for a missing, 1 for a number, so that every
* number sorts above every missing) and the position within that class. A value
* that is neither a number nor a missing has no place in the order and gets null,
* which no rule accepts.
*/
const sasNumericOrderKey = (value: any): [number, number] | null => {
if (value === undefined || value === null || value === '') return [0, 1] // regular missing
if (typeof value === 'string') {
const upper = value.trim().toUpperCase()
if (upper === '.' || upper === '') return [0, 1] // regular missing
if (upper === '._' || upper === '_') return [0, 0] // the lowest missing
if (/^\.?[A-Z]$/.test(upper))
return [0, 2 + (upper.charCodeAt(upper.length - 1) - 65)] // .A .. .Z
const numValue = parseFloat(upper)
return isNaN(numValue) ? null : [1, numValue]
}
const numValue = Number(value)
return isNaN(numValue) ? null : [1, numValue]
}
const compareSasNumericOrder = (
a: [number, number],
b: [number, number]
): number => (a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1])
const dqValidation: {
[key: string]: (
value: any,
@@ -33,25 +74,39 @@ const dqValidation: {
return true
},
MINVAL: (value: any, ruleValue: string | number): boolean => {
const isValidNumeric = specialMissingNumericValidator(value)
const numValue = parseFloat(value)
const valueKey = sasNumericOrderKey(value)
const ruleKey = sasNumericOrderKey(ruleValue)
// If it's validNumeric and it is NaN it means it is special numeric, and those are always less then any
// min value set
if (isValidNumeric && isNaN(numValue)) return false
// A value that is neither a number nor a missing has no place in the order,
// so nothing satisfies the rule.
if (!valueKey || !ruleKey) return false
return numValue >= Number(ruleValue.toString())
return compareSasNumericOrder(valueKey, ruleKey) >= 0
},
MAXVAL: (value: any, ruleValue: string | number): boolean => {
const isValidNumeric = specialMissingNumericValidator(value)
const numValue = parseFloat(value)
const valueKey = sasNumericOrderKey(value)
const ruleKey = sasNumericOrderKey(ruleValue)
if (isValidNumeric && isNaN(numValue)) return true
if (!valueKey || !ruleKey) return false
return numValue <= Number(ruleValue.toString())
return compareSasNumericOrder(valueKey, ruleKey) <= 0
},
NOTNULL: (value: any, ruleValue: string | number): boolean => {
return value !== undefined && value !== null && value.toString().length > 0
NOTNULL: (
value: any,
ruleValue: string | number,
isNumeric: boolean = false
): boolean => {
if (value === undefined || value === null) return false
// A special missing (.A-.Z, ._) is NULL as far as a SAS NOT NULL (or
// primary key) constraint is concerned - an insert carrying one is
// rejected with an integrity constraint error - so the rule must reject
// it too, or the editor would accept a value the target table refuses.
// Numeric columns only: a lone letter is ordinary data on a character
// column.
if (isNumeric && isSpecialMissing(value)) return false
return value.toString().length > 0
},
// Pattern is used as authored, not auto-anchored — a rule author who
// wants a full-value match must write ^...$ themselves.
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -152,10 +152,19 @@ for (const col of diffCols) colDdtypes[col.name] = getDdType(col)
// Staged values arrive from CSV as strings ("42"), base values are native JSON
// (42). Compare numerics by value and strings case-sensitively.
// A SAS special missing (._ , .A-.Z) reaches us as a string. Special missings
// are numeric-only, and real DC writes the DIFF with `missing=STRING` - whose
// format maps ._ and .a-.z to a string but leaves a bare `.` as null (see the
// `bart` format in mp_jsonout.sas). Coercing a special missing with Number()
// would yield NaN and blank the cell out of the DIFF.
const SPECIAL_MISSING_RE = /^\.(_|[a-z])$/i
function normVal(value, colName) {
const col = diffCols.find((c) => c.name === colName)
if (col && col.type === 'N') {
if (value === null || value === undefined || value === '') return null
const str = String(value).trim()
if (SPECIAL_MISSING_RE.test(str)) return str
const num = Number(value)
return isNaN(num) ? null : num
}
@@ -543,7 +552,7 @@ if (action === 'SHOW_DIFFS') {
const stageFolder = getStageFolder(loadRef)
// check: has this user already approved? (mirrors prev_upload_check in postdata.sas)
const reviewData = loadTableData('MPE_REVIEW') || { rows: [] }
const reviewData = mpeLoadTableData('MPE_REVIEW') || { rows: [] }
const alreadyApproved = reviewData.rows.some(
(r) =>
r.TABLE_ID === loadRef &&
+42 -3
View File
@@ -8,6 +8,28 @@ const dcLibref = 'DC_JSLIB'
// Load shared DC mock utilities
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
// Mirrors SAS cats() for the values a dropdown source can hold: a special
// missing stored in its period form (".a") becomes the bare uppercase letter
// ("A") that cats() returns, so the mock's dropdown list matches the real one.
function cats(value) {
if (typeof value === 'string') {
const m = /^\.(_|[a-z])$/i.exec(value.trim())
if (m) return m[1].toUpperCase()
return value.trim()
}
return String(value)
}
/**
* SAS sort position of a special missing, or null when the value is ordinary.
* `._` sorts first, then `.a` to `.z` - all of them below every number.
*/
function missingRank(value) {
const m = /^\.(_|[a-z])$/i.exec(String(value).trim())
if (!m) return null
return m[1] === '_' ? 0 : m[1].toUpperCase().charCodeAt(0) - 64
}
// ─── Parse input ──────────────────────────────────────────────────────────────
const _sctRow = fetchTable('SASControlTable')[0] || {}
@@ -130,7 +152,7 @@ if (tableData && tableData.rows && tableData.columns) {
const colName = parts[parts.length - 1]
const lcName = colName.toLowerCase()
const seen = new Set()
let order = 1
const values = []
for (const row of tableData.rows) {
let val = row[colName]
if (val === undefined) val = row[lcName]
@@ -139,13 +161,30 @@ if (tableData && tableData.rows && tableData.columns) {
if (key) val = row[key]
}
if (val !== undefined && val !== null) {
const strVal = String(val)
const strVal = cats(val)
if (!seen.has(strVal)) {
seen.add(strVal)
dqdata.push({ BASE_COL: rule.BASE_COL, RULE_VALUE: rule.RULE_VALUE, RULE_DATA: strVal, SELECTBOX_ORDER: order++ })
values.push({ raw: val, str: strVal })
}
}
}
// getdata.sas orders the source by the column itself, and a special
// missing sorts below every number - so the list reads `._`, `.a`-`.z`,
// then the numbers ascending.
values.sort((a, b) => {
const ra = missingRank(a.raw)
const rb = missingRank(b.raw)
if (ra !== null && rb !== null) return ra - rb
if (ra !== null) return -1
if (rb !== null) return 1
const na = Number(a.str)
const nb = Number(b.str)
if (!isNaN(na) && !isNaN(nb)) return na - nb
return a.str < b.str ? -1 : a.str > b.str ? 1 : 0
})
values.forEach((v, i) => {
dqdata.push({ BASE_COL: rule.BASE_COL, RULE_VALUE: rule.RULE_VALUE, RULE_DATA: v.str, SELECTBOX_ORDER: i + 1 })
})
}
}