A VA parameter can now carry a transform on the column side of a mapping: va_parmap=SELECTED_DATE-D_DATE:ADD(QUARTER_START,-1) which filters D_DATE on the report value AND the same point one quarter earlier, as a single IN clause - so the AND rule, the clause signature, saveQuery and the reload loop are untouched. Transforms resolve to one clause with these operations: - ADD(<ANCHOR>,<offset>) the value plus one anchor beside it -> IN - RANGE(<PERIOD>,<offset>) a closed period -> BETWEEN Anchors: QUARTER_START/END, MONTH_START/END, YEAR_START/END. Periods: QUARTER, MONTH, YEAR. Offset is a whole number of periods, defaulting to 0. The names describe the result rather than mirroring SAS intnx: the arithmetic runs in the browser, so mirroring intnx would mean reimplementing its alignment letters and their corners (week alignment, 's' and 'm') with no guarantee of matching SAS. A report author can read ADD(QUARTER_START,-1) without knowing intnx. Resolution is anchored on the parameter value, never on today, so a saved report reproduces the same rows. A transform that cannot run where it is used - a non-temporal column, a value that is not a date - is skipped and reported in the existing warning banner rather than silently filtering on the raw value.
499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
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}`)
|
|
|
|
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
|
|
// - chrome is hidden (same as embed=true),
|
|
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
|
|
// - the Filter button is hidden - VA filters replace the filter builder,
|
|
// - the Add Row / Add Record buttons are hidden.
|
|
//
|
|
// The detailed PK+label data-merge logic is covered by the VaMessagingService
|
|
// unit spec; here we assert the deterministic UI layout, that pushing a VA
|
|
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
|
|
// mapping filters the column it names end to end.
|
|
context('embed=va (VA data-driven content) tests: ', function () {
|
|
this.beforeAll(() => {
|
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
cy.loginAndUpdateValidKey()
|
|
})
|
|
|
|
this.beforeEach(() => {
|
|
cy.visit(hostUrl + appLocation)
|
|
// A cy.visit that differs from the current URL only in the hash is a
|
|
// same-document navigation, so the app is not re-bootstrapped. Reload so
|
|
// each test starts from a fresh document (the previous test may have left
|
|
// ?embed=va in the URL, which the app reads once at init).
|
|
cy.reload()
|
|
visitPage('home')
|
|
})
|
|
|
|
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
|
|
openEditorInVaMode('mpe_x_test', () => {
|
|
// Chrome hidden (same as embed=true)
|
|
cy.get('header.app-header').should('not.exist')
|
|
|
|
// Grid present, read-only: the user enters edit mode with Edit, as usual
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
|
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
|
|
|
// The filter builder is replaced by VA filters
|
|
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
|
|
|
|
// Not in edit mode, so no edit-mode controls
|
|
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
|
|
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
|
|
cy.get('.btnCtrl').contains('Submit').should('not.exist')
|
|
|
|
// Bottom Add Record hidden in VA mode
|
|
cy.contains('button', 'Add Record').should('not.exist')
|
|
})
|
|
})
|
|
|
|
it('2 | accepts a VA postMessage and stays in VA mode', () => {
|
|
// A VA message drives column visibility + filtering; an empty/unmatched
|
|
// message reloads the editor (unfiltered) while preserving ?embed=va.
|
|
openEditorInVaMode('mpe_x_test', () => {
|
|
cy.window().then((win) => {
|
|
win.postMessage(
|
|
{
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters: []
|
|
},
|
|
'*'
|
|
)
|
|
})
|
|
|
|
// Editor still loads in VA mode after the message (chrome hidden, grid up)
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
|
cy.get('header.app-header').should('not.exist')
|
|
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
|
})
|
|
})
|
|
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
|
|
// Two DC instances over one table (one per report tab), each filtering the
|
|
// same DC column from its own report parameter. Without the URL mapping the
|
|
// parameter label is not a column name, so nothing would filter at all. The
|
|
// mapping here is written in lower case against an upper-case label and
|
|
// column, which pins the case-insensitivity of both sides end to end.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
// The applied filter names the MAPPED column, and the grid shrinks to
|
|
// the rows matching that value (Option 2 occurs twice in this table).
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'SOME_DROPDOWN'
|
|
)
|
|
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
|
|
cy.get('h4').should('contain.text', '(2 rows')
|
|
},
|
|
'va_parmap=dropdown_tab1-some_dropdown'
|
|
)
|
|
})
|
|
|
|
it('4 | the other tab maps its own parameter to the same column', () => {
|
|
// Same table, same column, a different parameter label and value (Option 1
|
|
// occurs four times) - the per-instance URL mapping is what keeps the two
|
|
// tabs independent.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB2',
|
|
dataType: 'string',
|
|
value: 'Option 1'
|
|
})
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'SOME_DROPDOWN'
|
|
)
|
|
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
|
|
cy.get('h4').should('contain.text', '(4 rows')
|
|
},
|
|
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
|
|
)
|
|
})
|
|
|
|
it('5 | warns in the grid when a mapping names a column the table lacks', () => {
|
|
// Written by hand in the report's URL, so a typo must be visible to the
|
|
// report author: the parameter is skipped, and the reason is shown rather
|
|
// than leaving an unfiltered grid that looks like a working filter.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'VA parameter mapping ignored'
|
|
)
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
|
cy.get('.va-parmap-warning').should('contain.text', 'NOT_A_COLUMN')
|
|
// VA mode hides the app header, so the banner must paint inside the
|
|
// viewport on its own - assert the real box, not just its presence.
|
|
cy.get('.va-parmap-warning').should(($alert) => {
|
|
const rect = $alert[0].getBoundingClientRect()
|
|
expect(rect.width, 'banner width').to.be.greaterThan(100)
|
|
expect(rect.height, 'banner height').to.be.greaterThan(20)
|
|
expect(rect.top, 'banner top').to.be.at.least(0)
|
|
expect(rect.bottom, 'banner bottom').to.be.at.most(
|
|
Cypress.config('viewportHeight')
|
|
)
|
|
})
|
|
cy.screenshot('va-parmap-banner', { capture: 'viewport' })
|
|
// Nothing was filtered: every row is still there.
|
|
cy.get('h4').should('contain.text', '(7 rows')
|
|
},
|
|
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN'
|
|
)
|
|
})
|
|
|
|
it('6 | warns straight away when a mapping entry cannot be parsed', () => {
|
|
// No separator, so the entry never becomes a mapping - reported before any
|
|
// VA message arrives.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'it needs a "-" between the VA parameter and the column name'
|
|
)
|
|
},
|
|
'va_parmap=DROPDOWN_TAB1'
|
|
)
|
|
})
|
|
|
|
it('7 | warns when a mapping names a parameter VA is not sending', () => {
|
|
// Parameter-side typo: the mapping never applies, so without the banner this
|
|
// is indistinguishable from a working filter over every row.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'names VA parameter "dropdown_tab_1", which VA is not sending'
|
|
)
|
|
cy.get('h4').should('contain.text', '(7 rows')
|
|
},
|
|
'va_parmap=DROPDOWN_TAB_1-SOME_DROPDOWN'
|
|
)
|
|
})
|
|
|
|
it('8 | applies a mapping from the buffered message on the very first load', () => {
|
|
// VA posts once on iframe load, before the app bootstraps, so the first
|
|
// getdata is already filtered from that buffered message - with no column
|
|
// metadata available, the mapping is what names the column.
|
|
visitWithBufferedMessage(
|
|
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN',
|
|
{
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
},
|
|
() => {
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
"SOME_DROPDOWN = 'Option 2'"
|
|
)
|
|
cy.get('h4').should('contain.text', '(2 rows')
|
|
cy.url().should('match', /\/editor\/[^/]+\/\d+/)
|
|
}
|
|
)
|
|
})
|
|
|
|
it('9 | a bad mapping on the first load still ends at the banner', () => {
|
|
// The deferred first load cannot check the column against metadata, so its
|
|
// saveQuery may fail and fall back to an unfiltered load - the banner is
|
|
// what stops that reading as a working filter.
|
|
visitWithBufferedMessage(
|
|
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN',
|
|
{
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
},
|
|
() => {
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'NOT_A_COLUMN'
|
|
)
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
|
// Deliberately no row-count assertion: this path cannot validate the
|
|
// column (no metadata yet), so the outcome depends on the platform - on
|
|
// SAS, mp_filtercheck aborts the save and the load falls back to
|
|
// unfiltered; the JS mock stores the filter and returns 0 rows. The
|
|
// banner is the part that must hold either way, because both outcomes
|
|
// are otherwise indistinguishable from a working filter.
|
|
}
|
|
)
|
|
})
|
|
|
|
it('10 | warns when two parameters filter the same column (an empty grid)', () => {
|
|
// Clauses are AND-ed, so two different values on one column produce no rows
|
|
// at all - the last silent-empty-grid path. Reported rather than rewritten,
|
|
// so the author decides whether to use one parameter carrying both values.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage(
|
|
{
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 1'
|
|
},
|
|
{
|
|
name: 'pr2',
|
|
label: 'DROPDOWN_TAB2',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
}
|
|
)
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'combined with AND'
|
|
)
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB2')
|
|
// The consequence, deterministic on both SAS and the mock: rows must
|
|
// match both values, so there are none.
|
|
cy.get('h4').should('contain.text', '(0 rows')
|
|
},
|
|
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN&va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
|
|
)
|
|
})
|
|
|
|
it('12 | a transform adds the previous quarter to the filter', () => {
|
|
// ADD(QUARTER_START,-1) is the reported requirement: one clause carrying the
|
|
// selected date and the same point one quarter earlier. 2026-10-01 = 24380,
|
|
// 2026-07-01 = 24288 in SAS days; nothing in the fixture matches either, so
|
|
// the assertion is on the clause rather than the row count.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'SELECTED_DATE',
|
|
dataType: 'string',
|
|
value: '01OCT2026'
|
|
})
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'SOME_DATE IN ('
|
|
)
|
|
cy.get('.infoBar-text').should('contain.text', '24380')
|
|
cy.get('.infoBar-text').should('contain.text', '24288')
|
|
cy.get('h4').should('contain.text', '(0 rows')
|
|
},
|
|
'va_parmap=SELECTED_DATE-SOME_DATE:ADD(QUARTER_START,-1)'
|
|
)
|
|
})
|
|
|
|
it('13 | warns when a transform lands on a column it cannot apply to', () => {
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'SELECTED_DATE',
|
|
dataType: 'string',
|
|
value: 'x'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'is not a date or datetime column'
|
|
)
|
|
cy.get('h4').should('contain.text', '(7 rows')
|
|
},
|
|
'va_parmap=SELECTED_DATE-SOME_CHAR:ADD(QUARTER_START,-1)'
|
|
)
|
|
})
|
|
|
|
it('11 | logs a parse-time problem once, however many messages arrive', () => {
|
|
// The parse problem is logged at app init; the editor's own loop must not log
|
|
// it again. The second entry is valid-but-missing, so the loop definitely runs
|
|
// and logs its own line - which is what makes this a real assertion.
|
|
cy.visit(
|
|
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&va_parmap=DROPDOWN_TAB1&va_parmap=DROPDOWN_TAB2-NOT_A_COLUMN`,
|
|
{
|
|
onBeforeLoad(win: any) {
|
|
win.__warns = []
|
|
const original = win.console.warn.bind(win.console)
|
|
win.console.warn = (...args: any[]) => {
|
|
win.__warns.push(args.join(' '))
|
|
original(...args)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
|
'not.exist'
|
|
)
|
|
pushVaMessage({
|
|
name: 'pr2',
|
|
label: 'DROPDOWN_TAB2',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'NOT_A_COLUMN'
|
|
)
|
|
cy.window().then((win: any) => {
|
|
const warns: string[] = win.__warns || []
|
|
const parseLines = warns.filter((w) =>
|
|
w.includes('va_parmap "DROPDOWN_TAB1"')
|
|
)
|
|
const runtimeLines = warns.filter((w) => w.includes('NOT_A_COLUMN'))
|
|
expect(parseLines.length, warns.join(' | ')).to.eq(1)
|
|
expect(runtimeLines.length, warns.join(' | ')).to.eq(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Loads the editor with a VA message already in the pre-bootstrap buffer, the
|
|
* way VA's own postMessage arrives before Angular starts. A distinct pathname is
|
|
* used so this is a real document load: a cy.visit that differs only in the hash
|
|
* is a same-document navigation and would not re-bootstrap the app.
|
|
*/
|
|
const visitWithBufferedMessage = (
|
|
extraParams: string,
|
|
parameter: any,
|
|
callback?: any
|
|
) => {
|
|
cy.visit(
|
|
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&${extraParams}`,
|
|
{
|
|
onBeforeLoad(win: any) {
|
|
win.__vaLastMessage = {
|
|
t: Date.now(),
|
|
origin: win.location.origin,
|
|
data: {
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters: [parameter]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
|
if (callback) callback()
|
|
}
|
|
|
|
/** Pushes one VA data-driven content message carrying the given parameters. */
|
|
const pushVaMessage = (...parameters: any[]) => {
|
|
cy.window().then((win) => {
|
|
win.postMessage(
|
|
{
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters
|
|
},
|
|
'*'
|
|
)
|
|
})
|
|
}
|
|
|
|
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
|
|
// re-visits the same route with ?embed=va so the app parses VA embed mode.
|
|
// `extraParams` is appended to that query string (e.g. va_parmap entries).
|
|
const openEditorInVaMode = (
|
|
tablename: string,
|
|
callback?: any,
|
|
extraParams?: string
|
|
) => {
|
|
openTableFromTree(libraryToOpenIncludes, tablename)
|
|
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.then(() => {
|
|
cy.url().then((url) => {
|
|
const separator = url.includes('?') ? '&' : '?'
|
|
const extra = extraParams ? `&${extraParams}` : ''
|
|
cy.visit(`${url}${separator}embed=va${extra}`)
|
|
// A cy.visit that differs from the current URL only in the hash is a
|
|
// same-document navigation, so the app is not re-bootstrapped and the
|
|
// embed parameter (read once at app init) never takes effect. Reload to
|
|
// bootstrap it, as the VA iframe load does.
|
|
cy.reload()
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
|
'not.exist'
|
|
)
|
|
if (callback) callback()
|
|
})
|
|
})
|
|
}
|
|
|
|
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 > button').click()
|
|
|
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
|
for (let innerNode of innerNodes) {
|
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
|
innerNode.click()
|
|
break
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
const visitPage = (url: string) => {
|
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
}
|