derived-variables.cy.ts:97 expected dc-derived-first on the REGION header, because the comment assumed REGION opens the computed block. The grid follows MPE_DERIVED order, and that order is LINE_VALUE, REGION, REGION_LABEL - so the marker was on LINE_VALUE and correct. The spec asserted the wrong column, and the development pipeline has been red on it since. Dumping the header row on a local mock estate (sasjs/server desktop mode, RUN_TIMES=js) shows: 6 | LINE_VALUE | dc-derived-header dc-derived-first 7 | REGION | dc-derived-header 8 | REGION_LABEL | dc-derived-header beforeHiddenColumn htLastVisibleHeader and the same dump against the unmodified release/7.19 client, so this was never a client defect. The assertion now states the invariant instead: at least one derived header, the first of them opens the block, and no other one carries the marker. That holds whatever order the control table returns the variables in.
250 lines
8.5 KiB
TypeScript
250 lines
8.5 KiB
TypeScript
export {}
|
|
//
|
|
// Derived (computed) variables are configured per table in MPE_DERIVED and
|
|
// created at read time, ahead of the filter, so that they can be filtered on.
|
|
//
|
|
// TESTDATA.DERIVED_DEMO carries two of them, neither of which is a physical
|
|
// column of the table:
|
|
// REGION ifc(STATE in ('CA','OR','WA'),'West','East')
|
|
// LINE_VALUE QTY * PRICE
|
|
//
|
|
// The mock implements the derivation in getdata.js / getcolvals.js (see
|
|
// mpeDerived in dcMockUtils.js), so the EDIT screen can be exercised without a
|
|
// SAS server. The real evaluation is done by SAS.
|
|
|
|
const hostUrl = Cypress.env('hosturl')
|
|
const appLocation = Cypress.env('appLocation')
|
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
|
|
|
context('derived variables: ', function () {
|
|
this.beforeAll(() => {
|
|
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
|
cy.loginAndUpdateValidKey(true)
|
|
})
|
|
|
|
this.beforeEach(() => {
|
|
cy.viewport(1440, 900)
|
|
bootApp()
|
|
})
|
|
|
|
it('1 | derived columns are shown in the EDIT grid with computed values', () => {
|
|
openEditor('TESTDATA.DERIVED_DEMO')
|
|
|
|
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
|
|
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'East')
|
|
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '21')
|
|
getCellByHeaderAndRow(1, 'LINE_VALUE').should('have.text', '99')
|
|
// REGION_LABEL is built on REGION, so it only resolves if the derived
|
|
// statements run in VARIABLE_ORDER sequence
|
|
getCellByHeaderAndRow(0, 'REGION_LABEL').should('have.text', 'West-CA')
|
|
getCellByHeaderAndRow(1, 'REGION_LABEL').should('have.text', 'East-NY')
|
|
|
|
cy.screenshot('derived-1-grid', { capture: 'viewport' })
|
|
})
|
|
|
|
it('2 | filters on a character derived column', () => {
|
|
openEditor('TESTDATA.DERIVED_DEMO')
|
|
|
|
openFilterPopup()
|
|
setFilterVariable('REGION')
|
|
setFilterValue('West')
|
|
submitFilter()
|
|
|
|
cy.get('.infoBar-text').should('contain.text', "REGION = 'West'")
|
|
cy.get('.ht_master .htCore tbody tr', {
|
|
timeout: longerCommandTimeout
|
|
}).should('have.length', 2)
|
|
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'West')
|
|
|
|
cy.screenshot('derived-2-filter-region', { capture: 'viewport' })
|
|
})
|
|
|
|
it('3 | filters on a numeric derived column', () => {
|
|
openEditor('TESTDATA.DERIVED_DEMO')
|
|
|
|
openFilterPopup()
|
|
setFilterVariable('LINE_VALUE')
|
|
setFilterOperator('>')
|
|
setFilterValue('50')
|
|
submitFilter()
|
|
|
|
cy.get('.ht_master .htCore tbody tr', {
|
|
timeout: longerCommandTimeout
|
|
}).should('have.length', 1)
|
|
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '99')
|
|
|
|
cy.screenshot('derived-3-filter-value', { capture: 'viewport' })
|
|
})
|
|
|
|
it('4 | a derived column is marked read-only, and opens no editor', () => {
|
|
openEditor('TESTDATA.DERIVED_DEMO')
|
|
|
|
// a derived column is read-only AND computed, so the grid marks it apart
|
|
// from an ordinary read-only column - its own header/cell class, no
|
|
// readonlyCell dimming (editor.component.ts, afterGetColHeader /
|
|
// afterGetCellMeta). STATE is the control: same grid, a physical column.
|
|
headerCell('REGION').should('have.class', 'dc-derived-header')
|
|
headerCell('REGION').should('not.have.class', 'readonlyCell')
|
|
headerCell('STATE')
|
|
.should('not.have.class', 'dc-derived-header')
|
|
.and('not.have.class', 'readonlyCell')
|
|
getCellByHeaderAndRow(0, 'REGION').should('have.class', 'dc-derived-cell')
|
|
getCellByHeaderAndRow(0, 'STATE').should(
|
|
'not.have.class',
|
|
'dc-derived-cell'
|
|
)
|
|
// The computed block opens at whichever derived column comes first in the
|
|
// grid - MPE_DERIVED order, so LINE_VALUE here, not REGION. Assert the
|
|
// invariant rather than a name: exactly one derived header carries the
|
|
// marker, and it is the first one.
|
|
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
|
|
const derived = [...$ths].filter((th) =>
|
|
th.classList.contains('dc-derived-header')
|
|
)
|
|
expect(derived.length, 'derived headers').to.be.greaterThan(0)
|
|
expect(
|
|
derived[0].classList.contains('dc-derived-first'),
|
|
'the first derived header opens the block'
|
|
).to.be.true
|
|
derived
|
|
.slice(1)
|
|
.forEach(
|
|
(th) =>
|
|
expect(
|
|
th.classList.contains('dc-derived-first'),
|
|
'only the first derived header carries the marker'
|
|
).to.be.false
|
|
)
|
|
})
|
|
|
|
// the hover text carries the label, which is what says what the column is:
|
|
// the configured VAR_LABEL where there is one, otherwise the generated
|
|
// name=expression
|
|
headerCell('REGION_LABEL')
|
|
.should('have.attr', 'title')
|
|
.and('contain', 'Region and state')
|
|
headerCell('REGION')
|
|
.should('have.attr', 'title')
|
|
.and('contain', 'REGION=ifc')
|
|
|
|
enterEditMode()
|
|
|
|
getCellByHeaderAndRow(0, 'REGION').dblclick({ force: true })
|
|
cy.get('.handsontableInput').should('not.exist')
|
|
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
|
|
})
|
|
|
|
/**
|
|
* The positive control for the case above: same screen, same edit mode, a
|
|
* physical column - so its silence is about the column, not the screen.
|
|
*/
|
|
it('5 | a physical column in the same grid does accept an edit', () => {
|
|
openEditor('TESTDATA.DERIVED_DEMO')
|
|
enterEditMode()
|
|
|
|
getCellByHeaderAndRow(0, 'STATE').dblclick({ force: true })
|
|
cy.get('.handsontableInput').should('exist')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Cypress clears cookies between tests, which drops the SASjs Server session
|
|
* and lands the app back on the evaluation agreement card. Accept it when it is
|
|
* shown - a no-op when the session survived.
|
|
*/
|
|
const bootApp = () => {
|
|
cy.visit(hostUrl, { timeout: longerCommandTimeout })
|
|
cy.get('body').then(($body: any) => {
|
|
if ($body.find('#TCS input[type="checkbox"]').length) {
|
|
cy.get('#TCS input[type="checkbox"]').check({ force: true })
|
|
cy.wait(4000)
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Opens a table straight in the EDIT screen and waits for the grid. */
|
|
const openEditor = (libMem: string) => {
|
|
cy.visit(`${hostUrl}${appLocation}/#/editor/${libMem}`, {
|
|
timeout: longerCommandTimeout
|
|
})
|
|
cy.get('.ht_clone_top .htCore thead tr th', {
|
|
timeout: longerCommandTimeout
|
|
}).should('exist')
|
|
cy.wait(2500)
|
|
}
|
|
|
|
/** The EDIT screen opens read-only; this is the button that starts an edit. */
|
|
const enterEditMode = () => {
|
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
|
.click()
|
|
.then(() => cy.wait(2500))
|
|
}
|
|
|
|
/** The grid header cell for a column, by its rendered text. */
|
|
const headerCell = (headerText: string) =>
|
|
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
|
|
const th = [...$ths].find(
|
|
(candidate) => (candidate as HTMLElement).innerText.trim() === headerText
|
|
)
|
|
if (!th) throw new Error(`header not found: ${headerText}`)
|
|
return cy.wrap(th)
|
|
})
|
|
|
|
const openFilterPopup = () => {
|
|
cy.contains('button.btnView', 'Filter', {
|
|
timeout: longerCommandTimeout
|
|
}).click()
|
|
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
|
|
'be.visible'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The soft-select inputs drop a transparent .overlay click-catcher over the
|
|
* modal while their suggestion list is open, so they need force.
|
|
*/
|
|
const setFilterVariable = (column: string) => {
|
|
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
|
|
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
|
|
force: true
|
|
})
|
|
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
|
|
cy.wait(400)
|
|
}
|
|
|
|
const setFilterValue = (value: string) => {
|
|
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
|
|
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
|
|
cy.wait(400)
|
|
}
|
|
|
|
const setFilterOperator = (operator: string) => {
|
|
cy.get('.filter-modal .operator-col select').first().select(operator)
|
|
cy.wait(500)
|
|
}
|
|
|
|
const submitFilter = () => {
|
|
cy.contains('.filter-modal button', 'Ok').click()
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
|
cy.wait(2500)
|
|
}
|
|
|
|
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
|
return cy
|
|
.get('.ht_clone_top .htCore thead tr th')
|
|
.should(($ths) => {
|
|
const texts = [...$ths].map((th) => (th as HTMLElement).innerText.trim())
|
|
expect(texts).to.include(headerText)
|
|
})
|
|
.then(($ths) => {
|
|
const index = [...$ths].findIndex(
|
|
(th) => (th as HTMLElement).innerText.trim() === headerText
|
|
)
|
|
|
|
return cy
|
|
.get('.ht_master tbody tr')
|
|
.then((rows: any) => rows[rowIndex].childNodes[index])
|
|
.then((cell) => cy.get(cell))
|
|
})
|
|
}
|