A special missing 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 (_NM0001_ / _PK0001_) - but the frontend NOTNULL rule passed it, so the editor accepted a value the target table refuses. getdata merges a physical NOT NULL constraint into a NOTNULL rule, so the mismatch was reachable on any table with the constraint. The rule now fails a special missing on a numeric column. A single letter stays valid on a character column, where there is no special missing concept. Also updates the DEMO_01 clip script: the NOTNULL scene now shows a blank AND a special missing both rejected, with a number accepted.
245 lines
8.4 KiB
TypeScript
245 lines
8.4 KiB
TypeScript
// 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 (six columns, one rule each):
|
|
// ID (NOTNULL), AMOUNT (MINVAL 1), SCORE (MAXVAL 100),
|
|
// REF (SOFTREGEX /^[0-9]+$/), RATING (no rule), LAST_REVIEWED (date9.)
|
|
|
|
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('1 | records the rule scenes', () => {
|
|
// ---- Scene 1: the table and its rules ----------------------------------
|
|
openTableFromTree('testdata', 'demo_01')
|
|
beat(2500)
|
|
|
|
clickOnEdit(() => {
|
|
// Six columns, four rules: ID is the key, AMOUNT the floor, SCORE the
|
|
// ceiling, REF the pattern, RATING and LAST_REVIEWED carry nothing.
|
|
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).should(
|
|
'have.length.greaterThan',
|
|
3
|
|
)
|
|
beat(2500)
|
|
|
|
// ---- Scene 2: typing a special missing ------------------------------
|
|
// RATING has no rule, so this shows entry on its own. Every beat below
|
|
// 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.
|
|
typeAndHold(0, 'RATING', 'a', 'accepted') // a single letter is taken
|
|
typeAndHold(0, 'RATING', 'AB', 'rejected', 3200) // two letters are not
|
|
typeAndHold(0, 'RATING', '1a', 'rejected', 3200) // 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.
|
|
typeAndHold(0, 'ID', '', 'rejected', 3200) // a blank fails NOTNULL
|
|
typeAndHold(0, 'ID', 'A', 'rejected', 3200) // so does a special missing
|
|
typeAndHold(0, 'ID', '7', 'accepted') // a number is what it wants
|
|
|
|
// ---- Scene 4: the pattern still applies -----------------------------
|
|
typeIntoCell(0, 'REF', 'A')
|
|
// Assert the warning first, then hold on it, so the amber state is on
|
|
// screen for the beat rather than being raced by a fixed pause.
|
|
getCellByHeaderAndRow(0, 'REF')
|
|
.trigger('mouseover', { force: true })
|
|
.should('have.class', 'dc-warning-cell')
|
|
beat(3000)
|
|
|
|
// ---- Scene 5: below the floor, below the ceiling --------------------
|
|
typeAndHold(0, 'AMOUNT', 'A', 'rejected', 3200) // MINVAL fails
|
|
typeAndHold(0, 'SCORE', 'A', 'accepted', 3200) // MAXVAL passes
|
|
|
|
// Submit while AMOUNT is invalid - the modal reports it.
|
|
submitTable(() => {
|
|
cy.get('.modal-body', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'Invalid Values are Present'
|
|
)
|
|
beat(3000)
|
|
// 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(1500)
|
|
})
|
|
})
|
|
})
|
|
|
|
it('2 | records the round trip (submit, review, DIFF)', () => {
|
|
openTableFromTree('testdata', 'demo_01')
|
|
|
|
clickOnEdit(() => {
|
|
// Make one valid special missing change on an unruled column, and put
|
|
// AMOUNT back to a number so the submission is clean.
|
|
typeIntoCell(1, 'RATING', 'B')
|
|
beat(1500)
|
|
typeIntoCell(1, 'AMOUNT', '250')
|
|
beat(1500)
|
|
|
|
submitTable(() => {
|
|
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('not.be.disabled')
|
|
.click()
|
|
beat(3000)
|
|
})
|
|
})
|
|
|
|
// The submit queue, then the DIFF for the submission we just made.
|
|
cy.visit(`${hostUrl}${appLocation}/#/review/submitted`)
|
|
beat(2500)
|
|
|
|
// 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(3000)
|
|
|
|
cy.get('app-approve-details .card', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.should('be.visible')
|
|
beat(3500)
|
|
|
|
// Scroll the DIFF grid right so the changed RATING column - the special
|
|
// missing - is on screen next to AMOUNT.
|
|
cy.get('.tableCont', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.scrollTo('right', { duration: 1200 })
|
|
beat(4000)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (mirrored from the e2e specs so the clip drives the same UI paths)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const typeIntoCell = (rowIndex: number, header: string, value: string) => {
|
|
getCellByHeaderAndRow(rowIndex, header)
|
|
.dblclick({ force: true })
|
|
.then(() => {
|
|
cy.focused().clear().type(`${value}{enter}`)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 = 2500
|
|
) => {
|
|
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
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|