The three-row siphonophore result is narrower than the grid viewport, so Handsontable sizes the columns wider than it (scrollWidth 1562 vs clientWidth 1340): a horizontal scrollbar appears and NOTES - the column the match is actually in - is clipped. The cell text is in the DOM either way, so the beat passed without it, but the demo read as three arbitrary rows. scrollGridTo() sets scrollLeft on #hotTable .wtHolder, asserts the value landed, and the beat asserts the full NOTES text and scrolls back for the following beats. It is a plain spec step - CI runs it, no recording flag and no cy.wait. Also lets a recording size the browser window: the capture is the window's content area, so RECORD_WINDOW_SIZE is passed through as --window-size for a real Chromium browser (Electron ignores it). That is what makes a 16:9 pane possible: (W-450)/(H-96) = 16/9 at 1920x923 of content, i.e. 1920x1010 of window, for a 1470x827 pane and the repo's 1600x900 viewport at ~0.90 zoom. Inert unless the env var is set, so CI is unaffected. dc-cypress updated with the window-sizing recipe, the clipped-column scroll and the frame mean/variance scan used to find the cut point.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
// Marks this file as an ES module (rather than a global script) so its
|
|
// top-level consts don't collide, under the TS type-checker, with the same
|
|
// names declared in other spec files — see e.g. viewer-labels.cy.ts.
|
|
export {}
|
|
|
|
const username = Cypress.env('username')
|
|
const password = Cypress.env('password')
|
|
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}`)
|
|
|
|
// Fixture: MPE_X_SEARCH, a 1000-row demo table built by the mock data builder
|
|
// (sas/mocks/sasjs/services/admin/makedata.js). It is deterministic, so every
|
|
// count below can be derived by hand:
|
|
// - 1000 rows, PK 1001-2000, columns in demo order: PRIMARY_KEY_FIELD,
|
|
// SITE_NAME, VESSEL, SAMPLE_COUNT, DEPTH_M, SPECIES, CRUISE_DATE,
|
|
// EXPEDITION_ID, NOTES
|
|
// - 300 rows have a site name containing "Trench" (case sensitive, so
|
|
// "trench" matches nothing)
|
|
// - 280 rows contain "Halcyon": the site Halcyon Trench (100 rows) plus the
|
|
// vessel RV Halcyon (200 rows) less the 20 rows that are both
|
|
// - 3 rows carry "siphonophore" in the middle of their NOTES text, in the
|
|
// sites Oceanus Rise, Nereid Trench and Halcyon Trench
|
|
// - 100 rows observed Vampire squid (species is picked by (site+vessel)%10)
|
|
// - 100 rows are at Oceanus Rise (site is picked by (n%50)/5)
|
|
// - 9 rows have an expedition id starting EXP-000 (EXP-0001..EXP-0009)
|
|
// - the first row (PK 1001) is the only one with EXPEDITION_ID EXP-0001
|
|
// - 12 rows were sounded at exactly 1000m (DEPTH_M = 1000 + (n%90)*37)
|
|
// - 2 rows have SAMPLE_COUNT 4210 (the only numeric value that is not a
|
|
// primary key, a depth or a date)
|
|
// - PK 2000 is the last row of the table
|
|
// - 200 rows were surveyed by RV Halcyon, 60 of them at a Trench site
|
|
const demoTable = 'mpe_x_search'
|
|
|
|
// Full-table search: the viewer's search box searches EVERY column of the
|
|
// table, character columns by case-sensitive CONTAINS and numeric columns by
|
|
// exact equality (see %mp_searchdata in the SASjs macro core, and
|
|
// viewdata.sas / services/public/viewdata.js).
|
|
//
|
|
// The whole walkthrough is one test - the table is opened once and every
|
|
// variation is a search in place, which is how a user works and what makes the
|
|
// recording read as a demo rather than a set of page loads.
|
|
//
|
|
// Some searches legitimately match nothing: they pin the case sensitivity, the
|
|
// exact-match numeric semantics and the "no data found" handling. Those steps
|
|
// are wrapped in negativeStep() and can be skipped when recording the demo, so
|
|
// the video only ever shows searches that return rows:
|
|
//
|
|
// npx cypress run --browser electron \
|
|
// --spec cypress/e2e/full-table-search.cy.ts --env skipNegative=true
|
|
//
|
|
// CI runs the file with no env, so every step runs there.
|
|
const skipNegative = `${Cypress.env('skipNegative')}` === 'true'
|
|
const negativeStep = (step: () => void) => {
|
|
if (!skipNegative) step()
|
|
}
|
|
|
|
context('full table search tests: ', function () {
|
|
this.beforeAll(() => {
|
|
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
|
// forceLicenceKey=true: applies a valid licence key instead of relying on
|
|
// an earlier spec in the same run having already done so.
|
|
cy.loginAndUpdateValidKey(true)
|
|
})
|
|
|
|
this.beforeEach(() => {
|
|
cy.visit(hostUrl + appLocation, { timeout: longerCommandTimeout })
|
|
|
|
// Visit 'home' first, matching filtering.cy.ts/viewer-labels.cy.ts — going
|
|
// straight to 'view/data' skips the startup-data fetch 'home' triggers,
|
|
// which leaves the nav tree empty in slower environments.
|
|
visitPage('home')
|
|
visitPage('view/data')
|
|
})
|
|
|
|
it('searches the whole table from the viewer', () => {
|
|
// Full view: every column, and the true row count for the table.
|
|
openTableFromTree(libraryToOpenIncludes, demoTable)
|
|
assertRowCount('1,000')
|
|
assertGridContains(['Nereid Trench', 'RV Pelagia', 'Vampire squid'])
|
|
|
|
// Partial character search: "siphonophore" only ever appears in the middle
|
|
// of the NOTES text, so a match proves the search is not column-specific.
|
|
// Three rows is narrow enough that the grid grows a horizontal scrollbar
|
|
// and clips NOTES - the column the match is actually in - so scroll it
|
|
// into view, assert on what is now on screen, then put the grid back.
|
|
searchFor('siphonophore')
|
|
assertRowCount('3')
|
|
scrollGridTo('right')
|
|
pauseForDemo()
|
|
assertGridContains(['Oceanus Rise', 'Nereid Trench', 'Halcyon Trench'])
|
|
assertGridExcludes(['Kraken Hollow', 'Vostok Deep'])
|
|
assertGridContains(['bioluminescent siphonophore was filmed here'])
|
|
scrollGridTo('left')
|
|
|
|
// A search spans every column at once: "Halcyon" is both a site (Halcyon
|
|
// Trench) and a vessel (RV Halcyon), and the 280 matches are the union.
|
|
searchFor('Halcyon')
|
|
assertRowCount('280')
|
|
assertGridContains(['Halcyon Trench', 'RV Halcyon'])
|
|
|
|
// A multi-word site name: the search is not limited to single tokens.
|
|
searchFor('Oceanus Rise')
|
|
assertRowCount('100')
|
|
assertGridContains(['Oceanus Rise'])
|
|
|
|
// Multi-word values work the same way - species names are two words.
|
|
searchFor('Vampire squid')
|
|
assertRowCount('100')
|
|
assertGridContains(['Vampire squid'])
|
|
assertGridExcludes(['Yeti crab'])
|
|
|
|
// A partial value in a short code column: EXP-000 matches the first nine
|
|
// expedition ids.
|
|
searchFor('EXP-000')
|
|
assertRowCount('9')
|
|
assertGridContains(['1001', '1009'])
|
|
|
|
// A value that is unique in the table: the first row's expedition id.
|
|
searchFor('EXP-0001')
|
|
assertRowCount('1')
|
|
assertGridContains(['1001'])
|
|
|
|
// Character search is case sensitive: the same word in the wrong case
|
|
// matches nothing.
|
|
negativeStep(() => {
|
|
searchFor('trench')
|
|
assertNoData()
|
|
})
|
|
|
|
searchFor('Trench')
|
|
assertRowCount('300')
|
|
assertGridContains(['Halcyon Trench'])
|
|
assertGridExcludes(['Kraken Hollow'])
|
|
|
|
// Numeric search is an exact match across the numeric columns.
|
|
setNumericSearch(true)
|
|
searchFor('4210')
|
|
assertRowCount('2')
|
|
assertGridContains(['1221', '1781', '4210'])
|
|
|
|
// The numeric search covers every numeric column, not just the primary
|
|
// key: 12 rows were sounded at exactly 1000m.
|
|
searchFor('1000')
|
|
assertRowCount('12')
|
|
assertGridContains(['1091'])
|
|
|
|
// ...and an exact primary key is a single row.
|
|
searchFor('2000')
|
|
assertRowCount('1')
|
|
assertGridContains(['2000'])
|
|
|
|
negativeStep(() => {
|
|
searchFor('421')
|
|
assertNoData()
|
|
})
|
|
|
|
// Back to a character search: clearing the box restores the full table.
|
|
setNumericSearch(false)
|
|
searchFor('')
|
|
assertRowCount('1,000')
|
|
|
|
// A search runs inside the current filter, not the whole table.
|
|
openFilterPopup(() => {
|
|
setFilterWithValue('VESSEL', 'RV Halcyon', () => {
|
|
assertRowCount('200')
|
|
checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`)
|
|
|
|
searchFor('Trench')
|
|
assertRowCount('60')
|
|
assertGridContains(['Halcyon Trench'])
|
|
checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`)
|
|
|
|
// Clearing the search leaves the filter in place.
|
|
searchFor('')
|
|
assertRowCount('200')
|
|
checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`)
|
|
})
|
|
})
|
|
|
|
negativeStep(() => {
|
|
searchFor('not-in-this-table')
|
|
assertNoData()
|
|
})
|
|
})
|
|
})
|
|
|
|
const visitPage = (url: string) => {
|
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
}
|
|
|
|
// Recording-only linger. A beat's row count is the moment its result is on
|
|
// screen, so that is where a demo recording pauses to let it be read. CI runs
|
|
// with no env, so demoPauseMs is 0 and the spec contains no waits at all:
|
|
//
|
|
// npx cypress run --browser electron --spec cypress/e2e/full-table-search.cy.ts \
|
|
// --env skipNegative=true,demoPause=1500
|
|
const demoPauseMs = Number(Cypress.env('demoPause')) || 0
|
|
|
|
// Linger for a recording. A no-op unless demoPause is set, which only the
|
|
// recording run does - CI passes no env.
|
|
const pauseForDemo = () => {
|
|
if (demoPauseMs > 0) cy.wait(demoPauseMs)
|
|
}
|
|
|
|
// The row/column count the viewer shows next to the table name, e.g.
|
|
// "(1,000 rows, 9 cols)". The viewer pluralises: a single-row result reads
|
|
// "(1 row, 9 cols)".
|
|
const assertRowCount = (rows: string) => {
|
|
cy.get('.title-col p', { timeout: longerCommandTimeout }).should(
|
|
($el: any) => {
|
|
const unit = rows === '1' ? 'row' : 'rows'
|
|
expect($el.text().replace(/\s+/g, ' ')).to.contain(`(${rows} ${unit},`)
|
|
}
|
|
)
|
|
|
|
pauseForDemo()
|
|
}
|
|
|
|
// The grid body is a Handsontable .wtHolder, and a narrow result is wider than
|
|
// the grid viewport, which clips the rightmost column (NOTES). Setting
|
|
// scrollLeft is enough - Handsontable keeps the header clone in step - and the
|
|
// assertion right after proves the scroll landed rather than assuming it. The
|
|
// browser clamps scrollLeft to (scrollWidth - clientWidth), so that is what
|
|
// scrolling right has to land on.
|
|
const scrollGridTo = (position: 'left' | 'right') => {
|
|
cy.get('#hotTable .wtHolder')
|
|
.first()
|
|
.then(($holder: any) => {
|
|
const el = $holder[0]
|
|
const maxScroll = el.scrollWidth - el.clientWidth
|
|
|
|
el.scrollLeft = position === 'right' ? el.scrollWidth : 0
|
|
|
|
if (position === 'right') {
|
|
// This step only means anything if the grid really is clipping a
|
|
// column - fail loudly rather than silently doing nothing.
|
|
expect(el.scrollWidth).to.be.greaterThan(el.clientWidth)
|
|
expect(el.scrollLeft).to.equal(maxScroll)
|
|
} else {
|
|
expect(el.scrollLeft).to.equal(0)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A search that matches nothing replaces the grid with the viewer's "no data"
|
|
// panel - and must not raise a request error.
|
|
const assertNoData = () => {
|
|
cy.get('.noData h3', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'No data found with given conditions'
|
|
)
|
|
}
|
|
|
|
const assertGridContains = (values: string[]) => {
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should(
|
|
($grid: any) => {
|
|
const text = $grid.text().replace(/\s+/g, ' ')
|
|
for (const value of values) expect(text).to.contain(value)
|
|
}
|
|
)
|
|
}
|
|
|
|
const assertGridExcludes = (values: string[]) => {
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should(
|
|
($grid: any) => {
|
|
const text = $grid.text().replace(/\s+/g, ' ')
|
|
for (const value of values) expect(text).to.not.contain(value)
|
|
}
|
|
)
|
|
}
|
|
|
|
const searchFor = (value: string) => {
|
|
cy.get('input[name="search-input"]', { timeout: longerCommandTimeout })
|
|
.should('be.visible')
|
|
.clear()
|
|
|
|
if (value.length > 0) cy.get('input[name="search-input"]').type(value)
|
|
|
|
cy.get('input[name="search-input"]').type('{enter}')
|
|
}
|
|
|
|
const setNumericSearch = (enabled: boolean) => {
|
|
cy.get('input[name="numeric_check"]', { timeout: longerCommandTimeout }).then(
|
|
(checkbox: any) => {
|
|
if (checkbox[0].checked !== enabled) checkbox[0].click()
|
|
}
|
|
)
|
|
}
|
|
|
|
const checkInfoBarIncludes = (text: string) => {
|
|
cy.get('.infoBar b', { timeout: longerCommandTimeout }).should(($el: any) => {
|
|
expect($el.text().toLowerCase()).to.contain(text.toLowerCase())
|
|
})
|
|
}
|
|
|
|
// Opens the viewer's options menu and clicks through to the filter modal.
|
|
const openFilterPopup = (callback?: any) => {
|
|
cy.get('.filterSide', { timeout: longerCommandTimeout }).first().click()
|
|
|
|
cy.get('.dropdown-menu', { timeout: longerCommandTimeout })
|
|
.contains('Filter')
|
|
.click()
|
|
|
|
cy.get('#vals_var_id0_0', { timeout: longerCommandTimeout }).should('exist')
|
|
|
|
if (callback) callback()
|
|
}
|
|
|
|
// Fills the first clause of the filter modal (variable, then value) and
|
|
// submits it - the viewer stores the clause, reloads the table with the new
|
|
// filter RK and shows the generated WHERE text in the info bar.
|
|
const setFilterWithValue = (
|
|
variableValue: string,
|
|
valueString: string,
|
|
callback?: any
|
|
) => {
|
|
cy.get('#vals_var_id0_0').type(variableValue)
|
|
cy.get('#datalist_vals_var_id0_0 option')
|
|
.contains(variableValue)
|
|
.click({ force: true })
|
|
|
|
// The soft-select closes its suggestion list shortly after a selection -
|
|
// wait for that closed state instead of sleeping.
|
|
cy.get('#datalist_vals_var_id0_0').should('have.class', 'hidden')
|
|
|
|
cy.get('#vals_0_0').type(valueString)
|
|
|
|
closeAutocompleteList('#vals_0_0')
|
|
cy.get('#datalist_vals_0_0').should('have.class', 'hidden')
|
|
|
|
cy.get('.filter-modal button[aria-label="Ok"]').click()
|
|
|
|
if (callback) callback()
|
|
}
|
|
|
|
// The soft-select inputs open a suggestion list that drops a transparent
|
|
// click-catcher over the modal until it closes, so send the same Escape key a
|
|
// user would press. `force` is needed because the input sits underneath that
|
|
// click-catcher while the list is open.
|
|
const closeAutocompleteList = (inputSelector: string) => {
|
|
cy.get(inputSelector).trigger('keyup', { key: 'Escape', force: true })
|
|
}
|
|
|
|
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 viyaLib
|
|
|
|
for (let node of treeNodes) {
|
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
|
viyaLib = node
|
|
break
|
|
}
|
|
}
|
|
|
|
cy.get(viyaLib).within(() => {
|
|
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
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// Selecting the table triggers async SPA routing + a viewdata fetch; wait
|
|
// for the grid to actually render before any subsequent action.
|
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
|
timeout: longerCommandTimeout
|
|
}).should('exist')
|
|
}
|