Merge pull request 'feat(mocks): record public/viewdata and bring the viewer payload to parity' (#492) from feat/mock-viewdata-parity into release/7.23
Sites / deploy (push) Successful in 3m59s
Build / Build-and-ng-test (pull_request) Successful in 4m3s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m7s
Build / Build-and-test-development (pull_request) Canceled after 7m20s

Reviewed-on: #492
This commit was merged in pull request #492.
This commit is contained in:
allan committed 2026-11-07 10:45:44 +00:00
commit 15eb7c1121
6 files changed
+1168 -20

No files matched your search

+149 -20
View File
@@ -21,6 +21,89 @@ const maxObsWebView = dcConfigNumber(
2000
)
/**
* The text SAS puts in the viewer for a temporal column.
*
* Taken from the recorded response (golden/public/viewdata/mpe_tables.json,
* `_meta.sas` 9.4M9 - a value only a SAS runtime emits, see the Provenance
* section of parity/README.md), not from the mock's own output: the estate answers "01JAN60:00:00:00.00" for a datetime - a two-digit year and
* a two-decimal fraction - so the renderer matches that rather than the format
* label the column carries, which is what the recorded response shows for a
* column whose format reads DATETIME19.3.
*/
const VIEW_MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
function pad2(value) {
return String(value).padStart(2, '0')
}
function pad4(value) {
return String(value).padStart(4, '0')
}
function viewDateTime(seconds) {
const whole = Math.floor(seconds)
const d = new Date(SAS_EPOCH_MS + whole * 1000)
// two printed digits, so a fraction that rounds up must not carry into them
const fraction = Math.min(99, Math.round((seconds - whole) * 100))
return (
pad2(d.getUTCDate()) + VIEW_MONTHS[d.getUTCMonth()] +
String(d.getUTCFullYear()).slice(2) + ':' +
pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes()) + ':' +
pad2(d.getUTCSeconds()) + '.' + pad2(fraction)
)
}
function viewDate(seconds) {
// whole days, rounded as the text branch rounds, so a stored number and a
// stored ISO string of the same instant land on the same day
const d = new Date(SAS_EPOCH_MS + Math.round(seconds) * 86400000)
// DATE9. is four digits wide, so a year below 1000 is zero-padded
return pad2(d.getUTCDate()) + VIEW_MONTHS[d.getUTCMonth()] + pad4(d.getUTCFullYear())
}
function viewTime(seconds) {
const whole = Math.floor(seconds)
const fraction = Math.min(99, Math.round((seconds - whole) * 100))
return (
pad2(Math.floor(whole / 3600)) + ':' +
pad2(Math.floor((whole % 3600) / 60)) + ':' +
pad2(whole % 60) + '.' + pad2(fraction)
)
}
/** A value as the viewer shows it, whatever the row stored. */
function viewCell(value, ddtype) {
if (value === undefined || value === null || value === '') return null
let seconds = value
if (typeof value !== 'number') {
// the mock stores a datetime as ISO text in some rows; a text without a
// zone is SAS's own naive value, so it is read as UTC rather than in
// whatever zone the host happens to sit in
const text = String(value)
const zoned = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(text) ? text : text + 'Z'
const parsed = Date.parse(zoned)
if (isNaN(parsed)) return String(value)
if (ddtype === 'DATE') {
seconds = Math.round((parsed - SAS_EPOCH_MS) / 86400000)
} else if (ddtype === 'TIME') {
// a time is seconds since midnight, so the day the text falls on is
// discarded - an ISO '1970-01-01T01:01:01' is 3661, not a datetime
seconds = (((parsed % 86400000) + 86400000) % 86400000) / 1000
} else {
seconds = (parsed - SAS_EPOCH_MS) / 1000
}
}
if (ddtype === 'DATETIME') return viewDateTime(Math.round(seconds * 100) / 100)
if (ddtype === 'DATE') return viewDate(seconds)
// rounded to two decimals on entry, as the DATETIME branch does: the
// fraction is printed to two digits, and an unrounded 3661.9999 would carry
// 100 into it ("01:01:01.100")
if (ddtype === 'TIME') return viewTime(Math.round(seconds * 100) / 100)
return String(value)
}
// ─── Parse input ──────────────────────────────────────────────────────────────
const _sctRow = fetchTable('SASControlTable')[0] || {}
@@ -109,6 +192,10 @@ if (mpeTables && mpeTables.rows) {
const reg = mpeTables.rows.find(r => r.libref === libref && r.dsn === dsn)
if (reg && reg.buskey) {
pkFields = reg.buskey
// viewdata reports the validity column ahead of the business key: it is
// part of what identifies the current version of a row
const validity = String(colVal(reg, 'VAR_TXTO') || '').trim()
if (validity) pkFields = validity.toUpperCase() + ' ' + pkFields
}
}
if (dsn.toUpperCase() === 'MPE_AUDIT' && pkFields.trim() === '') {
@@ -123,15 +210,34 @@ let nobs = 0
if (tableData && tableData.columns) {
// mpGetcols, the JS counterpart of %mp_getcols.sas - the macro viewdata.sas
// calls for its own column list.
// the data dictionary supplies a column's business label, as it does for
// getdata - viewdata.sas augments mp_getcols the same way
const dictionaryData = loadMpeTable('MPE_DATADICTIONARY')
const dictionary = {}
if (dictionaryData && dictionaryData.rows) {
const prefix = (libref + '.' + dsn + '.').toUpperCase()
for (const row of dictionaryData.rows) {
const source = String(colVal(row, 'DD_SOURCE') || '').toUpperCase()
if (String(colVal(row, 'DD_TYPE') || '').toUpperCase() !== 'COLUMN') continue
if (source.indexOf(prefix) !== 0) continue
dictionary[source.slice(prefix.length)] = colVal(row, 'DD_SHORTDESC')
}
}
cols = mpGetcols(physicalColumns).map(c => {
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
const formatStr = c.TYPE === 'N' ? (c.FORMAT || '8.') : ('$' + c.LENGTH + '.')
const formatStr = c.TYPE === 'N'
? String(c.FORMAT || '8.').toUpperCase()
: ('$' + c.LENGTH + '.')
const label = dictionary[String(c.NAME).toUpperCase()] || c.LABEL || c.NAME
return {
NAME: c.NAME, TYPE: c.TYPE, LENGTH: c.LENGTH,
FORMAT: formatStr, FMTNAME: c.FMTNAME || ' ', DDTYPE: ddtype,
LABEL: c.LABEL
LABEL: label
}
})
// the estate answers the column list in name order
cols.sort((a, b) => (a.NAME < b.NAME ? -1 : a.NAME > b.NAME ? 1 : 0))
// Rows in scope: the stored filter is applied first, then the search
// (matching viewdata.sas, which searches a filtered view of the table).
@@ -167,23 +273,10 @@ if (tableData && tableData.columns) {
}
const ddtype = getDdType(col)
if (val !== undefined && val !== null && val !== '') {
if (typeof val === 'number') {
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
val = viewCell(val, ddtype)
} else if (typeof val === 'number') {
val = formatSasValue(val, ddtype)
} else {
// Try converting ISO/old-format strings to SAS display format
if (ddtype === 'DATETIME') {
const m = String(val).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
if (m) {
const mn = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
val = `${parseInt(m[3])}${mn[parseInt(m[2])-1]}${m[1].slice(2)}:${m[4]}:${m[5]}:${m[6]}`
}
} else if (ddtype === 'DATE') {
const m = String(val).match(/^(\d{4})-(\d{2})-(\d{2})/)
if (m) {
const mn = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
val = `${parseInt(m[3])}${mn[parseInt(m[2])-1]}${m[1]}`
}
}
}
}
/*
@@ -196,7 +289,18 @@ if (tableData && tableData.columns) {
if (val === undefined || val === null || val === '') {
row[col.name] = col.type === 'N' ? '.' : ' '
} else {
row[col.name] = String(val)
/*
Only trailing blanks are noise - the width SAS pads to - and
only blanks, not every whitespace character: a value that ends
in a tab is not padded, and stripping it would change a value
the estate keeps. A leading blank is part of the value and the
viewer shows it.
val is a string by now: the branches above hand it through
viewCell or formatSasValue, both of which return text.
*/
const text = String(val).replace(/ +$/, '')
row[col.name] = text === '' ? ' ' : text
}
}
return row
@@ -217,9 +321,34 @@ if (tableData && tableData.columns) {
}
// $viewdata vars: keep original SAS types (num stays num, char stays char)
/*
The viewer's metadata is the column's own: a datetime arrives as num, 8
bytes, DATETIME19.3. sasVarsEntry describes the *edit grid*, where the
temporal columns have been converted to character, so it is not used here.
*/
/*
The two label members are deliberately different, and the recorded
response proves it: cols[].LABEL for DSN is "Dataset Name to be edited"
(the data dictionary's business label, which viewdata.sas augments in as
it does for getdata) while $viewdata.vars.DSN.label is "DSN", the physical
label. Unifying them would look tidier and would stop matching the estate.
*/
vars = {}
for (const col of physicalColumns) {
vars[col.name] = sasVarsEntry(col)
const isTemporal = ['DATE', 'DATETIME', 'TIME'].indexOf(getDdType(col)) >= 0
vars[col.name] = {
format: isTemporal
? String(
col.format ||
(['DATE', 'TIME'].indexOf(getDdType(col)) >= 0
? (getDdType(col) === 'DATE' ? 'DATE9.' : 'TIME8.')
: 'DATETIME19.3')
).toUpperCase()
: (col.type === 'N' ? (col.format || 'best.') : ('$' + col.length + '.')),
label: col.label || col.name,
length: String(col.length),
type: col.type === 'N' ? 'num' : 'char'
}
}
}
@@ -0,0 +1,833 @@
{
"_meta": {
"scenario": "mpe_tables",
"service": "public/viewdata",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "9.4M9",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"cls_rules",
"cols",
"query",
"sasparams",
"versions",
"viewdata",
"$viewdata"
]
},
"body": {
"cls_rules": [],
"cols": [
{
"NAME": "AUDIT_LIBDS",
"TYPE": "C",
"LENGTH": 41,
"FORMAT": "$41.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "AUDIT_LIBDS"
},
{
"NAME": "BUSKEY",
"TYPE": "C",
"LENGTH": 1000,
"FORMAT": "$1000.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "BUSKEY"
},
{
"NAME": "CLOSE_VARS",
"TYPE": "C",
"LENGTH": 500,
"FORMAT": "$500.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "CLOSE_VARS"
},
{
"NAME": "DSN",
"TYPE": "C",
"LENGTH": 32,
"FORMAT": "$32.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "Dataset Name to be edited"
},
{
"NAME": "LIBREF",
"TYPE": "C",
"LENGTH": 8,
"FORMAT": "$8.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "LIBREF"
},
{
"NAME": "LOADTYPE",
"TYPE": "C",
"LENGTH": 12,
"FORMAT": "$12.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "LOADTYPE"
},
{
"NAME": "NOTES",
"TYPE": "C",
"LENGTH": 1000,
"FORMAT": "$1000.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "NOTES"
},
{
"NAME": "NUM_OF_APPROVALS_REQUIRED",
"TYPE": "N",
"LENGTH": 8,
"FORMAT": "8.",
"FMTNAME": " ",
"DDTYPE": "N",
"LABEL": "NUM_OF_APPROVALS_REQUIRED"
},
{
"NAME": "POST_APPROVE_HOOK",
"TYPE": "C",
"LENGTH": 200,
"FORMAT": "$200.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "POST_APPROVE_HOOK"
},
{
"NAME": "POST_EDIT_HOOK",
"TYPE": "C",
"LENGTH": 200,
"FORMAT": "$200.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "POST_EDIT_HOOK"
},
{
"NAME": "PRE_APPROVE_HOOK",
"TYPE": "C",
"LENGTH": 200,
"FORMAT": "$200.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "PRE_APPROVE_HOOK"
},
{
"NAME": "PRE_EDIT_HOOK",
"TYPE": "C",
"LENGTH": 200,
"FORMAT": "$200.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "PRE_EDIT_HOOK"
},
{
"NAME": "RK_UNDERLYING",
"TYPE": "C",
"LENGTH": 1000,
"FORMAT": "$1000.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "RK_UNDERLYING"
},
{
"NAME": "SIGNOFF_COLS",
"TYPE": "C",
"LENGTH": 500,
"FORMAT": "$500.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "SIGNOFF_COLS"
},
{
"NAME": "SIGNOFF_HOOK",
"TYPE": "C",
"LENGTH": 200,
"FORMAT": "$200.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "SIGNOFF_HOOK"
},
{
"NAME": "TX_FROM",
"TYPE": "N",
"LENGTH": 8,
"FORMAT": "DATETIME19.3",
"FMTNAME": "DATETIME",
"DDTYPE": "DATETIME",
"LABEL": "TX_FROM"
},
{
"NAME": "TX_TO",
"TYPE": "N",
"LENGTH": 8,
"FORMAT": "DATETIME19.3",
"FMTNAME": "DATETIME",
"DDTYPE": "DATETIME",
"LABEL": "TX_TO"
},
{
"NAME": "VAR_BUSFROM",
"TYPE": "C",
"LENGTH": 32,
"FORMAT": "$32.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "VAR_BUSFROM"
},
{
"NAME": "VAR_BUSTO",
"TYPE": "C",
"LENGTH": 32,
"FORMAT": "$32.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "VAR_BUSTO"
},
{
"NAME": "VAR_PROCESSED",
"TYPE": "C",
"LENGTH": 32,
"FORMAT": "$32.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "VAR_PROCESSED"
},
{
"NAME": "VAR_TXFROM",
"TYPE": "C",
"LENGTH": 32,
"FORMAT": "$32.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "VAR_TXFROM"
},
{
"NAME": "VAR_TXTO",
"TYPE": "C",
"LENGTH": 32,
"FORMAT": "$32.",
"FMTNAME": " ",
"DDTYPE": "C",
"LABEL": "VAR_TXTO"
}
],
"query": [],
"sasparams": [
{
"TABLEURI": " ",
"TABLENAME": "MPE_TABLES",
"FILTER_TEXT": " ",
"PK_FIELDS": "TX_TO LIBREF DSN",
"NOBS": 19,
"VARS": 22,
"MAXROWS": 2000
}
],
"versions": [],
"viewdata": [
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_COLUMN_LEVEL_SECURITY",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "CLS_SCOPE CLS_GROUP CLS_LIBREF CLS_TABLE CLS_VARIABLE_NM",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_column_level_security_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Docs: <URL>",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_XLMAP_INFO",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "XLMAP_ID",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_xlmap_info_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Docs: <URL>",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_XLMAP_RULES",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "XLMAP_ID XLMAP_RANGE_ID",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_xlmap_rules_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Docs: <URL>",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_XLMAP_DATA",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "UPDATE",
"BUSKEY": "LOAD_REF XLMAP_ID XLMAP_RANGE_ID ROW_NO COL_NO",
"VAR_TXFROM": " ",
"VAR_TXTO": " ",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Docs: <URL>",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_LOCKANYTABLE",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "UPDATE",
"BUSKEY": "LOCK_LIB LOCK_DS",
"VAR_TXFROM": " ",
"VAR_TXTO": " ",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "This table may be edited when a process failed and left a lock",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_TABLES",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "LIBREF DSN",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_tables_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "This entry allows the MP Editor to edit itself!",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_SECURITY",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "LIBREF DSN ACCESS_LEVEL SAS_GROUP",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_security_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Determines which groups can view/edit/approve which tables",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_SELECTBOX",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "SELECTBOX_RK",
"VAR_TXFROM": "VER_FROM_DTTM",
"VAR_TXTO": "VER_TO_DTTM",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Can configure dropdowns for the front end",
"RK_UNDERLYING": "SELECT_LIB SELECT_DS BASE_COLUMN SELECTBOX_VALUE",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_X_TEST",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "UPDATE",
"BUSKEY": "PRIMARY_KEY_FIELD",
"VAR_TXFROM": " ",
"VAR_TXTO": " ",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Test table for controller",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_EMAILS",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "USER_NAME",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Primary Emails Table (backup is metadata)",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_CONFIG",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "VAR_SCOPE VAR_NAME",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration variables for Data Controller",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_ALERTS",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "ALERT_EVENT ALERT_LIB ALERT_DS ALERT_USER",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration for alert email events",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_GROUPS",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "GROUP_NAME USER_NAME",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration for additional groups within Data Controller",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_VALIDATIONS",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "BASE_LIB BASE_DS BASE_COL RULE_TYPE",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_validations_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration of data quality rules in Editor component",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_DATADICTIONARY",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "DD_TYPE DD_SOURCE",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration of data dictionary",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_DERIVED",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "LIBREF DSN VAR_NM",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_derived_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Derived (computed) variables for the EDIT screen",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_EXCEL_CONFIG",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "XL_LIBREF XL_TABLE XL_COLUMN",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration of the excel import rules",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_ROW_LEVEL_SECURITY",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "TXTEMPORAL",
"BUSKEY": "RLS_RK",
"VAR_TXFROM": "TX_FROM",
"VAR_TXTO": "TX_TO",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": "services/hooks/mpe_row_level_security_postedit",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Configuration of Row Level Security",
"RK_UNDERLYING": "RLS_SCOPE RLS_GROUP RLS_LIBREF RLS_TABLE RLS_GROUP_LOGIC RLS_SUBGROUP_LOGIC RLS_SUBGROUP_ID RLS_VARIABLE_NM RLS_OPERATOR_NM RLS_RAW_VALUE",
"AUDIT_LIBDS": " "
},
{
"TX_FROM": "01JAN60:00:00:00.00",
"TX_TO": "31DEC99:23:59:59.00",
"LIBREF": "<LIBREF>",
"DSN": "MPE_X_CATALOG-FC",
"NUM_OF_APPROVALS_REQUIRED": "1",
"LOADTYPE": "FORMAT_CAT",
"BUSKEY": "TYPE FMTNAME FMTROW",
"VAR_TXFROM": " ",
"VAR_TXTO": " ",
"VAR_BUSFROM": " ",
"VAR_BUSTO": " ",
"VAR_PROCESSED": " ",
"CLOSE_VARS": " ",
"PRE_EDIT_HOOK": " ",
"POST_EDIT_HOOK": " ",
"PRE_APPROVE_HOOK": " ",
"POST_APPROVE_HOOK": " ",
"SIGNOFF_COLS": " ",
"SIGNOFF_HOOK": " ",
"NOTES": "Sample Format Catalog",
"RK_UNDERLYING": " ",
"AUDIT_LIBDS": " "
}
],
"$viewdata": {
"vars": {
"TX_FROM": {
"format": "DATETIME19.3",
"label": "TX_FROM",
"length": "8",
"type": "num"
},
"TX_TO": {
"format": "DATETIME19.3",
"label": "TX_TO",
"length": "8",
"type": "num"
},
"LIBREF": {
"format": "$8.",
"label": "LIBREF",
"length": "8",
"type": "char"
},
"DSN": {
"format": "$32.",
"label": "DSN",
"length": "32",
"type": "char"
},
"NUM_OF_APPROVALS_REQUIRED": {
"format": "best.",
"label": "NUM_OF_APPROVALS_REQUIRED",
"length": "8",
"type": "num"
},
"LOADTYPE": {
"format": "$12.",
"label": "LOADTYPE",
"length": "12",
"type": "char"
},
"BUSKEY": {
"format": "$1000.",
"label": "BUSKEY",
"length": "1000",
"type": "char"
},
"VAR_TXFROM": {
"format": "$32.",
"label": "VAR_TXFROM",
"length": "32",
"type": "char"
},
"VAR_TXTO": {
"format": "$32.",
"label": "VAR_TXTO",
"length": "32",
"type": "char"
},
"VAR_BUSFROM": {
"format": "$32.",
"label": "VAR_BUSFROM",
"length": "32",
"type": "char"
},
"VAR_BUSTO": {
"format": "$32.",
"label": "VAR_BUSTO",
"length": "32",
"type": "char"
},
"VAR_PROCESSED": {
"format": "$32.",
"label": "VAR_PROCESSED",
"length": "32",
"type": "char"
},
"CLOSE_VARS": {
"format": "$500.",
"label": "CLOSE_VARS",
"length": "500",
"type": "char"
},
"PRE_EDIT_HOOK": {
"format": "$200.",
"label": "PRE_EDIT_HOOK",
"length": "200",
"type": "char"
},
"POST_EDIT_HOOK": {
"format": "$200.",
"label": "POST_EDIT_HOOK",
"length": "200",
"type": "char"
},
"PRE_APPROVE_HOOK": {
"format": "$200.",
"label": "PRE_APPROVE_HOOK",
"length": "200",
"type": "char"
},
"POST_APPROVE_HOOK": {
"format": "$200.",
"label": "POST_APPROVE_HOOK",
"length": "200",
"type": "char"
},
"SIGNOFF_COLS": {
"format": "$500.",
"label": "SIGNOFF_COLS",
"length": "500",
"type": "char"
},
"SIGNOFF_HOOK": {
"format": "$200.",
"label": "SIGNOFF_HOOK",
"length": "200",
"type": "char"
},
"NOTES": {
"format": "$1000.",
"label": "NOTES",
"length": "1000",
"type": "char"
},
"RK_UNDERLYING": {
"format": "$1000.",
"label": "RK_UNDERLYING",
"length": "1000",
"type": "char"
},
"AUDIT_LIBDS": {
"format": "$41.",
"label": "AUDIT_LIBDS",
"length": "41",
"type": "char"
}
}
},
"SYSCC": "0",
"SYSENCODING": "utf-8"
}
}
+32
View File
@@ -103,6 +103,38 @@
"reason": "SAS lists SASHELP, the library it installs for itself; the mock estate has only the control library, so it has one row where the estate has two"
}
]
},
{
"id": "mpe_tables",
"service": "public/viewdata",
"params": {},
"input": {
"table": "SASControlTable",
"rows": [
{
"LIBDS": "{{LIBREF}}.MPE_TABLES"
}
]
},
"mockInput": {
"table": "SASControlTable",
"rows": [
{
"LIBDS": "DC_JSLIB.MPE_TABLES"
}
]
},
"members": [
"cls_rules",
"cols",
"query",
"sasparams",
"versions",
"viewdata",
"$viewdata"
],
"note": "the viewer's load: the rows as SAS prints them, the column list the grid is built from and the formats behind it. dsmeta is excluded for the same reason as getdata - it is the engine's report on the dataset",
"accepted": []
}
]
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Character cells and labels in the viewer's payload.
*
* Two rules the golden fixes, both of which look like bugs from the inside and
* are parity from the outside. The golden is a live capture -
* parity/golden/public/viewdata/mpe_tables.json, `_meta.sas` 9.4M9, a value only
* a SAS runtime emits (see the Provenance section of parity/README.md) - so
* these assertions compare the mock with the estate, not with itself.
*/
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { seededEstate, runService } = require('./harness')
function viewOf(estate, libds) {
const body = JSON.parse(
runService(estate, 'public/viewdata', {
tableName: 'SASControlTable',
rows: [{ LIBDS: libds, FILTER_RK: -1 }]
})
)
assert.equal(body.sasjsAbort, undefined)
return body
}
test('a character cell keeps a leading blank and loses only the padding', () => {
const estate = seededEstate({ demo: false })
const table = {
columns: [{ name: 'C', type: 'char', length: 10 }],
rows: [{ C: ' ABC' }, { C: 'ABC ' }, { C: ' ' }]
}
fs.writeFileSync(
path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_x_cells.json'),
JSON.stringify(table)
)
const rows = viewOf(estate, 'DC_JSLIB.MPE_X_CELLS').viewdata
// a leading blank is part of the value, and the viewer shows it
assert.equal(rows[0].C, ' ABC')
// trailing blanks are the width SAS pads to
assert.equal(rows[1].C, 'ABC')
// an all-blank cell is SAS missing
assert.equal(rows[2].C, ' ')
})
test('cols and $viewdata report the labels the estate reports', () => {
// the recorded response has cols[].LABEL for DSN as the data dictionary's
// business label and $viewdata.vars.DSN.label as the physical label - two
// members, two answers, and the estate is the authority
const body = viewOf(seededEstate({ demo: false }), 'DC_JSLIB.MPE_TABLES')
const dsn = body.cols.find((c) => c.NAME === 'DSN')
assert.equal(dsn.LABEL, 'Dataset Name to be edited')
assert.equal(body.$viewdata.vars.DSN.label, 'DSN')
})
+81
View File
@@ -0,0 +1,81 @@
/**
* How the viewer renders a temporal column.
*
* viewdata hands the client text, not numbers: the recorded golden (a live
* capture, not the mock's own output - see the Provenance section of
* parity/README.md) shows
* 01JAN1960, 01JAN60:00:00:00.00 and a two-decimal time, whatever format label
* the column carries. These cases pin the edges the control library has no
* column for - a DATE, a TIME, a year below 1000, and a stored ISO string.
*/
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { seededEstate, runService } = require('./harness')
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
const daySeconds = (iso) => {
const parts = iso.split('-').map(Number)
return (Date.UTC(parts[0], parts[1] - 1, parts[2]) - SAS_EPOCH_MS) / 86400000
}
function view(rows) {
const estate = seededEstate({ demo: false })
const table = {
columns: [
{ name: 'D', type: 'num', length: 8, format: 'DATE9.' },
{ name: 'T', type: 'num', length: 8, format: 'TIME8.' },
{ name: 'DT', type: 'num', length: 8, format: 'DATETIME19.3' }
],
rows
}
const file = path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_x_temporal.json')
fs.writeFileSync(file, JSON.stringify(table))
const body = JSON.parse(
runService(estate, 'public/viewdata', {
tableName: 'SASControlTable',
rows: [{ LIBDS: 'DC_JSLIB.MPE_X_TEMPORAL', FILTER_RK: -1 }]
})
)
assert.equal(body.sasjsAbort, undefined)
return body.viewdata
}
test('a date is day-month-year with a four-digit year', () => {
const rows = view([{ D: 0, T: 0, DT: 0 }, { D: daySeconds('0999-01-01'), T: 0, DT: 0 }])
assert.equal(rows[0].D, '01JAN1960')
// DATE9. is four digits wide: a year below 1000 is zero-padded, not printed
// as 999 and read back as a stray width
assert.equal(rows[1].D, '01JAN0999')
})
test('a time is hours-minutes-seconds with two decimals', () => {
const rows = view([{ D: 0, T: 3661, DT: 0 }, { D: 0, T: 3661.994, DT: 0 }])
assert.equal(rows[0].T, '01:01:01.00')
// the fraction is printed to two digits, and the value is rounded before it
// reaches the printer, so a stray third digit cannot appear as ".100"
assert.equal(rows[1].T, '01:01:01.99')
})
test('a datetime is a two-digit year with a two-decimal fraction', () => {
const seconds = (Date.UTC(2020, 1, 29, 23, 59, 59) - SAS_EPOCH_MS) / 1000
const rows = view([{ D: 0, T: 0, DT: 0 }, { D: 0, T: 0, DT: seconds + 0.25 }])
assert.equal(rows[0].DT, '01JAN60:00:00:00.00')
assert.equal(rows[1].DT, '29FEB20:23:59:59.25')
})
test('a stored ISO string is read as a temporal value, not echoed', () => {
const rows = view([{ D: '1960-01-02', T: '1970-01-01T01:01:01', DT: '2020-02-29T23:59:59.25' }])
assert.equal(rows[0].D, '02JAN1960')
// a time is seconds since midnight: the day the text carries is discarded
assert.equal(rows[0].T, '01:01:01.00')
// a text with no zone is SAS's own naive value, read as UTC
assert.equal(rows[0].DT, '29FEB20:23:59:59.25')
})
+15
View File
@@ -50,3 +50,18 @@ test('an empty table sends one row of SAS missings, one per column type', () =>
assert.equal(row.LIBREF, ' ', 'a character column is a space')
assert.equal(row.PROCESSED_DTTM, '.', 'a numeric column is a dot')
})
test('a datetime is rendered the way the estate renders it', () => {
const estate = seededEstate({ demo: false })
const body = JSON.parse(
runService(estate, 'public/viewdata', {
tableName: 'SASControlTable',
rows: [{ LIBDS: 'DC_JSLIB.MPE_TABLES' }]
})
)
// two-digit year and a two-decimal fraction, which is what the recorded
// response carries for a column whose format label reads DATETIME19.3
assert.equal(body.viewdata[0].TX_FROM, '01JAN60:00:00:00.00')
assert.equal(body.viewdata[0].TX_TO, '31DEC99:23:59:59.00')
})