Files
dc/client/cypress/clips/special-missings-clip.cy.ts
T
dc 6d85bce2a0
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m41s
Build / Build-and-test-development (pull_request) Successful in 25m18s
fix(validator): reject a special missing on a NOT NULL column
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.
2026-09-23 07:45:18 +00:00

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
}
}
})
})
})
})
}