fhirsmith 0.13.4 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/testing/store.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SQLite storage for received TestReports.
|
|
9
|
+
*
|
|
10
|
+
* Each report is kept whole, as the JSON that was stored (with the server's id and
|
|
11
|
+
* meta.lastUpdated), alongside the columns the searches use. Reports are never edited
|
|
12
|
+
* once received, so there is no versioning. The submitter's IP address is recorded for
|
|
13
|
+
* tracing abuse, and is never returned by anything in this module.
|
|
14
|
+
*
|
|
15
|
+
* @module testing/store
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const Database = require('better-sqlite3');
|
|
19
|
+
const { dateRange } = require('./search');
|
|
20
|
+
|
|
21
|
+
const SCHEMA = `
|
|
22
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
received TEXT NOT NULL, -- meta.lastUpdated
|
|
25
|
+
received_ms INTEGER NOT NULL,
|
|
26
|
+
ip TEXT, -- never shared
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
name_lc TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL,
|
|
30
|
+
result TEXT NOT NULL,
|
|
31
|
+
score REAL,
|
|
32
|
+
tester TEXT NOT NULL,
|
|
33
|
+
tester_lc TEXT NOT NULL,
|
|
34
|
+
test_script TEXT,
|
|
35
|
+
issued TEXT NOT NULL,
|
|
36
|
+
issued_lo INTEGER NOT NULL,
|
|
37
|
+
issued_hi INTEGER NOT NULL,
|
|
38
|
+
size INTEGER NOT NULL,
|
|
39
|
+
run_ms INTEGER, -- how long the run took, when the report says
|
|
40
|
+
test_count INTEGER, -- how many entries in TestReport.test
|
|
41
|
+
json TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS participants (
|
|
45
|
+
report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
46
|
+
seq INTEGER NOT NULL,
|
|
47
|
+
type TEXT,
|
|
48
|
+
uri TEXT NOT NULL,
|
|
49
|
+
version TEXT,
|
|
50
|
+
display TEXT
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- people who can maintain the named links and delete reports. The administrator is not
|
|
54
|
+
-- in here: their password is in the configuration
|
|
55
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
login TEXT UNIQUE NOT NULL,
|
|
58
|
+
name TEXT NOT NULL,
|
|
59
|
+
password_hash TEXT NOT NULL,
|
|
60
|
+
can_edit_links INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
can_delete_reports INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
created TEXT NOT NULL
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- names (and links) shown in place of known canonical URLs
|
|
66
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
canonical TEXT UNIQUE NOT NULL, -- without any |version
|
|
69
|
+
name TEXT NOT NULL,
|
|
70
|
+
link TEXT
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_ms);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_reports_name ON reports(name_lc);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_reports_tester ON reports(tester_lc);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_reports_result ON reports(result);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_reports_score ON reports(score);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_reports_script ON reports(test_script);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_reports_issued ON reports(issued_lo, issued_hi);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_participants_report ON participants(report_id);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_participants_uri ON participants(uri);
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* TestReport.testScript is a canonical in R5 and a Reference in R4; either way we want
|
|
87
|
+
* the URL.
|
|
88
|
+
*/
|
|
89
|
+
function testScriptOf(report) {
|
|
90
|
+
const ts = report.testScript;
|
|
91
|
+
if (typeof ts === 'string') {
|
|
92
|
+
return ts;
|
|
93
|
+
}
|
|
94
|
+
if (ts && typeof ts === 'object') {
|
|
95
|
+
if (typeof ts.reference === 'string') {
|
|
96
|
+
return ts.reference;
|
|
97
|
+
}
|
|
98
|
+
if (ts.identifier && typeof ts.identifier.value === 'string') {
|
|
99
|
+
return ts.identifier.value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function str(v) {
|
|
106
|
+
return typeof v === 'string' ? v : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How long the run took, in milliseconds, if the report says: from the earliest
|
|
111
|
+
* period.start to the latest period.end across its tests. null if no test has a period
|
|
112
|
+
* with both ends.
|
|
113
|
+
*/
|
|
114
|
+
function runLengthOf(report) {
|
|
115
|
+
let lo = null;
|
|
116
|
+
let hi = null;
|
|
117
|
+
for (const t of Array.isArray(report.test) ? report.test : []) {
|
|
118
|
+
const p = t && typeof t === 'object' ? t.period : null;
|
|
119
|
+
if (!p || typeof p !== 'object') {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const s = typeof p.start === 'string' ? Date.parse(p.start) : NaN;
|
|
123
|
+
const e = typeof p.end === 'string' ? Date.parse(p.end) : NaN;
|
|
124
|
+
if (!Number.isNaN(s) && (lo === null || s < lo)) {
|
|
125
|
+
lo = s;
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isNaN(e) && (hi === null || e > hi)) {
|
|
128
|
+
hi = e;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return lo !== null && hi !== null && hi >= lo ? hi - lo : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** How many tests the report has (entries in TestReport.test). */
|
|
135
|
+
function testCountOf(report) {
|
|
136
|
+
return Array.isArray(report.test) ? report.test.length : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A canonical URL without its |version. */
|
|
140
|
+
function unversioned(url) {
|
|
141
|
+
return typeof url === 'string' && url.includes('|') ? url.substring(0, url.indexOf('|')) : url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class TestReportStore {
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} dbPath - file name, or ':memory:'
|
|
147
|
+
*/
|
|
148
|
+
constructor(dbPath) {
|
|
149
|
+
this.db = new Database(dbPath);
|
|
150
|
+
this.db.pragma('journal_mode = WAL');
|
|
151
|
+
this.db.pragma('foreign_keys = ON');
|
|
152
|
+
this.db.exec(SCHEMA);
|
|
153
|
+
this.migrate();
|
|
154
|
+
this.stmts = {
|
|
155
|
+
insert: this.db.prepare(`
|
|
156
|
+
INSERT INTO reports (id, received, received_ms, ip, name, name_lc, status, result, score,
|
|
157
|
+
tester, tester_lc, test_script, issued, issued_lo, issued_hi, size, run_ms, test_count, json)
|
|
158
|
+
VALUES (@id, @received, @received_ms, @ip, @name, @name_lc, @status, @result, @score,
|
|
159
|
+
@tester, @tester_lc, @test_script, @issued, @issued_lo, @issued_hi, @size, @run_ms, @test_count, @json)`),
|
|
160
|
+
insertParticipant: this.db.prepare(
|
|
161
|
+
'INSERT INTO participants (report_id, seq, type, uri, version, display) VALUES (?, ?, ?, ?, ?, ?)'),
|
|
162
|
+
read: this.db.prepare('SELECT json FROM reports WHERE id = ?'),
|
|
163
|
+
delete: this.db.prepare('DELETE FROM reports WHERE id = ?'),
|
|
164
|
+
purge: this.db.prepare('DELETE FROM reports WHERE received_ms < ?'),
|
|
165
|
+
count: this.db.prepare('SELECT COUNT(*) AS n FROM reports')
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Bring a database made by an earlier version of the module up to date. */
|
|
170
|
+
migrate() {
|
|
171
|
+
const has = (table, col) => this.db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === col);
|
|
172
|
+
if (!has('participants', 'version')) {
|
|
173
|
+
this.db.exec('ALTER TABLE participants ADD COLUMN version TEXT');
|
|
174
|
+
}
|
|
175
|
+
// columns worked out from the report: add them, and fill them in for the reports
|
|
176
|
+
// already there
|
|
177
|
+
const derived = [['run_ms', runLengthOf], ['test_count', testCountOf]];
|
|
178
|
+
for (const [col, fn] of derived) {
|
|
179
|
+
if (!has('reports', col)) {
|
|
180
|
+
this.db.exec(`ALTER TABLE reports ADD COLUMN ${col} INTEGER`);
|
|
181
|
+
const update = this.db.prepare(`UPDATE reports SET ${col} = ? WHERE id = ?`);
|
|
182
|
+
this.db.transaction(() => {
|
|
183
|
+
for (const row of this.db.prepare('SELECT id, json FROM reports').all()) {
|
|
184
|
+
const v = fn(JSON.parse(row.json));
|
|
185
|
+
if (v !== null) {
|
|
186
|
+
update.run(v, row.id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
close() {
|
|
195
|
+
if (this.db) {
|
|
196
|
+
this.db.close();
|
|
197
|
+
this.db = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Store a report that has already been validated and given its id and meta.lastUpdated.
|
|
203
|
+
*
|
|
204
|
+
* @param {Object} report
|
|
205
|
+
* @param {{ip?: string, receivedMs: number}} info
|
|
206
|
+
*/
|
|
207
|
+
insert(report, info) {
|
|
208
|
+
const json = JSON.stringify(report);
|
|
209
|
+
const issued = dateRange(report.issued);
|
|
210
|
+
const row = {
|
|
211
|
+
id: report.id,
|
|
212
|
+
received: report.meta.lastUpdated,
|
|
213
|
+
received_ms: info.receivedMs,
|
|
214
|
+
ip: info.ip || null,
|
|
215
|
+
name: report.name,
|
|
216
|
+
name_lc: report.name.toLowerCase(),
|
|
217
|
+
status: report.status,
|
|
218
|
+
result: report.result,
|
|
219
|
+
score: typeof report.score === 'number' && Number.isFinite(report.score) ? report.score : null,
|
|
220
|
+
tester: report.tester,
|
|
221
|
+
tester_lc: report.tester.toLowerCase(),
|
|
222
|
+
test_script: testScriptOf(report),
|
|
223
|
+
issued: report.issued,
|
|
224
|
+
issued_lo: issued.lo,
|
|
225
|
+
issued_hi: issued.hi,
|
|
226
|
+
size: Buffer.byteLength(json, 'utf8'),
|
|
227
|
+
run_ms: runLengthOf(report),
|
|
228
|
+
test_count: testCountOf(report),
|
|
229
|
+
json
|
|
230
|
+
};
|
|
231
|
+
this.db.transaction(() => {
|
|
232
|
+
this.stmts.insert.run(row);
|
|
233
|
+
report.participant.forEach((p, i) => {
|
|
234
|
+
this.stmts.insertParticipant.run(report.id, i, str(p.type), p.uri, str(p.version), str(p.display));
|
|
235
|
+
});
|
|
236
|
+
})();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @returns {Object|null} the stored report */
|
|
240
|
+
read(id) {
|
|
241
|
+
const row = this.stmts.read.get(id);
|
|
242
|
+
return row ? JSON.parse(row.json) : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @returns {boolean} whether there was such a report */
|
|
246
|
+
delete(id) {
|
|
247
|
+
return this.stmts.delete.run(id).changes > 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Delete reports received before the given time. @returns {number} how many */
|
|
251
|
+
purgeBefore(ms) {
|
|
252
|
+
return this.stmts.purge.run(ms).changes;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
count() {
|
|
256
|
+
return this.stmts.count.get().n;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run a parsed search (see search.js).
|
|
261
|
+
*
|
|
262
|
+
* @param {Object} search - from parseSearch
|
|
263
|
+
* @param {{withJson?: boolean}} options - include the stored JSON (FHIR API), or just
|
|
264
|
+
* the columns the HTML list needs
|
|
265
|
+
* @returns {{total: number, rows: Object[]}}
|
|
266
|
+
*/
|
|
267
|
+
search(search, options = {}) {
|
|
268
|
+
const where = search.where.length > 0 ? 'WHERE ' + search.where.join(' AND ') : '';
|
|
269
|
+
const total = this.db.prepare(`SELECT COUNT(*) AS n FROM reports r ${where}`).get(...search.args).n;
|
|
270
|
+
if (search.summaryCount || search.count === 0) {
|
|
271
|
+
return { total, rows: [] };
|
|
272
|
+
}
|
|
273
|
+
const cols = options.withJson
|
|
274
|
+
? 'r.id, r.json'
|
|
275
|
+
: 'r.id, r.received, r.name, r.status, r.result, r.score, r.tester, r.test_script, r.issued, r.run_ms, r.test_count';
|
|
276
|
+
const rows = this.db.prepare(
|
|
277
|
+
`SELECT ${cols} FROM reports r ${where} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`
|
|
278
|
+
).all(...search.args, search.count, search.offset);
|
|
279
|
+
if (!options.withJson && rows.length > 0) {
|
|
280
|
+
const byId = new Map(rows.map(r => [r.id, r]));
|
|
281
|
+
for (const r of rows) {
|
|
282
|
+
r.participants = [];
|
|
283
|
+
}
|
|
284
|
+
const ph = rows.map(() => '?').join(',');
|
|
285
|
+
const parts = this.db.prepare(
|
|
286
|
+
`SELECT report_id, type, uri, version, display FROM participants WHERE report_id IN (${ph}) ORDER BY report_id, seq`
|
|
287
|
+
).all(...rows.map(r => r.id));
|
|
288
|
+
for (const p of parts) {
|
|
289
|
+
byId.get(p.report_id).participants.push(p);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { total, rows };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Whether any report has a participant of this type. */
|
|
296
|
+
hasParticipantType(type) {
|
|
297
|
+
return !!this.db.prepare('SELECT 1 FROM participants WHERE type = ? LIMIT 1').get(type);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- named links ----
|
|
301
|
+
|
|
302
|
+
/** @returns {Array<{id, canonical, name, link}>} sorted by name */
|
|
303
|
+
links() {
|
|
304
|
+
return this.db.prepare('SELECT id, canonical, name, link FROM links ORDER BY name COLLATE NOCASE, canonical').all();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Add a link, or replace the one for the same canonical. */
|
|
308
|
+
saveLink(canonical, name, link) {
|
|
309
|
+
this.db.prepare(`INSERT INTO links (canonical, name, link) VALUES (?, ?, ?)
|
|
310
|
+
ON CONFLICT(canonical) DO UPDATE SET name = excluded.name, link = excluded.link`)
|
|
311
|
+
.run(unversioned(canonical), name, link || null);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
updateLink(id, canonical, name, link) {
|
|
315
|
+
return this.db.prepare('UPDATE links SET canonical = ?, name = ?, link = ? WHERE id = ?')
|
|
316
|
+
.run(unversioned(canonical), name, link || null, id).changes > 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
deleteLink(id) {
|
|
320
|
+
return this.db.prepare('DELETE FROM links WHERE id = ?').run(id).changes > 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- users ----
|
|
324
|
+
|
|
325
|
+
/** @returns {Array} users, without their password hashes */
|
|
326
|
+
users() {
|
|
327
|
+
return this.db.prepare('SELECT id, login, name, can_edit_links, can_delete_reports, created FROM users ORDER BY login').all();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** @returns {Object|null} the user, including the password hash */
|
|
331
|
+
userByLogin(login) {
|
|
332
|
+
return this.db.prepare('SELECT * FROM users WHERE login = ?').get(login) || null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
userById(id) {
|
|
336
|
+
return this.db.prepare('SELECT id, login, name, can_edit_links, can_delete_reports FROM users WHERE id = ?').get(id) || null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
createUser(login, name, passwordHash, rights) {
|
|
340
|
+
this.db.prepare(`INSERT INTO users (login, name, password_hash, can_edit_links, can_delete_reports, created)
|
|
341
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(login, name, passwordHash, rights.editLinks ? 1 : 0, rights.deleteReports ? 1 : 0,
|
|
342
|
+
new Date().toISOString());
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
updateUser(id, name, rights, passwordHash) {
|
|
346
|
+
const n = this.db.prepare('UPDATE users SET name = ?, can_edit_links = ?, can_delete_reports = ? WHERE id = ?')
|
|
347
|
+
.run(name, rights.editLinks ? 1 : 0, rights.deleteReports ? 1 : 0, id).changes;
|
|
348
|
+
if (n > 0 && passwordHash) {
|
|
349
|
+
this.db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
|
|
350
|
+
}
|
|
351
|
+
return n > 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
deleteUser(id) {
|
|
355
|
+
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The distinct values in a column, for filter drop-downs. */
|
|
359
|
+
distinct(column) {
|
|
360
|
+
if (!['status', 'result'].includes(column)) {
|
|
361
|
+
throw new Error('Not a filterable column: ' + column);
|
|
362
|
+
}
|
|
363
|
+
return this.db.prepare(`SELECT DISTINCT ${column} AS v FROM reports ORDER BY ${column}`).all().map(r => r.v);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* How many reports each tester has sent, with how they came out, and when the latest
|
|
368
|
+
* was received. Most reports first.
|
|
369
|
+
*
|
|
370
|
+
* @returns {Array<{tester: string, reports: number, pass: number, fail: number, other: number,
|
|
371
|
+
* latest: string}>}
|
|
372
|
+
*/
|
|
373
|
+
testerCounts() {
|
|
374
|
+
return this.db.prepare(`
|
|
375
|
+
SELECT tester, COUNT(*) AS reports,
|
|
376
|
+
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS pass,
|
|
377
|
+
SUM(CASE WHEN result = 'fail' THEN 1 ELSE 0 END) AS fail,
|
|
378
|
+
SUM(CASE WHEN result NOT IN ('pass', 'fail') THEN 1 ELSE 0 END) AS other,
|
|
379
|
+
MAX(received) AS latest
|
|
380
|
+
FROM reports GROUP BY tester ORDER BY reports DESC, tester_lc`).all();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The latest report for each test script against each participant (or tester): the
|
|
385
|
+
* one with the latest issued date, then the latest received. Test engines are not
|
|
386
|
+
* participants in this sense - the columns are the things that were tested.
|
|
387
|
+
*
|
|
388
|
+
* @param {'participant'|'tester'} by
|
|
389
|
+
* @returns {Array<{test_script: string|null, col: string, id: string, name: string,
|
|
390
|
+
* result: string, score: number|null, issued: string, runs: number}>}
|
|
391
|
+
*/
|
|
392
|
+
summary(by) {
|
|
393
|
+
const source = by === 'tester'
|
|
394
|
+
? 'SELECT r.*, r.tester AS col FROM reports r'
|
|
395
|
+
: `SELECT r.*, p.uri AS col FROM reports r
|
|
396
|
+
JOIN (SELECT DISTINCT report_id, uri FROM participants
|
|
397
|
+
WHERE type IS NULL OR type <> 'test-engine') p ON p.report_id = r.id`;
|
|
398
|
+
return this.db.prepare(`
|
|
399
|
+
SELECT test_script, col, id, name, result, score, issued, runs FROM (
|
|
400
|
+
SELECT s.*,
|
|
401
|
+
COUNT(*) OVER (PARTITION BY s.test_script, s.col) AS runs,
|
|
402
|
+
ROW_NUMBER() OVER (PARTITION BY s.test_script, s.col
|
|
403
|
+
ORDER BY s.issued_lo DESC, s.received_ms DESC) AS rn
|
|
404
|
+
FROM (${source}) s
|
|
405
|
+
) WHERE rn = 1
|
|
406
|
+
ORDER BY test_script, col`).all();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = { TestReportStore, testScriptOf, runLengthOf, testCountOf, unversioned };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
|
|
3
|
+
<html xml:lang="en" lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<title>FHIRsmith: [%title%]</title>
|
|
6
|
+
|
|
7
|
+
<meta charset="utf-8"/>
|
|
8
|
+
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
9
|
+
<meta content="http://hl7.org/fhir" name="author"/>
|
|
10
|
+
<meta charset="utf-8" http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
11
|
+
|
|
12
|
+
<link rel="stylesheet" href="/fhir.css"/>
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
<!-- Bootstrap core CSS -->
|
|
16
|
+
<link rel="stylesheet" href="/assets/css/bootstrap.css"/>
|
|
17
|
+
<link rel="stylesheet" href="/assets/css/bootstrap-fhir.css"/>
|
|
18
|
+
|
|
19
|
+
<!-- Project extras -->
|
|
20
|
+
<link rel="stylesheet" href="/assets/css/project.css"/>
|
|
21
|
+
<link rel="stylesheet" href="/assets/css/pygments-manni.css"/>
|
|
22
|
+
|
|
23
|
+
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
24
|
+
<!-- [if lt IE 9]>
|
|
25
|
+
<script src="/assets/js/html5shiv.js"></script>
|
|
26
|
+
<script src="/assets/js/respond.min.js"></script>
|
|
27
|
+
<![endif] -->
|
|
28
|
+
|
|
29
|
+
<!-- Favicons -->
|
|
30
|
+
<link sizes="144x144" rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-144-precomposed.png"/>
|
|
31
|
+
<link sizes="114x114" rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-114-precomposed.png"/>
|
|
32
|
+
<link sizes="72x72" rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-72-precomposed.png"/>
|
|
33
|
+
<link rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-57-precomposed.png"/>
|
|
34
|
+
<link rel="shortcut icon" href="/assets/ico/favicon.png"/>
|
|
35
|
+
<script type="text/javascript" src="/assets/js/json2.js"></script>
|
|
36
|
+
<script type="text/javascript" src="/assets/js/statuspage.js"></script>
|
|
37
|
+
<script type="text/javascript" src="/assets/js/jquery.min.js"></script>
|
|
38
|
+
<script type="text/javascript" src="/assets/js/jquery-ui.min.js"></script>
|
|
39
|
+
<link rel="stylesheet" href="/assets/css/jquery.ui.all.css">
|
|
40
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.core.js"></script>
|
|
41
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.widget.js"></script>
|
|
42
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.mouse.js"></script>
|
|
43
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.resizable.js"></script>
|
|
44
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.draggable.js"></script>
|
|
45
|
+
<script type="text/javascript" src="/assets/js/jtip.js"></script>
|
|
46
|
+
<script type="text/javascript" src="/assets/js/jcookie.js"></script>
|
|
47
|
+
<script type="text/javascript" src="/assets/js/fhir-gw.js"></script>
|
|
48
|
+
</head>
|
|
49
|
+
|
|
50
|
+
<body>
|
|
51
|
+
|
|
52
|
+
<div id="segment-navbar" class="segment"> <!-- segment-breadcrumb -->
|
|
53
|
+
<div id="stripe"> </div>
|
|
54
|
+
<div class="container"> <!-- container -->
|
|
55
|
+
<div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
|
|
56
|
+
|
|
57
|
+
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org |
|
|
58
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] |
|
|
59
|
+
<a href="/" style="color: gold"> Server Home</a> |
|
|
60
|
+
<a href="/testing" style="color: gold">Test Reports</a> |
|
|
61
|
+
<a href="/testing/summary" style="color: gold">Summary</a> |
|
|
62
|
+
</div> <!-- /container -->
|
|
63
|
+
</div> <!-- /container -->
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- /segment-breadcrumb -->
|
|
67
|
+
|
|
68
|
+
<div id="segment-content" class="segment"> <!-- segment-content -->
|
|
69
|
+
<div class="container"> <!-- container -->
|
|
70
|
+
<div class="row">
|
|
71
|
+
<div class="inner-wrapper">
|
|
72
|
+
<div class="col-12">
|
|
73
|
+
|
|
74
|
+
<h2><img border="0" src="/FHIRsmith32.png" style="vertical-align: text-bottom"/> [%title%] </h2>
|
|
75
|
+
|
|
76
|
+
[%content%]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
</div> <!-- /inner-wrapper -->
|
|
83
|
+
</div> <!-- /row -->
|
|
84
|
+
</div> <!-- /container -->
|
|
85
|
+
</div> <!-- /segment-content -->
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
<div id="segment-footer" class="segment"> <!-- segment-footer -->
|
|
89
|
+
<div class="container"> <!-- container -->
|
|
90
|
+
<div class="inner-wrapper">
|
|
91
|
+
<p>
|
|
92
|
+
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org 2011+. |
|
|
93
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
94
|
+
([%ms%] ms)
|
|
95
|
+
[%sponsorMessage%]
|
|
96
|
+
</p>
|
|
97
|
+
</div> <!-- /inner-wrapper -->
|
|
98
|
+
</div> <!-- /container -->
|
|
99
|
+
</div> <!-- /segment-footer -->
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
<div id="segment-post-footer" class="segment hidden"> <!-- segment-post-footer -->
|
|
103
|
+
<div class="container"> <!-- container -->
|
|
104
|
+
</div> <!-- /container -->
|
|
105
|
+
</div> <!-- /segment-post-footer -->
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
<!-- JS and analytics only. -->
|
|
112
|
+
<!-- Bootstrap core JavaScript
|
|
113
|
+
================================================== -->
|
|
114
|
+
<!-- Placed at the end of the document so the pages load faster -->
|
|
115
|
+
<!-- <script src="/assets/js/jquery.js"/> -->
|
|
116
|
+
<script src="/assets/js/bootstrap.min.js"/>
|
|
117
|
+
<script src="/assets/js/respond.min.js"/>
|
|
118
|
+
|
|
119
|
+
<script src="/assets/js/fhir.js"/>
|
|
120
|
+
|
|
121
|
+
<!-- Analytics Below
|
|
122
|
+
================================================== -->
|
|
123
|
+
|
|
124
|
+
</body>
|
|
125
|
+
|
|
126
|
+
</html>
|