// The EDIT screen is bounded by DC_MAXOBS_WEBEDIT rows and DC_MAXCELLS_WEBEDIT // cells (rows x columns), whichever is reached first, and the VIEW screen caps // at DC_MAXOBS_WEBVIEW rows. // // A selection over an EDIT limit is served TRUNCATED - the grid carries the // first rows that fit, and a warning triangle against the table name says why. // This spec pins those limits against the mock, which mirrors the shipped // defaults (250 rows / 200000 cells for EDIT, 2000 rows for VIEW). It uses // two existing fixtures rather than new ones: // MPE_X_SEARCH - 1000 rows x 9 cols, so it trips the ROW limit // MPE_X_WIDE - 200 rows x 1001 cols (200,200 cells), under the row limit // but over the CELL limit, so the row cap is 199 export {} const hostUrl = Cypress.env('hosturl') const appLocation = Cypress.env('appLocation') const longerCommandTimeout = Cypress.env('longerCommandTimeout') const serverType = Cypress.env('serverType') const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`) context('EDIT row and cell limits, VIEW row limit: ', function () { this.beforeAll(() => { cy.visit(`${hostUrl}/SASLogon/logout`) // forceLicenceKey=true: applies a valid licence rather than relying on an // earlier spec in the run having done so (see commands.ts). cy.loginAndUpdateValidKey(true) }) this.beforeEach(() => { cy.visit(hostUrl + appLocation) visitPage('home') visitPage('view/data') }) it('1 | VIEW renders up to the raised row cap, past the old 500', () => { openTableFromTree(libraryToOpenIncludes, 'mpe_x_search') // Neither the header nor the cell text can pin the cap: the header reports // the table's true row count whether or not the grid is capped, and the // fixture's own numeric columns carry values above 1500 (DEPTH_M reaches // 4293, SAMPLE_COUNT 4210), so a text match can hit fixture data. // // The PK can pin it. The first td of a .ht_master row is the PK - the row // header, where present, is a th - and MPE_X_SEARCH holds PK 1001-2000, so // the range check below is what stops a mis-selected column passing. With // the old 500 row cap the grid stopped at PK 1500, so a rendered PK above // 1500 can only appear if the cap was raised. scrollGridToBottom() cy.get('#hotTable .ht_master .htCore tbody tr', { timeout: longerCommandTimeout }).should(($rows) => { const pks = $rows .toArray() .map((row) => row.querySelectorAll('td')[0]) .filter((td) => !!td) .map((td) => parseInt((td.textContent || '').trim(), 10)) .filter((pk) => !isNaN(pk)) expect(pks.length).to.be.greaterThan(0) pks.forEach((pk) => expect(pk).to.be.within(1001, 2000)) expect(Math.max(...pks)).to.be.greaterThan(1500) }) }) it('2 | EDIT serves a table over the row limit, truncated, with a warning', () => { openTableFromTree(libraryToOpenIncludes, 'mpe_x_search') openEdit() // 1000 rows x 9 columns is over the 250 row limit, so the grid carries the // first 250 rows and the header says how many it is showing. cy.get('.editor-title', { timeout: longerCommandTimeout }).should( 'contain.text', '250 rows' ) cy.get('#hotTable .ht_master .htCore tbody tr', { timeout: longerCommandTimeout }).should('have.length.at.least', 1) cy.get('.truncation-warning', { timeout: longerCommandTimeout }) .should('be.visible') .realHover() cy.get('.editor-title clr-tooltip-content', { timeout: longerCommandTimeout }) .should('be.visible') .should('contain.text', 'DC_MAXOBS_WEBEDIT') .should(($el) => expect($el.css('opacity')).to.eq('1')) }) it('3 | EDIT serves a table over the cell limit, truncated, with a warning', () => { openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide') openEdit() // 200 rows x 1001 stored columns = 200,200 cells, over the 200,000 cell // limit, while the row count is under the 250 row limit - so the cell // limit sets the cap: floor(200000 / 1001) = 199 rows. cy.get('.editor-title', { timeout: longerCommandTimeout }).should( 'contain.text', '199 rows' ) cy.get('.truncation-warning', { timeout: longerCommandTimeout }) .should('be.visible') .realHover() cy.get('.editor-title clr-tooltip-content', { timeout: longerCommandTimeout }) .should('be.visible') .should('contain.text', 'DC_MAXCELLS_WEBEDIT') .should(($el) => expect($el.css('opacity')).to.eq('1')) }) it('4 | the EDIT limits follow MPE_CONFIG, not the shipped defaults', () => { // Two things at once. The seeded config carries the shipped defaults (250 // rows / 200000 cells), which are the numbers the mock used to hard-code - // so nothing above would notice if the mock stopped reading MPE_CONFIG at // all. And MPE_CONFIG lives in the control library, so reading it from the // table's own library would fail for this table in particular. // // The cell limit is the one to move: 7 rows x 17 columns is 119 cells, so // at 110 the TESTDATA.MPE_X_NEW edit must be truncated to // floor(110 / 17) = 6 rows, where a hard-coded 200000 - or a config read // that fails and falls back to it - lets it through. 110 is still above // MPE_CONFIG's own 6 x 17 = 102, so the option can be put back afterwards. setConfigValue('DC_MAXCELLS_WEBEDIT', '110', () => { visitPage('view/data/TESTDATA.MPE_X_NEW') closeAbortModalIfPresent() openEdit() cy.get('.editor-title', { timeout: longerCommandTimeout }).should( 'contain.text', '6 rows' ) cy.get('.truncation-warning', { timeout: longerCommandTimeout }) .should('be.visible') .realHover() cy.get('.editor-title clr-tooltip-content', { timeout: longerCommandTimeout }) .should('be.visible') .should('contain.text', 'DC_MAXCELLS_WEBEDIT') .should(($el) => expect($el.css('opacity')).to.eq('1')) }) // Put the option back, so the rest of the estate sees the shipped default. setConfigValue('DC_MAXCELLS_WEBEDIT', '200000', () => { visitPage('view/data/TESTDATA.MPE_X_NEW') closeAbortModalIfPresent() openEdit() // 119 cells under a 200000 cell limit: all 7 rows load, no warning. cy.get('.editor-title', { timeout: longerCommandTimeout }).should( 'contain.text', '7 rows' ) cy.get('.truncation-warning').should('not.exist') }) }) it('5 | the warning says to filter, and filtering narrows the grid', () => { openTableFromTree(libraryToOpenIncludes, 'mpe_x_search') openEdit() cy.get('.truncation-warning', { timeout: longerCommandTimeout }) .should('be.visible') .realHover() cy.get('.editor-title clr-tooltip-content', { timeout: longerCommandTimeout }) .should('be.visible') .should('contain.text', 'Filter the table to work with the rest') .should(($el) => expect($el.css('opacity')).to.eq('1')) // The advice has to be actionable: the picker must offer the table's // columns and the filter must reload the grid. openFilterPopup() filterVariables().should(($options) => { const names = $options .toArray() .map((o) => (o.textContent || '').trim()) .filter((n) => n.length > 0) expect(names, 'the picker offers the table columns').to.include.members([ 'PRIMARY_KEY_FIELD', 'SITE_NAME', 'VESSEL', 'SAMPLE_COUNT', 'DEPTH_M', 'SPECIES', 'CRUISE_DATE', 'EXPEDITION_ID', 'NOTES' ]) }) setFilterVariable('SITE_NAME') // 100 of the 1000 rows: under the limit, so nothing is truncated. setFilterValue('Nereid Trench') submitFilter() cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should( 'contain.text', "SITE_NAME = 'Nereid Trench'" ) cy.get('.editor-title', { timeout: longerCommandTimeout }).should( 'contain.text', '100 rows' ) cy.get('.truncation-warning').should('not.exist') }) }) const visitPage = (url: string) => { cy.visit(`${hostUrl}${appLocation}/#/${url}`) } // The viewer's options dropdown holds the Edit item; it only renders for a // table that is registered in MPE_TABLES (tableEditExists()). const openEdit = () => { cy.get('.filterSide', { timeout: longerCommandTimeout }).click() cy.contains('[clrDropdownItem]', 'Edit', { timeout: longerCommandTimeout }).click() } /** * The EDIT screen's Filter button opens the same .filter-modal the viewer uses. * It only renders once the editor is in its read-only (non-editing) state, * which is where a failed EDIT load leaves it. */ const openFilterPopup = () => { cy.contains('button.btnView', 'Filter', { timeout: longerCommandTimeout }).click() cy.get('.filter-modal', { timeout: longerCommandTimeout }).should( 'be.visible' ) } /** The picker's variable suggestion list. */ const filterVariables = () => cy.get('#datalist_vals_var_id0_0 option', { timeout: longerCommandTimeout }) /** The soft-select drops a transparent overlay over the modal, so it needs 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 }) } /** The value input of the same soft-select row. */ 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 submitFilter = () => { cy.contains('.filter-modal button', 'Ok').click() cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist') cy.wait(2500) } /** * Sets a DC option in MPE_CONFIG through the editor and approves it, then hands * back to the callback. The same route licensing.cy.ts uses for the licence * keys: the config table needs an approval like any other. */ const setConfigValue = ( varName: string, varValue: string, callback: () => void ) => { reloadApp() // straight to the config table's editor, so the flow does not depend on // whichever view mode the previous step left behind visitPage('editor/DC_JSLIB.MPE_CONFIG') closeAbortModalIfPresent() clickOnEdit() editTableField([{ varName, varValue }], () => { submitTable(() => { cy.wait(2000) approveTable(() => { reloadApp() callback() }) }) }) } /** * A full page load. A visit that only changes the hash does not reload the * app, so an open modal - the abort message from the step just gone, say - * would still be covering the page. */ const reloadApp = () => { cy.visit(hostUrl + appLocation) cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist') } /** * DC puts abort messages up as modals over whatever page is showing, and they * outlive a hash-only navigation, so clear one before driving the page. */ const closeAbortModalIfPresent = () => { cy.get('body').then(($body) => { if ($body.find('app-info-modal').length > 0) { cy.get('app-info-modal') .contains('button', 'Close') .click({ force: true }) } }) } /** * The viewer's Edit button. The options dropdown (.filterSide) that openEdit() * uses is not rendered for every table, so the config table is opened the way * licensing.cy.ts opens it. */ const clickOnEdit = (callback?: () => void) => { cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout }) .click() .then(() => { if (callback) callback() }) } /** Types a new value into the row whose cell carries varName. */ const editTableField = ( edits: { varName: string; varValue: string }[], callback: () => void ) => { cy.get('#hotTable .ht_master .htCore tbody td', { timeout: longerCommandTimeout }).then(($tds: any) => { const nodes = $tds.toArray() for (const edit of edits) { for (let i = 0; i < nodes.length; i++) { // exact match on the cell that holds the option name; the cell after it // is the value if ((nodes[i].textContent || '').trim() === edit.varName) { cy.wrap(nodes[i + 1]) .dblclick() .then(() => { cy.focused() .type('{selectall}') .type(edit.varValue) .type('{enter}') }) break } } } callback() }) } const submitTable = (callback: () => void) => { cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout }) .click() .then(() => { cy.get(".modal.ng-star-inserted button[type='submit']").click() callback() }) } const approveTable = (callback: () => void) => { cy.get('button', { timeout: longerCommandTimeout }) .should('contain', 'Approve') .then((allButtons: any) => { for (const approvalButton of allButtons) { if (approvalButton.innerText.toLowerCase().includes('approve')) { approvalButton.click() break } } cy.get('button#acceptBtn', { timeout: longerCommandTimeout }) .should('exist') .should('not.be.disabled') .click() .then(() => { cy.get('app-history', { timeout: longerCommandTimeout }).should( 'exist' ) callback() }) }) } // Handsontable virtualises rows, so the last row is only in the DOM once the // grid has been scrolled to the bottom. The scroller is the .wtHolder inside // the .ht_master pane. const scrollGridToBottom = () => { cy.get('#hotTable .ht_master .wtHolder', { timeout: longerCommandTimeout }) .scrollTo('bottom', { duration: 0 }) .trigger('scroll') } 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 targetLib for (let node of treeNodes) { if (node.innerText.toLowerCase().includes(libNameIncludes)) { targetLib = node break } } cy.get(targetLib).within(() => { // Small settle wait: right after a fresh visit the tree component can // still be re-rendering, causing this node to be found then swapped // out mid-click. cy.wait(300) cy.get( '.clr-tree-node-content-container .clr-treenode-content p' ).click() cy.get('.clr-treenode-link').then((innerNodes: any) => { for (let innerNode of innerNodes) { if (innerNode.innerText.toLowerCase().includes(tablename)) { innerNode.click() break } } }) }) }) }) cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', { timeout: longerCommandTimeout }).should('exist') }