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
Reviewed-on: #323
This commit was merged in pull request #323.
This commit is contained in:
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user