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