Files
dc/client/cypress/clips/special-missings-clip.cy.ts
T
dc 489819b051
Sites / deploy (push) Successful in 3m52s
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m5s
Build / Build-and-test-development (pull_request) Successful in 33m8s
fix(mocks): address the audit-pack review findings
- zip entries carry flag bit 11 when the name is non-ASCII, so an extractor
  reads `BKP_` plus an accented filename back by name instead of CP437
- loadfile and stagedata name the staged CSV after the libds the request
  arrived with, before a hook can re-point the submission
- stagedata writes that CSV at all (%mp_ds2csv does), so a grid submission's
  audit pack carries the same artefact as an upload's
- loadfile writes no BKP_ file: loadfile.sas copies one only for a second,
  non-CSV fileref, which the mock does not accept
- getauditfile says access is unenforced rather than that there is no
  permission store (MPE_SECURITY is seeded)
- the special-missings clip no longer claims the estate carries a valid key
2026-10-24 20:06:09 +00:00

593 lines
21 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 (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)
// Assumes the estate is already activated (the licensing spec registers a
// valid key); otherwise a licensing page would sit 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
}
}
})
})
})
})
}