Files
dc/client/cypress/e2e/full-table-search.cy.ts
T
dc aae48dc401
Build / Build-and-ng-test (pull_request) Failing after 1m36s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m18s
test(e2e): scroll the grid so the NOTES column is on screen for the siphonophore beat
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.
2026-09-16 18:44:25 +00:00

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