Review feedback on this PR. - The fallback list bypassed mergeColsRules, so col.TYPE was never set and every column reached the picker as char: char operators for numerics, and unquoted character values, which %mp_filtercheck rejects on the value list and on the submit alike. getcols.sas and the mock now emit TYPE in the client's vocabulary - char for C, num for N/DATE/DATETIME/TIME - and the cypress case no longer stops at selecting the variable: it sets a value, submits, and asserts the applied clause reads SITE_NAME = 'Bristol', so the quoting is the assertion. - The temporal exclusion is dropped, matching getdata.sas and viewdata.sas, whose cols payloads include those columns - the drop is on the data, not on the cols. The mock's getdata.js cols had the same divergence and is aligned; it keeps the exclusion for the rows. That also removes the SAS/mock disagreement on the excluded set, since there is no set. - client/.npmrc added with ignore-scripts and save-exact (plus legacy-peer-deps and fund, matching the root), so a local `cd client && npm i` is guarded the way the CI's is. Verified against the running mock: for MPE_CONFIG (TXTEMPORAL) getcols returns TX_FROM/TX_TO with TYPE=num and DDTYPE=DATETIME, the char columns as TYPE=char, and editors/getdata returns cols including both temporal columns while the rows still exclude them. row-cell-limits.cy.ts is 5/5.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
// The EDIT screen refuses a selection that is too large - DC_MAXOBS_WEBEDIT
|
|
// rows, or DC_MAXCELLS_WEBEDIT cells (rows x columns), whichever is reached
|
|
// first - and the VIEW screen caps at DC_MAXOBS_WEBVIEW rows.
|
|
//
|
|
// 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,400 cells), under the row limit
|
|
// but over the CELL limit
|
|
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 refuses a table over the row limit', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
|
openEdit()
|
|
|
|
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'Table is too big (1000 rows) - please filter and try again!'
|
|
)
|
|
})
|
|
|
|
it('3 | EDIT refuses a table over the cell limit', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
|
|
openEdit()
|
|
|
|
// 200 rows x 1002 columns (the 1001 stored columns plus the housekeeping
|
|
// delete column) = 200,400 cells, over the 200,000 cell limit, while the
|
|
// row count is under the 250 row limit.
|
|
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'Selection is too wide (200 rows x 1002 cols) - please filter and try again!'
|
|
)
|
|
})
|
|
|
|
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 18 columns is 126 cells, so
|
|
// at 110 the TESTDATA.MPE_X_NEW edit must be refused, 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 17 x 6 = 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('.abortMsg', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'Selection is too wide (7 rows x 18 cols) - please filter and try again!'
|
|
)
|
|
})
|
|
|
|
// 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()
|
|
|
|
// 126 cells under a 200000 cell limit: the editor loads, no abort.
|
|
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
|
timeout: longerCommandTimeout
|
|
}).should('exist')
|
|
})
|
|
})
|
|
|
|
it('5 | the filter picker still lists the variables after the abort', () => {
|
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
|
openEdit()
|
|
|
|
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'Table is too big (1000 rows) - please filter and try again!'
|
|
)
|
|
|
|
closeAbortModal()
|
|
|
|
// The abort returns no table, and the picker's variable list normally comes
|
|
// from the table's own columns - so without the public/getcols fallback the
|
|
// "please filter and try again" advice has nothing to pick from. Every
|
|
// column of MPE_X_SEARCH must be on offer.
|
|
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'
|
|
])
|
|
})
|
|
|
|
// and the suggestion list is usable, not just populated
|
|
setFilterVariable('SITE_NAME')
|
|
|
|
// Submitting is what exercises TYPE. The picker keys its operator set and
|
|
// its value quoting on it, and the fallback list never passes through
|
|
// mergeColsRules, so without TYPE on this payload a character value would be
|
|
// submitted unquoted and the backend's %mp_filtercheck would reject it.
|
|
setFilterValue('Bristol')
|
|
submitFilter()
|
|
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
"SITE_NAME = 'Bristol'"
|
|
)
|
|
})
|
|
})
|
|
|
|
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 abort modal offers Close, and the filter button sits behind it. */
|
|
const closeAbortModal = () => {
|
|
cy.get('app-info-modal', { timeout: longerCommandTimeout })
|
|
.contains('button', 'Close')
|
|
.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')
|
|
}
|