Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators
- Tests mirror source structure in `test/unit/`
- API tests for high-level generators

### Example Test Structure

Example test files in `examples/` follow a two-tier structure:

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:
- Build **valid** resources (populate all required fields so `validate().errors` is empty)
- Use `toMatchSnapshot()` on the final resource to capture the full FHIR JSON
- Show the validation error → fix → valid flow when it makes the demo clearer
- Use comments to explain what the profile API does, not what the test asserts

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.

Reference example: `examples/typescript-r4/profile-bodyweight.test.ts`

### Key Dependencies
- `@atomic-ehr/fhir-canonical-manager`: FHIR package management
- `@atomic-ehr/fhirschema`: FHIR schema definitions
Expand Down
12 changes: 12 additions & 0 deletions assets/api/writer-generator/typescript/profile-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ export const pushExtension = <E extends { url?: string }>(target: { extension?:
(target.extension ??= []).push(ext);
};

/**
* Insert or replace an extension by URL on `target.extension`.
* If an extension with the same `url` already exists it is replaced in place;
* otherwise the new extension is appended (like {@link pushExtension}).
*/
export const upsertExtension = <E extends { url?: string }>(target: { extension?: E[] }, ext: E): void => {
const list = (target.extension ??= []);
const idx = list.findIndex((e) => e.url === ext.url);
if (idx >= 0) list[idx] = ext;
else list.push(ext);
};

// ---------------------------------------------------------------------------
// Extension helpers
// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 24 additions & 108 deletions examples/local-package-folder/profile-typed-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,152 +11,68 @@

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

const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" });

const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] };
const jonesPatient: Patient = { resourceType: "Patient", name: [{ family: "Jones" }] };
const activePatient: Patient = { resourceType: "Patient", active: true };
const acmeOrg: Organization = { resourceType: "Organization", name: "Acme Corp" };
const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" };

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

test("setPatientEntry inserts a typed patient entry", () => {
const bundle = createBundle();
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
bundle.setPatientEntry({ resource: smithPatient });

const entry = bundle.getPatientEntry()!;
expect(entry.resource).toEqual(smithPatient);
expect(bundle.validate().errors).toEqual([]);
});

test("setPatientEntry replaces existing patient entry (no duplicates)", () => {
const bundle = createBundle();
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
bundle.setPatientEntry({ resource: smithPatient });
bundle.setPatientEntry({ resource: jonesPatient });

const patients = bundle.toResource().entry!.filter((e) => e.resource?.resourceType === "Patient");
expect(patients).toHaveLength(1);
expect(bundle.getPatientEntry()!.resource).toEqual(jonesPatient);
});

test("setOrganizationEntry adds an organization entry", () => {
const bundle = createBundle();
bundle.setOrganizationEntry({ resource: acmeOrg });
bundle.setPatientEntry({ resource: activePatient });

expect(bundle.getOrganizationEntry()!.resource).toEqual(acmeOrg);
const entries = bundle.toResource().entry!;
expect(entries).toHaveLength(1);
expect(entries[0]!.resource).toEqual(activePatient);
});

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

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

test("validate() checks PatientEntry cardinality", () => {
const bundle = ExampleTypedBundleProfile.apply({
resourceType: "Bundle",
type: "collection",
});
const { errors } = bundle.validate();
expect(errors).toEqual(["ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0"]);
});

test("fluent chaining across slice setters", () => {
const bundle = createBundle()
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
.setPatientEntry({ resource: activePatient })
.setOrganizationEntry({ resource: clinicOrg });

expect(bundle.toResource().entry).toHaveLength(2);
expect(bundle.getPatientEntry()!.resource).toEqual(activePatient);
expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg);
expect(bundle.toResource().entry).toHaveLength(2);
});

test("set/get PatientEntry with full BundleEntry<Patient> input", () => {
const bundle = createBundle();
const input: BundleEntry<Patient> = { fullUrl: "urn:uuid:p1", resource: smithPatient };
bundle.setPatientEntry(input);

const raw = bundle.getPatientEntry("raw")!;
expect(raw.fullUrl).toBe("urn:uuid:p1");
expect(raw.resource).toEqual(smithPatient);

const flat = bundle.getPatientEntry("flat")!;
expect(flat.fullUrl).toBe("urn:uuid:p1");
expect(flat.resource).toEqual(smithPatient);
});

test("set/get OrganizationEntry with full BundleEntry<Organization> input", () => {
const bundle = createBundle();
const input: BundleEntry<Organization> = { fullUrl: "urn:uuid:o1", resource: acmeOrg };
bundle.setOrganizationEntry(input);

const raw = bundle.getOrganizationEntry("raw")!;
expect(raw.fullUrl).toBe("urn:uuid:o1");
expect(raw.resource).toEqual(acmeOrg);

const flat = bundle.getOrganizationEntry("flat")!;
expect(flat.fullUrl).toBe("urn:uuid:o1");
expect(flat.resource).toEqual(acmeOrg);
});
});

describe("generic type-family fields — compile-time narrowing", () => {
test("BundleEntry<Patient>.resource is Patient (access Patient-specific fields without cast)", () => {
const bundle = createBundle();
bundle.setPatientEntry({ resource: smithPatient });

const entry = bundle.getPatientEntry()!;
// entry.resource is Patient — .name is available directly, no cast needed
const family: string | undefined = entry.resource?.name?.[0]?.family;
expect(family).toBe("Smith");
});

test("BundleEntry<Organization>.resource is Organization (access Organization-specific fields without cast)", () => {
const bundle = createBundle();
bundle.setOrganizationEntry({ resource: acmeOrg });

const entry = bundle.getOrganizationEntry()!;
// entry.resource is Organization — .name is string, not HumanName[]
const name: string | undefined = entry.resource?.name;
expect(name).toBe("Acme Corp");
});

test("BundleEntry<T> defaults to BundleEntry<Resource> — unparameterized usage unchanged", () => {
const entry: BundleEntry = { resource: smithPatient };
expect(entry.resource?.resourceType).toBe("Patient");
});

test("DomainResource<T> narrows contained to T[]", () => {
const container: DomainResource<Patient> = {
resourceType: "Patient",
contained: [smithPatient, jonesPatient],
};
// contained is Patient[] — .name available directly
const family: string | undefined = container.contained?.[0]?.name?.[0]?.family;
expect(family).toBe("Smith");
});

test("BundleEntry<Patient> rejects Organization at compile time", () => {
const patientEntry: BundleEntry<Patient> = { resource: smithPatient };
expect(patientEntry.resource?.resourceType).toBe("Patient");
test("setOrganizationEntry replaces existing org entry (same discriminator)", () => {
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
.setPatientEntry({ resource: activePatient })
.setOrganizationEntry({ resource: clinicOrg })
.setOrganizationEntry({ resource: { resourceType: "Organization", name: "Acme" } });

// Uncomment to verify compile error:
// @ts-expect-error — Organization is not assignable to Patient
const _bad: BundleEntry<Patient> = { resource: acmeOrg };
void _bad;
const entries = bundle.toResource().entry!;
expect(entries).toHaveLength(2);
expect(bundle.getOrganizationEntry()!.resource!.name).toBe("Acme");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots

exports[`demo: create a bodyweight observation build a valid bodyweight resource step by step 1`] = `
{
"category": [
{
"coding": [
{
"code": "vital-signs",
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
},
],
},
],
"code": {
"coding": [
{
"code": "29463-7",
"system": "http://loinc.org",
},
],
},
"effectiveDateTime": "2024-06-15",
"meta": {
"profile": [
"http://hl7.org/fhir/StructureDefinition/bodyweight",
],
},
"resourceType": "Observation",
"status": "final",
"subject": {
"reference": "Patient/pt-1",
},
"valueQuantity": {
"code": "kg",
"system": "http://unitsofmeasure.org",
"unit": "kg",
"value": 75,
},
}
`;

exports[`bodyweight profile creation fully populated resource matches snapshot 1`] = `
{
"category": [
{
"coding": [
{
"code": "vital-signs",
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
},
],
},
],
"code": {
"coding": [
{
"code": "29463-7",
"system": "http://loinc.org",
},
],
},
"effectiveDateTime": "2024-06-15",
"meta": {
"profile": [
"http://hl7.org/fhir/StructureDefinition/bodyweight",
],
},
"resourceType": "Observation",
"status": "final",
"subject": {
"reference": "Patient/pt-1",
},
"valueQuantity": {
"code": "kg",
"system": "http://unitsofmeasure.org",
"unit": "kg",
"value": 75,
},
}
`;
69 changes: 69 additions & 0 deletions examples/typescript-r4/__snapshots__/profile-bp.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots

exports[`demo: create a blood pressure observation build a valid BP resource step by step 1`] = `
{
"category": [
{
"coding": [
{
"code": "vital-signs",
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
},
],
},
],
"code": {
"coding": [
{
"code": "85354-9",
"system": "http://loinc.org",
},
],
},
"component": [
{
"code": {
"coding": [
{
"code": "8480-6",
"system": "http://loinc.org",
},
],
},
"valueQuantity": {
"code": "mm[Hg]",
"system": "http://unitsofmeasure.org",
"unit": "mmHg",
"value": 120,
},
},
{
"code": {
"coding": [
{
"code": "8462-4",
"system": "http://loinc.org",
},
],
},
"valueQuantity": {
"code": "mm[Hg]",
"system": "http://unitsofmeasure.org",
"unit": "mmHg",
"value": 80,
},
},
],
"effectiveDateTime": "2024-06-15",
"meta": {
"profile": [
"http://hl7.org/fhir/StructureDefinition/bp",
],
},
"resourceType": "Observation",
"status": "final",
"subject": {
"reference": "Patient/pt-1",
},
}
`;
Loading
Loading