Skip to content

Commit a6ef301

Browse files
authored
Merge pull request #121 from atomic-ehr/fix-profile-class-issues
TS: Fix profile apply/create, fix slice match arrays, rewrite example tests
2 parents ee38640 + 630949f commit a6ef301

41 files changed

Lines changed: 1132 additions & 948 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,20 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators
131131
- Tests mirror source structure in `test/unit/`
132132
- API tests for high-level generators
133133

134+
### Example Test Structure
135+
136+
Example test files in `examples/` follow a two-tier structure:
137+
138+
1. **Demo tests** come first — readable, self-contained scenarios that show how the generated API is used. Each demo is a separate `describe("demo: ...")` block covering one use case. Demos should:
139+
- Build **valid** resources (populate all required fields so `validate().errors` is empty)
140+
- Use `toMatchSnapshot()` on the final resource to capture the full FHIR JSON
141+
- Show the validation error → fix → valid flow when it makes the demo clearer
142+
- Use comments to explain what the profile API does, not what the test asserts
143+
144+
2. **Regression tests** follow — concise, focused tests for edge cases and mechanics not covered by demos (e.g. factory equivalence, slice replacement, choice type independence). Keep these minimal; don't duplicate what demos already prove.
145+
146+
Reference example: `examples/typescript-r4/profile-bodyweight.test.ts`
147+
134148
### Key Dependencies
135149
- `@atomic-ehr/fhir-canonical-manager`: FHIR package management
136150
- `@atomic-ehr/fhirschema`: FHIR schema definitions

assets/api/writer-generator/typescript/profile-helpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,18 @@ export const pushExtension = <E extends { url?: string }>(target: { extension?:
157157
(target.extension ??= []).push(ext);
158158
};
159159

160+
/**
161+
* Insert or replace an extension by URL on `target.extension`.
162+
* If an extension with the same `url` already exists it is replaced in place;
163+
* otherwise the new extension is appended (like {@link pushExtension}).
164+
*/
165+
export const upsertExtension = <E extends { url?: string }>(target: { extension?: E[] }, ext: E): void => {
166+
const list = (target.extension ??= []);
167+
const idx = list.findIndex((e) => e.url === ext.url);
168+
if (idx >= 0) list[idx] = ext;
169+
else list.push(ext);
170+
};
171+
160172
// ---------------------------------------------------------------------------
161173
// Extension helpers
162174
// ---------------------------------------------------------------------------

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/local-package-folder/profile-typed-bundle.test.ts

Lines changed: 24 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -11,152 +11,68 @@
1111

1212
import { describe, expect, test } from "bun:test";
1313
import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle";
14-
import type { BundleEntry } from "./fhir-types/hl7-fhir-r4-core/Bundle";
15-
import type { DomainResource } from "./fhir-types/hl7-fhir-r4-core/DomainResource";
1614
import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization";
1715
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
1816

19-
const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" });
20-
2117
const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] };
22-
const jonesPatient: Patient = { resourceType: "Patient", name: [{ family: "Jones" }] };
2318
const activePatient: Patient = { resourceType: "Patient", active: true };
24-
const acmeOrg: Organization = { resourceType: "Organization", name: "Acme Corp" };
2519
const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" };
2620

2721
describe("type-discriminated bundle slices", () => {
28-
test("create() auto-populates a PatientEntry stub (min: 1)", () => {
29-
const bundle = createBundle();
30-
const entry = bundle.toResource().entry;
31-
expect(entry).toHaveLength(1);
32-
expect(entry![0]!.resource).toEqual({ resourceType: "Patient" });
22+
test("create() starts with no entry — PatientEntry must be set by user", () => {
23+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
24+
expect(bundle.toResource().entry).toBeUndefined();
25+
expect(bundle.validate().errors).toEqual([
26+
"ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0",
27+
]);
3328
});
3429

3530
test("setPatientEntry inserts a typed patient entry", () => {
36-
const bundle = createBundle();
31+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
3732
bundle.setPatientEntry({ resource: smithPatient });
3833

3934
const entry = bundle.getPatientEntry()!;
4035
expect(entry.resource).toEqual(smithPatient);
36+
expect(bundle.validate().errors).toEqual([]);
4137
});
4238

4339
test("setPatientEntry replaces existing patient entry (no duplicates)", () => {
44-
const bundle = createBundle();
40+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
4541
bundle.setPatientEntry({ resource: smithPatient });
46-
bundle.setPatientEntry({ resource: jonesPatient });
47-
48-
const patients = bundle.toResource().entry!.filter((e) => e.resource?.resourceType === "Patient");
49-
expect(patients).toHaveLength(1);
50-
expect(bundle.getPatientEntry()!.resource).toEqual(jonesPatient);
51-
});
52-
53-
test("setOrganizationEntry adds an organization entry", () => {
54-
const bundle = createBundle();
55-
bundle.setOrganizationEntry({ resource: acmeOrg });
42+
bundle.setPatientEntry({ resource: activePatient });
5643

57-
expect(bundle.getOrganizationEntry()!.resource).toEqual(acmeOrg);
44+
const entries = bundle.toResource().entry!;
45+
expect(entries).toHaveLength(1);
46+
expect(entries[0]!.resource).toEqual(activePatient);
5847
});
5948

6049
test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => {
61-
const bundle = createBundle();
50+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
6251
bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient });
6352

6453
const flat = bundle.getPatientEntry("flat")!;
6554
expect(flat.fullUrl).toBe("urn:uuid:patient-1");
6655
expect(flat.resource).toEqual(activePatient);
6756
});
6857

69-
test("validate() checks PatientEntry cardinality", () => {
70-
const bundle = ExampleTypedBundleProfile.apply({
71-
resourceType: "Bundle",
72-
type: "collection",
73-
});
74-
const { errors } = bundle.validate();
75-
expect(errors).toEqual(["ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0"]);
76-
});
77-
7858
test("fluent chaining across slice setters", () => {
79-
const bundle = createBundle()
59+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
8060
.setPatientEntry({ resource: activePatient })
8161
.setOrganizationEntry({ resource: clinicOrg });
8262

63+
expect(bundle.toResource().entry).toHaveLength(2);
8364
expect(bundle.getPatientEntry()!.resource).toEqual(activePatient);
8465
expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg);
85-
expect(bundle.toResource().entry).toHaveLength(2);
8666
});
8767

88-
test("set/get PatientEntry with full BundleEntry<Patient> input", () => {
89-
const bundle = createBundle();
90-
const input: BundleEntry<Patient> = { fullUrl: "urn:uuid:p1", resource: smithPatient };
91-
bundle.setPatientEntry(input);
92-
93-
const raw = bundle.getPatientEntry("raw")!;
94-
expect(raw.fullUrl).toBe("urn:uuid:p1");
95-
expect(raw.resource).toEqual(smithPatient);
96-
97-
const flat = bundle.getPatientEntry("flat")!;
98-
expect(flat.fullUrl).toBe("urn:uuid:p1");
99-
expect(flat.resource).toEqual(smithPatient);
100-
});
101-
102-
test("set/get OrganizationEntry with full BundleEntry<Organization> input", () => {
103-
const bundle = createBundle();
104-
const input: BundleEntry<Organization> = { fullUrl: "urn:uuid:o1", resource: acmeOrg };
105-
bundle.setOrganizationEntry(input);
106-
107-
const raw = bundle.getOrganizationEntry("raw")!;
108-
expect(raw.fullUrl).toBe("urn:uuid:o1");
109-
expect(raw.resource).toEqual(acmeOrg);
110-
111-
const flat = bundle.getOrganizationEntry("flat")!;
112-
expect(flat.fullUrl).toBe("urn:uuid:o1");
113-
expect(flat.resource).toEqual(acmeOrg);
114-
});
115-
});
116-
117-
describe("generic type-family fields — compile-time narrowing", () => {
118-
test("BundleEntry<Patient>.resource is Patient (access Patient-specific fields without cast)", () => {
119-
const bundle = createBundle();
120-
bundle.setPatientEntry({ resource: smithPatient });
121-
122-
const entry = bundle.getPatientEntry()!;
123-
// entry.resource is Patient — .name is available directly, no cast needed
124-
const family: string | undefined = entry.resource?.name?.[0]?.family;
125-
expect(family).toBe("Smith");
126-
});
127-
128-
test("BundleEntry<Organization>.resource is Organization (access Organization-specific fields without cast)", () => {
129-
const bundle = createBundle();
130-
bundle.setOrganizationEntry({ resource: acmeOrg });
131-
132-
const entry = bundle.getOrganizationEntry()!;
133-
// entry.resource is Organization — .name is string, not HumanName[]
134-
const name: string | undefined = entry.resource?.name;
135-
expect(name).toBe("Acme Corp");
136-
});
137-
138-
test("BundleEntry<T> defaults to BundleEntry<Resource> — unparameterized usage unchanged", () => {
139-
const entry: BundleEntry = { resource: smithPatient };
140-
expect(entry.resource?.resourceType).toBe("Patient");
141-
});
142-
143-
test("DomainResource<T> narrows contained to T[]", () => {
144-
const container: DomainResource<Patient> = {
145-
resourceType: "Patient",
146-
contained: [smithPatient, jonesPatient],
147-
};
148-
// contained is Patient[] — .name available directly
149-
const family: string | undefined = container.contained?.[0]?.name?.[0]?.family;
150-
expect(family).toBe("Smith");
151-
});
152-
153-
test("BundleEntry<Patient> rejects Organization at compile time", () => {
154-
const patientEntry: BundleEntry<Patient> = { resource: smithPatient };
155-
expect(patientEntry.resource?.resourceType).toBe("Patient");
68+
test("setOrganizationEntry replaces existing org entry (same discriminator)", () => {
69+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
70+
.setPatientEntry({ resource: activePatient })
71+
.setOrganizationEntry({ resource: clinicOrg })
72+
.setOrganizationEntry({ resource: { resourceType: "Organization", name: "Acme" } });
15673

157-
// Uncomment to verify compile error:
158-
// @ts-expect-error — Organization is not assignable to Patient
159-
const _bad: BundleEntry<Patient> = { resource: acmeOrg };
160-
void _bad;
74+
const entries = bundle.toResource().entry!;
75+
expect(entries).toHaveLength(2);
76+
expect(bundle.getOrganizationEntry()!.resource!.name).toBe("Acme");
16177
});
16278
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2+
3+
exports[`demo: create a bodyweight observation build a valid bodyweight resource step by step 1`] = `
4+
{
5+
"category": [
6+
{
7+
"coding": [
8+
{
9+
"code": "vital-signs",
10+
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
11+
},
12+
],
13+
},
14+
],
15+
"code": {
16+
"coding": [
17+
{
18+
"code": "29463-7",
19+
"system": "http://loinc.org",
20+
},
21+
],
22+
},
23+
"effectiveDateTime": "2024-06-15",
24+
"meta": {
25+
"profile": [
26+
"http://hl7.org/fhir/StructureDefinition/bodyweight",
27+
],
28+
},
29+
"resourceType": "Observation",
30+
"status": "final",
31+
"subject": {
32+
"reference": "Patient/pt-1",
33+
},
34+
"valueQuantity": {
35+
"code": "kg",
36+
"system": "http://unitsofmeasure.org",
37+
"unit": "kg",
38+
"value": 75,
39+
},
40+
}
41+
`;
42+
43+
exports[`bodyweight profile creation fully populated resource matches snapshot 1`] = `
44+
{
45+
"category": [
46+
{
47+
"coding": [
48+
{
49+
"code": "vital-signs",
50+
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
51+
},
52+
],
53+
},
54+
],
55+
"code": {
56+
"coding": [
57+
{
58+
"code": "29463-7",
59+
"system": "http://loinc.org",
60+
},
61+
],
62+
},
63+
"effectiveDateTime": "2024-06-15",
64+
"meta": {
65+
"profile": [
66+
"http://hl7.org/fhir/StructureDefinition/bodyweight",
67+
],
68+
},
69+
"resourceType": "Observation",
70+
"status": "final",
71+
"subject": {
72+
"reference": "Patient/pt-1",
73+
},
74+
"valueQuantity": {
75+
"code": "kg",
76+
"system": "http://unitsofmeasure.org",
77+
"unit": "kg",
78+
"value": 75,
79+
},
80+
}
81+
`;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2+
3+
exports[`demo: create a blood pressure observation build a valid BP resource step by step 1`] = `
4+
{
5+
"category": [
6+
{
7+
"coding": [
8+
{
9+
"code": "vital-signs",
10+
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
11+
},
12+
],
13+
},
14+
],
15+
"code": {
16+
"coding": [
17+
{
18+
"code": "85354-9",
19+
"system": "http://loinc.org",
20+
},
21+
],
22+
},
23+
"component": [
24+
{
25+
"code": {
26+
"coding": [
27+
{
28+
"code": "8480-6",
29+
"system": "http://loinc.org",
30+
},
31+
],
32+
},
33+
"valueQuantity": {
34+
"code": "mm[Hg]",
35+
"system": "http://unitsofmeasure.org",
36+
"unit": "mmHg",
37+
"value": 120,
38+
},
39+
},
40+
{
41+
"code": {
42+
"coding": [
43+
{
44+
"code": "8462-4",
45+
"system": "http://loinc.org",
46+
},
47+
],
48+
},
49+
"valueQuantity": {
50+
"code": "mm[Hg]",
51+
"system": "http://unitsofmeasure.org",
52+
"unit": "mmHg",
53+
"value": 80,
54+
},
55+
},
56+
],
57+
"effectiveDateTime": "2024-06-15",
58+
"meta": {
59+
"profile": [
60+
"http://hl7.org/fhir/StructureDefinition/bp",
61+
],
62+
},
63+
"resourceType": "Observation",
64+
"status": "final",
65+
"subject": {
66+
"reference": "Patient/pt-1",
67+
},
68+
}
69+
`;

0 commit comments

Comments
 (0)