The recording spec paused for a fixed couple of seconds after each edit and trusted that the rule engine had finished. It has not: the engine flags a cell (htInvalid) shortly after the edit commits, so a blind pause can land before the red appears. In the first take the blanked NOTNULL key never showed as invalid on screen, which is the whole point of that scene. Every beat now asserts the expected state - flagged for the rejections (AB, 1a, a blank key, a special missing below MINVAL), unflagged for the accepted ones - and only then holds. The hold therefore always rests on the settled outcome, and the clip is self-checking: if the engine stops flagging one of these states the recording fails instead of quietly showing the wrong thing. Verified against the app: blanking ID gives the cell htInvalid; AB and 1a in RATING are flagged; a single letter in RATING, a special missing in ID and a special missing under MAXVAL all settle clean.
239 lines
8.1 KiB
TypeScript
239 lines
8.1 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 passes it, a blank does not -------------------
|
|
typeAndHold(0, 'ID', '', 'rejected', 3200) // a blank fails NOTNULL
|
|
typeAndHold(0, 'ID', 'A', 'accepted') // a special missing is a value
|
|
|
|
// ---- 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
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|