295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
/**
|
||
* Standalone gate test for the condition-spine pure helpers.
|
||
*
|
||
* Japa cannot boot locally without MySQL/Redis, so this file exercises the pure
|
||
* helpers (parseConditionsFile, parseConditionEntry, buildIndicationQuery,
|
||
* orderOtcFirst, findConditionBySlug, toConditionSummary) directly under
|
||
* `node --experimental-strip-types`. Run:
|
||
* node --experimental-strip-types tests/standalone/conditions.standalone.ts
|
||
*
|
||
* Also smoke-checks the shipped curated spine (admin/app/data/conditions.ts)
|
||
* parses cleanly and stays a bounded curated list of ~30–50 conditions.
|
||
*/
|
||
import assert from 'node:assert/strict'
|
||
import {
|
||
parseConditionsFile,
|
||
parseConditionEntry,
|
||
buildIndicationQuery,
|
||
orderOtcFirst,
|
||
findConditionBySlug,
|
||
toConditionSummary,
|
||
situationsForIndications,
|
||
OTC_PRODUCT_TYPE,
|
||
RX_PRODUCT_TYPE,
|
||
} from '../../util/conditions.ts'
|
||
import { CONDITIONS_FILE } from '../../app/data/conditions.ts'
|
||
import { PRODUCT_TYPES } from '../../types/drug_reference.ts'
|
||
import type { DrugSearchResult } from '../../types/drug_reference.ts'
|
||
|
||
let passed = 0
|
||
function check(name: string, fn: () => void) {
|
||
fn()
|
||
passed++
|
||
console.log(` ok - ${name}`)
|
||
}
|
||
|
||
function makeDrug(id: number, product_type: string | null): DrugSearchResult {
|
||
return {
|
||
id,
|
||
brand_name: `Brand ${id}`,
|
||
generic_name: `generic ${id}`,
|
||
manufacturer: null,
|
||
route: null,
|
||
product_type,
|
||
labelCount: 1,
|
||
}
|
||
}
|
||
|
||
// ── parseConditionEntry ───────────────────────────────────────────────────────
|
||
check('parseConditionEntry accepts a well-formed entry', () => {
|
||
const entry = parseConditionEntry({
|
||
slug: 'burns',
|
||
label: 'Burns',
|
||
category: 'Skin & wounds',
|
||
searchTerms: ['burns', 'sunburn'],
|
||
})
|
||
assert.ok(entry)
|
||
assert.equal(entry!.slug, 'burns')
|
||
assert.deepEqual(entry!.searchTerms, ['burns', 'sunburn'])
|
||
})
|
||
|
||
check('parseConditionEntry de-dupes searchTerms case-insensitively', () => {
|
||
const entry = parseConditionEntry({
|
||
slug: 's',
|
||
label: 'L',
|
||
category: 'C',
|
||
searchTerms: ['Burn', 'burn', ' burn ', 'scald'],
|
||
})
|
||
assert.ok(entry)
|
||
assert.deepEqual(entry!.searchTerms, ['Burn', 'scald'])
|
||
})
|
||
|
||
check('parseConditionEntry rejects missing slug/label/category', () => {
|
||
assert.equal(parseConditionEntry({ label: 'L', category: 'C', searchTerms: ['x'] }), null)
|
||
assert.equal(parseConditionEntry({ slug: 's', category: 'C', searchTerms: ['x'] }), null)
|
||
assert.equal(parseConditionEntry({ slug: 's', label: 'L', searchTerms: ['x'] }), null)
|
||
})
|
||
|
||
check('parseConditionEntry rejects an entry with no usable searchTerms', () => {
|
||
assert.equal(parseConditionEntry({ slug: 's', label: 'L', category: 'C', searchTerms: [] }), null)
|
||
assert.equal(
|
||
parseConditionEntry({ slug: 's', label: 'L', category: 'C', searchTerms: ['', ' '] }),
|
||
null
|
||
)
|
||
})
|
||
|
||
check('parseConditionEntry rejects non-objects', () => {
|
||
assert.equal(parseConditionEntry(null), null)
|
||
assert.equal(parseConditionEntry('burns'), null)
|
||
assert.equal(parseConditionEntry(42), null)
|
||
})
|
||
|
||
// ── parseConditionsFile ───────────────────────────────────────────────────────
|
||
check('parseConditionsFile parses a valid file', () => {
|
||
const file = parseConditionsFile({
|
||
version: '2026-06-07',
|
||
conditions: [
|
||
{ slug: 'a', label: 'A', category: 'C', searchTerms: ['a'] },
|
||
{ slug: 'b', label: 'B', category: 'C', searchTerms: ['b'] },
|
||
],
|
||
})
|
||
assert.equal(file.version, '2026-06-07')
|
||
assert.equal(file.conditions.length, 2)
|
||
})
|
||
|
||
check('parseConditionsFile drops malformed entries but keeps good ones', () => {
|
||
const file = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [
|
||
{ slug: 'good', label: 'G', category: 'C', searchTerms: ['g'] },
|
||
{ slug: 'bad-no-terms', label: 'B', category: 'C', searchTerms: [] },
|
||
'not-an-object',
|
||
],
|
||
})
|
||
assert.equal(file.conditions.length, 1)
|
||
assert.equal(file.conditions[0].slug, 'good')
|
||
})
|
||
|
||
check('parseConditionsFile de-dupes by slug, keeping the first', () => {
|
||
const file = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [
|
||
{ slug: 'dup', label: 'First', category: 'C', searchTerms: ['x'] },
|
||
{ slug: 'dup', label: 'Second', category: 'C', searchTerms: ['y'] },
|
||
],
|
||
})
|
||
assert.equal(file.conditions.length, 1)
|
||
assert.equal(file.conditions[0].label, 'First')
|
||
})
|
||
|
||
check('parseConditionsFile fail-soft on garbage input', () => {
|
||
assert.deepEqual(parseConditionsFile(null), { version: 'unknown', conditions: [] })
|
||
assert.deepEqual(parseConditionsFile('nope'), { version: 'unknown', conditions: [] })
|
||
assert.deepEqual(parseConditionsFile({ version: 'v' }), { version: 'v', conditions: [] })
|
||
})
|
||
|
||
// ── buildIndicationQuery ──────────────────────────────────────────────────────
|
||
check('buildIndicationQuery quotes multi-word phrases, passes single words bare', () => {
|
||
assert.equal(buildIndicationQuery(['burn', 'sore throat']), 'burn "sore throat"')
|
||
})
|
||
|
||
check('buildIndicationQuery de-dupes and drops empties', () => {
|
||
assert.equal(buildIndicationQuery(['burn', 'BURN', '', ' ', 'scald']), 'burn scald')
|
||
})
|
||
|
||
check('buildIndicationQuery strips embedded quotes', () => {
|
||
assert.equal(buildIndicationQuery(['sore "throat"']), '"sore throat"')
|
||
})
|
||
|
||
check('buildIndicationQuery returns empty string for all-empty input', () => {
|
||
assert.equal(buildIndicationQuery([]), '')
|
||
assert.equal(buildIndicationQuery(['', ' ']), '')
|
||
})
|
||
|
||
// ── product-type constants drift-guard ───────────────────────────────────────
|
||
check('helper OTC/RX constants match canonical PRODUCT_TYPES', () => {
|
||
assert.equal(OTC_PRODUCT_TYPE, PRODUCT_TYPES.OTC)
|
||
assert.equal(RX_PRODUCT_TYPE, PRODUCT_TYPES.RX)
|
||
})
|
||
|
||
// ── orderOtcFirst ─────────────────────────────────────────────────────────────
|
||
check('orderOtcFirst buckets OTC → other → Rx, preserving relevance order', () => {
|
||
const input = [
|
||
makeDrug(1, PRODUCT_TYPES.RX),
|
||
makeDrug(2, PRODUCT_TYPES.OTC),
|
||
makeDrug(3, null),
|
||
makeDrug(4, PRODUCT_TYPES.OTC),
|
||
makeDrug(5, PRODUCT_TYPES.RX),
|
||
]
|
||
const out = orderOtcFirst(input)
|
||
assert.deepEqual(
|
||
out.map((d) => d.id),
|
||
[2, 4, 3, 1, 5]
|
||
)
|
||
})
|
||
|
||
check('orderOtcFirst does not mutate the input array', () => {
|
||
const input = [makeDrug(1, PRODUCT_TYPES.RX), makeDrug(2, PRODUCT_TYPES.OTC)]
|
||
const snapshot = input.map((d) => d.id)
|
||
orderOtcFirst(input)
|
||
assert.deepEqual(
|
||
input.map((d) => d.id),
|
||
snapshot
|
||
)
|
||
})
|
||
|
||
// ── findConditionBySlug / toConditionSummary ─────────────────────────────────
|
||
check('findConditionBySlug returns the matching condition or null', () => {
|
||
const conditions = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [{ slug: 'fever', label: 'Fever', category: 'C', searchTerms: ['fever'] }],
|
||
}).conditions
|
||
assert.equal(findConditionBySlug(conditions, 'fever')!.label, 'Fever')
|
||
assert.equal(findConditionBySlug(conditions, 'nope'), null)
|
||
assert.equal(findConditionBySlug(conditions, ''), null)
|
||
})
|
||
|
||
check('toConditionSummary strips searchTerms', () => {
|
||
const summary = toConditionSummary({
|
||
slug: 'fever',
|
||
label: 'Fever',
|
||
category: 'Pain',
|
||
searchTerms: ['fever', 'reduces fever'],
|
||
})
|
||
assert.deepEqual(summary, { slug: 'fever', label: 'Fever', category: 'Pain' })
|
||
assert.equal('searchTerms' in summary, false)
|
||
})
|
||
|
||
// ── situationsForIndications (reverse link) ───────────────────────────────────
|
||
const sampleConditions = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [
|
||
{ slug: 'fever', label: 'Fever', category: 'Pain', searchTerms: ['fever', 'reduces fever'] },
|
||
{ slug: 'pain', label: 'Pain', category: 'Pain', searchTerms: ['pain', 'minor aches'] },
|
||
{ slug: 'cough', label: 'Cough', category: 'Cold', searchTerms: ['cough', 'cough suppressant'] },
|
||
{
|
||
slug: 'sore-throat',
|
||
label: 'Sore throat',
|
||
category: 'Cold',
|
||
searchTerms: ['sore throat', 'throat pain'],
|
||
},
|
||
],
|
||
}).conditions
|
||
|
||
check('situationsForIndications matches a single-word term, case-insensitively', () => {
|
||
const out = situationsForIndications('Temporarily reduces FEVER and relieves minor aches', sampleConditions)
|
||
assert.deepEqual(
|
||
out.map((c) => c.slug),
|
||
['fever', 'pain']
|
||
)
|
||
})
|
||
|
||
check('situationsForIndications matches a multi-word phrase term', () => {
|
||
const out = situationsForIndications('For the temporary relief of sore throat', sampleConditions)
|
||
assert.deepEqual(
|
||
out.map((c) => c.slug),
|
||
['sore-throat']
|
||
)
|
||
})
|
||
|
||
check('situationsForIndications preserves curated order, not text order', () => {
|
||
// text mentions cough first, then fever, but curated order is fever before cough
|
||
const out = situationsForIndications('Controls cough; also reduces fever', sampleConditions)
|
||
assert.deepEqual(
|
||
out.map((c) => c.slug),
|
||
['fever', 'cough']
|
||
)
|
||
})
|
||
|
||
check('situationsForIndications returns summaries (no searchTerms leak)', () => {
|
||
const out = situationsForIndications('pain', sampleConditions)
|
||
assert.equal(out.length, 1)
|
||
assert.equal('searchTerms' in out[0], false)
|
||
assert.deepEqual(out[0], { slug: 'pain', label: 'Pain', category: 'Pain' })
|
||
})
|
||
|
||
check('situationsForIndications returns [] for blank/empty/null input', () => {
|
||
assert.deepEqual(situationsForIndications('', sampleConditions), [])
|
||
assert.deepEqual(situationsForIndications(' ', sampleConditions), [])
|
||
assert.deepEqual(situationsForIndications(null, sampleConditions), [])
|
||
assert.deepEqual(situationsForIndications(undefined, sampleConditions), [])
|
||
})
|
||
|
||
check('situationsForIndications returns [] when nothing matches', () => {
|
||
assert.deepEqual(situationsForIndications('antiseptic for minor cuts', sampleConditions), [])
|
||
})
|
||
|
||
check('situationsForIndications dedupes by slug (one hit per condition)', () => {
|
||
// both "pain" and "minor aches" appear; pain must surface exactly once
|
||
const out = situationsForIndications('relieves minor aches and pain', sampleConditions)
|
||
assert.equal(out.filter((c) => c.slug === 'pain').length, 1)
|
||
})
|
||
|
||
// ── shipped spine smoke check ─────────────────────────────────────────────────
|
||
check('shipped CONDITIONS_FILE parses cleanly with no dropped entries', () => {
|
||
const parsed = parseConditionsFile(CONDITIONS_FILE)
|
||
assert.equal(parsed.conditions.length, CONDITIONS_FILE.conditions.length)
|
||
})
|
||
|
||
check('shipped spine holds a bounded curated list of ~30–50 conditions', () => {
|
||
const n = CONDITIONS_FILE.conditions.length
|
||
assert.ok(n >= 30 && n <= 50, `expected 30–50 conditions, got ${n}`)
|
||
})
|
||
|
||
check('shipped spine has unique slugs and non-empty searchTerms', () => {
|
||
const slugs = new Set<string>()
|
||
for (const c of CONDITIONS_FILE.conditions) {
|
||
assert.ok(!slugs.has(c.slug), `duplicate slug: ${c.slug}`)
|
||
slugs.add(c.slug)
|
||
assert.ok(c.searchTerms.length > 0, `empty searchTerms: ${c.slug}`)
|
||
assert.ok(buildIndicationQuery(c.searchTerms).length > 0, `empty query: ${c.slug}`)
|
||
}
|
||
})
|
||
|
||
console.log(`\n${passed} checks passed`)
|