diff --git a/.dockerignore b/.dockerignore index a661baad..158d4573 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ .git .github -logs \ No newline at end of file +coverage +html_summaries +json_results +logs +tmp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-r4.yml similarity index 99% rename from .github/workflows/ci.yml rename to .github/workflows/ci-r4.yml index eac36812..9979c833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-r4.yml @@ -1,4 +1,4 @@ -name: CI +name: CI R4 on: workflow_dispatch: diff --git a/.github/workflows/ci-r5.yml b/.github/workflows/ci-r5.yml new file mode 100644 index 00000000..13dade44 --- /dev/null +++ b/.github/workflows/ci-r5.yml @@ -0,0 +1,160 @@ +name: CI R5 + +on: + workflow_dispatch: + push: + branches: + - 'master' + pull_request: + +jobs: + build: + if: github.repository == 'incendilabs/plan-executor' + + runs-on: ubuntu-24.04 + steps: + - + name: Checkout plan-executor + uses: actions/checkout@v7 + - + name: Build plan-executor Docker image + run: docker build -t incendi/plan_executor:latest . + - + name: Checkout Spark + uses: actions/checkout@v7 + with: + repository: FirelyTeam/spark + ref: master + path: spark + - + name: Record Spark revision + run: | + git -C spark rev-parse HEAD | tee spark-revision.txt + - + name: Build Spark R5 Docker image + run: | + docker build spark \ + --file spark/.docker/linux/Spark.R5.Dockerfile \ + --tag sparkfhir/spark:r5-latest + - + name: Build Mongo R5 Docker image + run: | + docker build spark \ + --file spark/.docker/linux/Mongo.R5.Dockerfile \ + --tag sparkfhir/mongo:r5-latest + - + name: Start Spark + run: | + mkdir -p logs html_summaries json_results + docker compose -f docker-compose.yml -f docker-compose-r5.yml up -d spark + - + name: Wait for and validate R5 metadata + run: | + set -e + for attempt in $(seq 1 30); do + if docker compose -f docker-compose.yml -f docker-compose-r5.yml run --rm --no-deps plan_executor \ + bundle exec ruby -rnet/http -e ' + require "fhir_client" + + response = Net::HTTP.get_response(URI("http://spark:8080/fhir/metadata")) + abort "metadata request returned #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + capability = FHIR::R5::Json.from_json(response.body) + abort "unexpected CapabilityStatement fhirVersion #{capability.fhirVersion.inspect}" unless capability.fhirVersion == "5.0.0" + '; then + exit 0 + fi + + echo "Waiting for Spark R5 metadata (attempt ${attempt}/30)" + sleep 5 + done + + echo 'Spark R5 metadata did not become ready in time' >&2 + exit 1 + - + name: Run R5 tests + id: suite + run: | + set +e + docker compose -f docker-compose.yml -f docker-compose-r5.yml run --rm --no-deps plan_executor \ + ./execute_all.sh 'http://spark:8080/fhir' r5 'html|json|stdout' + suite_status=$? + printf '%s\n' "$suite_status" > suite-exit-status + echo "suite_status=$suite_status" >> "$GITHUB_OUTPUT" + exit 0 + - + name: Capture service logs + if: ${{ always() }} + run: | + mkdir -p logs + cp spark-revision.txt logs/spark-revision.txt 2>/dev/null || true + docker compose -f docker-compose.yml -f docker-compose-r5.yml logs spark > logs/spark.log 2>&1 || true + docker compose -f docker-compose.yml -f docker-compose-r5.yml logs mongodb > logs/mongodb.log 2>&1 || true + - + name: Combine test results + if: ${{ always() }} + run: | + set +e + ./combine-test-results.sh json_results annotations.json + combine_status=$? + printf '%s\n' "$combine_status" > combine-exit-status + exit 0 + - + name: Attach test results + if: ${{ always() && github.event_name != 'pull_request' }} + uses: yuzutech/annotations-action@v0.6.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + input: annotations.json + - + name: Archive logs + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: logs-r5-${{ github.sha }} + path: logs/*.log* + - + name: Archive test reports + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: html_summaries-r5-${{ github.sha }} + path: html_summaries/**/*.html + - + name: Archive JSON results + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: json_results-r5-${{ github.sha }} + path: json_results/**/*.json + - + name: Archive annotations file + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: annotations-r5-${{ github.sha }} + path: annotations.json + - + name: Evaluate suite status + if: ${{ always() }} + run: | + if [ ! -f suite-exit-status ]; then + echo 'R5 suite did not produce an exit status' >&2 + exit 1 + fi + + suite_status=$(cat suite-exit-status) + combine_status=$(cat combine-exit-status 2>/dev/null || printf '1') + if [ "$suite_status" -ne 0 ]; then + echo "R5 suite exited with status $suite_status" >&2 + exit "$suite_status" + fi + + if [ "$combine_status" -ne 0 ]; then + echo "R5 result combination exited with status $combine_status" >&2 + exit "$combine_status" + fi + - + name: Cleanup + if: ${{ always() }} + run: docker compose -f docker-compose.yml -f docker-compose-r5.yml down diff --git a/Gemfile b/Gemfile index 2b768960..976d5f28 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" -gem 'fhir_dstu2_models', git: 'https://github.com/incendilabs/fhir_dstu2_models.git' -gem 'fhir_stu3_models', git: 'https://github.com/incendilabs/fhir_stu3_models.git' +gem 'fhir_dstu2_models', git: 'https://github.com/incendilabs/fhir_dstu2_models.git', branch: 'master' +gem 'fhir_stu3_models', git: 'https://github.com/incendilabs/fhir_stu3_models.git', branch: 'master' gem 'fhir_client', git: 'https://github.com/incendilabs/fhir_client.git', branch: 'master' gem 'fhir_models', git: 'https://github.com/incendilabs/fhir_models.git', branch: 'master' gem 'activesupport', '>= 6' diff --git a/Gemfile.lock b/Gemfile.lock index d25799ad..938395b3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,23 +1,24 @@ GIT remote: https://github.com/incendilabs/fhir_client.git - revision: 79026641f9b2ac7cf30bc27a3528e505d34c67e8 + revision: e32abec25f8d6ca1b6997d159c263bb70f54abfd branch: master specs: - fhir_client (5.0.0) + fhir_client (5.1.0) activesupport (>= 3) addressable (>= 2.9.0) fhir_dstu2_models (>= 1.0.10) - fhir_models (>= 4.0.2) + fhir_models (>= 5.0.0) fhir_stu3_models (>= 3.0.1) nokogiri (>= 1.10.4) - oauth2 (~> 1.1) + oauth2 (>= 2.0, < 3.0) rack (>= 1.5) rest-client (~> 2.0) tilt (>= 1.1) GIT remote: https://github.com/incendilabs/fhir_dstu2_models.git - revision: 66c58438d323f634116dc937446d42d9b4356687 + revision: 4f8b32300c2991d2803b888adff5c097bbfb018e + branch: master specs: fhir_dstu2_models (1.0.10) bcp47 (>= 0.3) @@ -27,10 +28,10 @@ GIT GIT remote: https://github.com/incendilabs/fhir_models.git - revision: a143d2e21d0253b33fdaeb17e2d152ad656c9a3e + revision: 19ed4a6070f08a18b3f839b6b74aba51a7240c67 branch: master specs: - fhir_models (4.1.0) + fhir_models (5.0.0) bcp47 (>= 0.3) date_time_precision (>= 0.8) mime-types (>= 3.0) @@ -38,7 +39,8 @@ GIT GIT remote: https://github.com/incendilabs/fhir_stu3_models.git - revision: 71db01196b6cafe2310498135849cae356fe6f44 + revision: e37cfa7b49518b918727d093c41954be1f1406aa + branch: master specs: fhir_stu3_models (3.0.1) bcp47 (>= 0.3) @@ -63,29 +65,33 @@ PATH GEM remote: https://rubygems.org/ specs: - activesupport (7.2.3.1) + activesupport (8.1.3.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) - minitest (>= 5.1, < 6) + minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) ansi (1.5.0) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) awesome_print (1.9.2) base64 (0.3.0) bcp47 (0.3.3) i18n - benchmark (0.5.0) - bigdecimal (4.1.1) + bigdecimal (4.1.2) coderay (1.1.3) - concurrent-ruby (1.3.7) + concurrent-ruby (1.3.8) connection_pool (3.0.2) coolline (0.5.0) unicode_utils (~> 1.4) @@ -103,26 +109,31 @@ GEM faraday-net_http (3.4.4) net-http (~> 0.5) hashdiff (1.2.1) + hashie (5.1.0) + logger http-accept (1.7.0) - http-cookie (1.1.4) + http-cookie (1.1.6) domain_name (~> 0.5) - i18n (1.14.8) + i18n (1.15.2) concurrent-ruby (~> 1.0) - json (2.20.0) + json (2.21.2) jsonpath (1.1.5) multi_json - jwt (2.10.3) + jwt (3.2.0) base64 logger (1.7.0) method_source (1.1.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0407) + mime-types-data (3.2026.0701) mini_portile2 (2.8.9) - minitest (5.27.0) - multi_json (1.19.1) - multi_xml (0.6.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + multi_json (1.21.1) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) net-http (0.9.1) uri (>= 0.11.1) netrc (0.11.0) @@ -134,20 +145,25 @@ GEM nokogiri-diff (0.3.0) nokogiri (~> 1.5) tdiff (~> 0.4) - oauth2 (1.4.11) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) - multi_json (~> 1.3) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) power_assert (3.0.1) + prism (1.9.0) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) pry-coolline (0.2.6) coolline (~> 0.5) pry (~> 0.13) - public_suffix (6.0.2) + public_suffix (7.0.5) racc (1.8.1) rack (3.2.6) rake (13.3.1) @@ -164,16 +180,20 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) tdiff (0.4.0) test-unit (3.7.7) power_assert - tilt (2.7.0) + tilt (2.8.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) tzinfo-data (1.2026.1) tzinfo (>= 1.0.0) unicode_utils (1.4.0) uri (1.1.1) + version_gem (1.1.14) webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) diff --git a/R5SuiteCompatibility.md b/R5SuiteCompatibility.md new file mode 100644 index 00000000..a7abc32e --- /dev/null +++ b/R5SuiteCompatibility.md @@ -0,0 +1,50 @@ +# FHIR R5 Suite Compatibility Inventory + +Status date: 2026-07-30 + +This is the complete initial R5 audit inventory. It is derived from the 12 +suite classes that currently declare `:r4b` in `supported_versions`. +R4B compatibility is not evidence of R5 compatibility. Every entry began as +`unaudited`; only suites with a completed audit explicitly declare `:r5`. + +| Suite | Current versions | R5 status | Reason | Evidence | +| --- | --- | --- | --- | --- | +| `ConsentSearchByPatientReferenceTest` | STU3, R4, R4B, R5 | compatible | R5 Consent uses `subject`, while the R5 `patient` search parameter resolves patient subjects; the focused endpoint run returned the created Consent. | `R5IncendiSearchRegressionsTest`; `tmp/task-7i/ConsentSearchByPatientReferenceTest.log` | +| `ElementsSearchParameterTest` | STU3, R4, R4B, R5 | conditionally compatible | R5 search with `_elements=name,birthDate` retained `id`, the required `meta.tag` `SUBSETTED` marker, and the requested fields while omitting populated `gender`. The existing read case remains explicitly skipped for Spark #1336. | `R5IncendiSearchRegressionsTest`; `tmp/task-7i/ElementsSearchParameterTest.log`; `tmp/task-7i/ElementsResponseProbe.log` | +| `FhirPathPatchTest` | STU3, R4, R4B, R5 | compatible | R5 FHIRPath Patch Parameters, JSON/XML request and response negotiation, R5 MedicationRequest lifecycle, choice-element syntax, and version-aware stale patch handling audited. | `R5FhirPathPatchSuiteTest`; `tmp/task-7f/FhirPathPatchEndpointAfterSparkFix.log`; `tmp/task-7f/StalePatchProbeAfterSparkFix.log` | +| `FormatTest` | DSTU2, STU3, R4, R4B, R5 | compatible | R5 JSON/XML negotiation, canonical media types, `_format` aliases, request content types, cross-format parsing, and unsupported-media handling audited; targeted R5 endpoint run passed. | `FormatSuiteTest`; `TaskRoutingTest#test_r5_eligibility_is_limited_to_audited_suites`; `tmp/task-7d/FormatTestEndpoint.log`; `tmp/task-7d/FormatContentTypesEndpoint.log` | +| `HistoryTest` | DSTU2, STU3, R4, R4B, R5 | compatible | R5 history, vread, deleted-resource, and error-response behavior audited; targeted R5 endpoint run passed. | `R5ReadHistorySuiteTest`; `TaskRoutingTest#test_r5_eligibility_is_limited_to_audited_suites`; `tmp/task-7b/HistoryTestEndpoint.log` | +| `ReadTest` | DSTU2, STU3, R4, R4B, R5 | compatible | R5 read, conditional-read, response parsing, and lifecycle setup audited; targeted R5 endpoint run passed. | `R5ReadHistorySuiteTest`; `TaskRoutingTest#test_r5_eligibility_is_limited_to_audited_suites`; `tmp/task-7b/ReadTestEndpoint.log` | +| `ResourceTest` | DSTU2, STU3, R4, R4B, R5 | compatible | R5 structure expansion, generated/parsing namespace ownership, and representative unchanged, changed, and R5-only endpoint cases audited. | `R5ResourceSuiteTest`; `tmp/task-7c` endpoint cases; `fhir_models` `67c146c4` | +| `RobustSearchTest` | STU3, R4, R4B, R5 | conditionally compatible | R5 setup and cleanup construct `FHIR::R5::Patient`; its only MPI `$match` case is explicitly skipped for Spark issue #310. | `R5SearchSuiteTest`; `tmp/task-7g/RobustSearchEndpointAfterCurrentRebuild.log` | +| `SearchTest` | DSTU2, STU3, R4, R4B, R5 | compatible | R5 CapabilityStatement parameter names and types are compared with R5 SearchParameter definitions; R5 generic `Resource` parameters and `_summary` are handled explicitly. | `R5SearchSuiteTest`; `tmp/task-7g/SearchEndpointAfterCurrentRebuild.log` | +| `SprinklerSearchTest` | DSTU2, STU3, R4, R4B, R5 | conditionally compatible | R5 parameter types, quantity boundaries, UCUM syntax, chaining, include, unknown and malformed parameters are audited. `_revinclude` remains the existing explicit Spark #307 skip. | `R5SprinklerSearchSuiteTest`; `tmp/task-7h/R5EndpointWithExpressionIncludes.log`; `tmp/task-7h/R4BEndpointWithExpressionIncludes.log` | +| `TransactionAndBatchTest` | DSTU2, STU3, R4, R4B, R5 | conditionally compatible | R5 transaction construction, conditional operations, temporary references, response parsing, and Bundle response types are audited. Five existing Spark issue skips remain for transaction ordering, fetch-and-update, and historical batch cases. | `R5TransactionSuiteTest`; `TaskRoutingTest#test_r5_task_clients_construct_and_audited_suites_are_eligible`; `tmp/task-7e/TransactionAndBatchEndpoint.log`; `tmp/task-7e/BatchEndpointProbe.log` | +| `UnknownSearchParameterTest` | STU3, R4, R4B, R5 | compatible | R5 has `QuestionnaireResponse:based-on`, but not camel-case `basedOn`; GET and POST return a searchset Bundle with a warning `OperationOutcome` entry using `search.mode=outcome`. | `R5IncendiSearchRegressionsTest`; `tmp/task-7i/UnknownSearchParameterTest.log` | + +`supported_versions` is the sole eligibility annotation. The same annotation +controls suite listing, metadata generation, and execution. A suite may add +`:r5` only in the atomic commit that changes its recorded status to +`compatible`, `conditionally compatible`, or `incompatible` and includes the +supporting audit evidence. + +FHIR TestScript artifacts are excluded from this inventory. They use +`supported_versions == [:stu3]`, are loaded only for STU3 clients, and R5 +TestScript task requests are rejected. + +## Final Eligibility Verification + +Task 7J verified the same 12-suite set through `crucible:list_suites[r5]`, +the `crucible:metadata` task, targeted endpoint execution, and a clean +`crucible:execute_all[...,r5,stdout]` run. The focused +`TaskRoutingTest#test_r5_metadata_task_accepts_every_audited_suite` test +invokes metadata generation for each inventory entry; the existing routing +and supported-version tests assert that the listing and executable sets are +identical and that every R4 suite remains R4B-capable. + +The clean aggregate run completed with `3539 PASS`, `0 FAIL`, `0 ERROR`, and +`477 SKIP`. Its evidence is retained in `tmp/task-7j/R5ExecuteAll.log`. +Expected skips remain limited to existing Spark issues: ResourceTest +`$validate` (#205), RobustSearch `$match` (#310), Sprinkler `_revinclude` +(#307), Elements read `_elements` (#1336), and transaction/batch cases +(#304, #305, and #306). FHIR TestScripts remain outside the R5 execution set. diff --git a/R5Verification.md b/R5Verification.md new file mode 100644 index 00000000..b556bf61 --- /dev/null +++ b/R5Verification.md @@ -0,0 +1,671 @@ +# FHIR R5 Harness Verification + +Verification date: 2026-07-28 + +## Scope + +This records the Task 5H routing regression matrix. It verifies the +plan-executor R5 harness against the local R5 implementations in +`fhir_models` and `fhir_client`, while retaining the existing DSTU2 and STU3 +model dependencies. + +Suite compatibility and real-endpoint execution were not part of this task. +They are recorded separately in the Task 7B section below. + +## Source Revisions + +| Repository | Revision | Branch | +| --- | --- | --- | +| `plan-executor` | `105c365ac611ce0dcb35435e67116e6a74c73333` | `add-r5-support` | +| `fhir_models` | `695ea76f0078465d2da8225cbb82be8ba2eec7fd` | `add-r5-support` | +| `fhir_client` | `4273d633730df70bdd58c3f5b14cd595edc04e95` | `add-r5-support` | +| `fhir_stu3_models` | `71db01196b6cafe2310498135849cae356fe6f44` | `master` | +| `fhir_dstu2_models` | `66c58438d323f634116dc937446d42d9b4356687` | `master` | + +## Dependency Provenance + +The committed `Gemfile.lock` was not used as evidence for R5 dependency +resolution. It currently locks the GitHub repositories to pre-R5 revisions: + +- `fhir_models`: `a143d2e21d0253b33fdaeb17e2d152ad656c9a3e` +- `fhir_client`: `79026641f9b2ac7cf30bc27a3528e505d34c67e8` + +For the Docker verification, exact `git archive` snapshots of the revisions +above were copied into a disposable build context under +`tmp/task-5h/r5-docker-context`. Its temporary Gemfile selected all four model +and client repositories with Bundler `path:` dependencies. The resulting +image therefore contains the local R5 work and does not depend on unmerged +GitHub branches. + +The container runs used no sibling source mounts and no `RUBYLIB` override. +The installed sources resolved to: + +```text +fhir_client: /workspace/fhir_client +fhir_models: /workspace/fhir_models +``` + +Updating the committed lockfile to merged GitHub revisions remains Task 8E. + +## Environment + +| Component | Host | Container | +| --- | --- | --- | +| Platform | macOS arm64 | Linux aarch64 | +| Ruby | 3.4.9 | 3.4.9 | +| RubyGems | 3.6.9 | 3.6.9 | +| Bundler | 4.0.10 | 4.0.10 | +| `plan_executor` | 1.8.0 | 1.8.0 | +| `fhir_client` | 5.1.0 | 5.1.0 | +| `fhir_models` | 4.1.0 | 4.1.0 | +| `fhir_stu3_models` | 3.0.1 | 3.0.1 | +| `fhir_dstu2_models` | 1.0.10 | 1.0.10 | +| Docker engine | 29.6.2 | n/a | + +Docker verification image: + +```text +incendi/plan_executor:r5-task-5h-local +sha256:36d0db4e13944dba3871304360e78da7dcade83066a1e1935ceb5c5ecb51f3d8 +``` + +## Results + +| Check | Tests | Assertions | Failures | Errors | Omissions | Exit | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Focused Task 5 routing matrix | 160 | 486 | 0 | 0 | 0 | 0 | +| Unchanged R4B and R4 routing regressions | 1,118 | 3,414 | 0 | 0 | 0 | 0 | +| Complete unit suite in Docker | 1,271 | 3,865 | 0 | 0 | 0 | 0 | +| R5 namespace and structure gate in Docker | 15 | 62 | 0 | 0 | 0 | 0 | + +The focused matrix covered version parsing, structure generation and lookup, +fixtures, R5 routing, task routing, TestScript version handling, metadata, and +supported-version behavior. + +The unchanged R4B and R4 regression selection covered version routing, R4B +routing, format handling, and resource generation. + +## R5 Purity And Reproducibility + +The Docker R5 gate verifies: + +- explicit clients, suites, resource lookup, parsing, and structure ownership + resolve through `FHIR::R5`; +- generated Patient and Bundle object graphs contain only `FHIR::R5` model + classes; +- parsed Patient, Bundle, CapabilityStatement, and OperationOutcome resources + use R5 classes; +- the structure index contains each of the 158 concrete R5 resources exactly + once, includes representative R5-only resources, and excludes resources + removed after R4B; +- the pinned official definitions archive regenerates the checked-in structure + artifact byte-for-byte on two consecutive generations. + +Pinned artifacts: + +| Artifact | SHA-256 | +| --- | --- | +| `tmp/task-5c/r5-definitions.json.zip` | `df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c` | +| `lib/FHIR_structure_r5.json` | `fa26a3b092cfa9232765d0e3042e03331535a985856957cfd6aefed3a15b9225` | + +The zero-omission Docker result confirms that the archive-backed +reproducibility test ran rather than taking its missing-archive omission path. + +## Commands + +Focused tests were loaded together with: + +```sh +ruby -Ilib -Itest -e \ + 'files = ARGV.dup; ARGV.clear; files.each { |file| require File.expand_path(file) }' \ + test/unit/fhir_version_test.rb \ + test/unit/fhir_structure_generator_test.rb \ + test/unit/fhir_structure_test.rb \ + test/unit/r5_structure_test.rb \ + test/unit/fixture_selection_test.rb \ + test/unit/fixtures_test.rb \ + test/unit/r5_routing_test.rb \ + test/unit/task_routing_test.rb \ + test/unit/testscript_version_test.rb \ + test/unit/metadata_test.rb \ + test/unit/supported_versions_test.rb +``` + +The unchanged R4B and R4 regression selection used: + +```sh +ruby -Ilib -Itest -e \ + 'files = ARGV.dup; ARGV.clear; files.each { |file| require File.expand_path(file) }' \ + test/unit/fhir_version_test.rb \ + test/unit/r4b_routing_test.rb \ + test/unit/format_suite_test.rb \ + test/unit/resource_generator_test.rb +``` + +The self-contained Docker image ran the complete suite with: + +```sh +bundle exec ruby -Itest -e \ + 'Dir["test/unit/**/*_test.rb"].sort.each { |file| require File.expand_path(file) }' +``` + +## Task 7B: Read And History Suite Verification + +Verification performed: 2026-07-29. + +`ReadTest` and `HistoryTest` were audited against FHIR R5 read, conditional +read, vread, delete, and history behavior. They now explicitly advertise +`:r5`; the other ten R4B-capable suites remain unaudited and ineligible for +R5. + +Ruby-level checks used rbenv Ruby 3.4.9 and the visible +`tmp/task-7b/Gemfile`, whose `path:` dependencies select the local sibling +`fhir_models`, `fhir_client`, `fhir_stu3_models`, and +`fhir_dstu2_models` checkouts. No FHIR implementation was resolved from +GitHub. + +| Verification | Result | +| --- | --- | +| `test/unit/r5_read_history_suite_test.rb` | 4 tests, 11 assertions, 0 failures, 0 errors, 0 omissions | +| `test/unit/supported_versions_test.rb` | 8 tests, 11 assertions, 0 failures, 0 errors, 0 omissions | +| `TaskRoutingTest#test_r5_eligibility_is_limited_to_audited_suites` | 1 test, 2 assertions, 0 failures, 0 errors, 0 omissions | +| R5 `ReadTest` endpoint run | 6 pass, 0 fail, 0 error, 0 skip | +| R5 `HistoryTest` endpoint run | 11 pass, 0 fail, 0 error, 0 skip | +| R4B `ReadTest` regression endpoint run | 6 pass, 0 fail, 0 error, 0 skip | +| R4B `HistoryTest` regression endpoint run | 11 pass, 0 fail, 0 error, 0 skip | + +The endpoint runs used the local-source Task 6G image +`incendi/plan_executor:r5-task-6g-local` with the Task 7B suite files mounted +for execution, against the user-built `sparkfhir/spark:r5-latest` and +`sparkfhir/mongo:r5-latest` images. The raw logs and the captured deletion +history payload are retained locally under `tmp/task-7b/` and are not +committed. + +The audit makes the following R5-specific behavior explicit: + +- Conditional read accepts either a full `200` response or `304 Not Modified`. +- A deleted resource's ordinary read and version read expect `410 Gone`. +- A history deletion entry has no resource body; its request URL carries the + version identifier used for the deleted-resource vread assertion. +- A `404` history response may carry an R5 `OperationOutcome` and must parse + through the selected R5 model namespace. +- `_summary=text` must parse as R5. The suite warns, rather than fails, when a + server returns full content without a narrative because servers may ignore a + requested summary form. + +These conditions follow the R5 [HTTP interaction +rules](https://hl7.org/fhir/R5/http.html) and [search summary +rules](https://hl7.org/fhir/R5/search.html). + +An additional direct R4 endpoint attempt could not start the locally supplied +R4 Spark image: it was configured to require an HTTPS certificate that was +not present. This was an environment startup limitation, not a suite result; +the R4B regression runs above completed successfully. + +## Task 7G: General Search Suite Verification + +Verification performed: 2026-07-29. + +`SearchTest` and `RobustSearchTest` now explicitly advertise `:r5`. The +SearchTest R5 branch compares each endpoint-advertised search parameter name +and type with the R5 SearchParameter definition for that resource, including +the generic `Resource` parameters. `_summary` remains an explicitly allowed +result-control parameter because it is not a SearchParameter resource. + +The focused unit tests use the local sibling R5 model and client repositories +through `tmp/task-7b/Gemfile` and rbenv Ruby 3.4.9: + +| Verification | Result | +| --- | --- | +| `R5SearchSuiteTest` | 5 tests, 25 assertions, 0 failures, 0 errors | +| `SupportedVersionsTest` and `TaskRoutingTest` with `R5SearchSuiteTest` | 22 tests, 82 assertions, 0 failures, 0 errors | +| R5 `SearchTest` endpoint run | 1,092 pass, 0 fail, 0 error, 0 skip | +| R5 `RobustSearchTest` endpoint run | 0 pass, 0 fail, 0 error, 1 explicit Spark #310 skip | + +The endpoint runs used local-source image +`incendi/plan_executor:r5-task7g-local-deps` against +`sparkfhir/spark:r5-task7g-local` and +`sparkfhir/mongo:r5-task7g-local`. Raw output is retained under +`tmp/task-7g/` and is not committed. + +The R5 definition coverage includes representative string, token, reference, +date, number, and quantity parameters. `SearchTest` itself executes only its +existing `_id` and `_count` GET/POST cases; it does not exercise modifiers, +chaining, inclusion, sorting, paging, or arbitrary typed query values. Those +behaviors remain in scope for Task 7H. `RobustSearchTest` contains only the +MPI `$match` case, which remains an explicit skip for +[Spark issue #310](https://github.com/FirelyTeam/spark/issues/310). + +## Task 7H: Sprinkler Search Suite Verification + +Verification performed: 2026-07-29. + +`SprinklerSearchTest` now explicitly advertises `:r5`. Its setup resources +have unique patient names, identifiers, and Observation codes. Each creation +waits for its resource to become searchable through `_id` before test +execution continues. The suite now compares exact resource ID sets and Bundle +totals without relying on entry order or unrelated endpoint data. + +Quantity searches use the full UCUM system and code form, and combine a +unique Observation `code` token with `value-quantity` to keep the precision +and comparator result sets isolated. R5 definitions are covered for the +Patient string/token parameters, Condition reference parameter, Observation +token/quantity parameters, and generic `_id` token. + +| Verification | Result | +| --- | --- | +| `R5SprinklerSearchSuiteTest`, `SupportedVersionsTest`, and `TaskRoutingTest` | 20 tests, 80 assertions, 0 failures, 0 errors | +| R5 `SprinklerSearchTest` endpoint run | 36 pass, 0 fail, 0 error, 2 explicit Spark #307 skips | +| R4B `SprinklerSearchTest` regression endpoint run | 36 pass, 0 fail, 0 error, 2 explicit Spark #307 skips | + +The endpoint checks used `incendi/plan_executor:r5-task7h-local-deps` with the +current suite file mounted, no-cache builds of `sparkfhir/spark:r5-task7h-local` +and `sparkfhir/spark:r4b-task7h-local`, and isolated R4B/R5 Spark and Mongo +containers. Raw logs remain under +`tmp/task-7h/` and are not committed. + +R5 `Condition:patient` `_include` initially returned only the primary +Condition. Spark's include resolver depended on generated `SearchParameter.Path` +metadata, but the Firely R5 model provides FHIRPath expressions and no paths or +XPaths. Spark now evaluates the selected search parameter expression with the same +`ResourceResolver` and FHIRPath symbol-table setup used by indexing. The R5 +regression test proves that the empty-path `Condition:patient` definition includes +the referenced Patient. +`_revinclude` remains the pre-existing explicit +[Spark issue #307](https://github.com/FirelyTeam/spark/issues/307) skip. + +## Task 7C: Resource Suite Verification + +Verification performed: 2026-07-29. + +`ResourceTest` now explicitly advertises `:r5`. It enumerates 156 R5 +CRUD-testable resource classes from the checked-in R5 structure index. The R5 +index contains 158 concrete resources; `OperationOutcome` and `Parameters` +remain intentionally excluded because they are response and operation payload +resources rather than ordinary CRUD targets. This is the pre-existing +cross-version `BaseSuite::EXCLUDED_RESOURCES` policy, not an R4B fallback. + +`test/unit/r5_resource_suite_test.rb` verifies all of the following with local +siblings selected through `tmp/task-7b/Gemfile` and rbenv Ruby 3.4.9: + +- ResourceTest's 156 classes equal the R5 structure-index resources after the + two payload exclusions. +- Every ResourceTest-generated resource and every parsed JSON round-trip graph + stays entirely in `FHIR::R5`. +- R5-only `ActorDefinition`, `ArtifactAssessment`, `GenomicStudy`, + `Permission`, `Requirements`, `TestPlan`, and `Transport` are listed. +- Removed R4B resources `CatalogEntry`, `DeviceUseStatement`, + `DocumentManifest`, `Media`, `RequestGroup`, `ResearchDefinition`, and + `ResearchElementDefinition` have no R5 ResourceTest entry. + +| Verification | Result | +| --- | --- | +| `test/unit/r5_resource_suite_test.rb` | 3 tests, 15,832 assertions, 0 failures, 0 errors, 0 omissions | +| `test/unit/supported_versions_test.rb` | 8 tests, 11 assertions, 0 failures, 0 errors, 0 omissions | +| Focused R5 ResourceTest routing checks | 5 tests, 12 assertions, 0 failures, 0 errors, 0 omissions | +| R5 `ResourceTest_Patient` endpoint run | 15 pass, 3 expected `$validate` TODO skips, 0 fail, 0 error | +| R5 `ResourceTest_MedicationRequest` endpoint run | 15 pass, 3 expected `$validate` TODO skips, 0 fail, 0 error | +| R5 `ResourceTest_ActorDefinition` endpoint run | 15 pass, 3 expected `$validate` TODO skips, 0 fail, 0 error | + +The three endpoint resources cover an unchanged resource (`Patient`), a +shared R5-changed resource (`MedicationRequest`, whose R5 medication element +uses `CodeableReference`), and an R5-only resource (`ActorDefinition`). The +existing ResourceTest behavior intentionally skips `$validate` cases pending +Spark issue 205; no optional interaction was treated as a universal endpoint +requirement. The endpoint logs are retained locally at +`tmp/task-7c/ResourceTestPatientEndpoint.log`, +`tmp/task-7c/ResourceTestMedicationRequestEndpoint.log`, and +`tmp/task-7c/ResourceTestActorDefinitionEndpoint.log`. + +During the endpoint audit, generated `Patient.photo.size` exposed an R5 JSON +wire-format defect in `fhir_models`: R5 `integer64` values must be JSON +strings, but the shared serializer emitted JSON numbers. The prerequisite +`fhir_models` commit `67c146c4 Serialize R5 integer64 values as JSON strings` +keeps integer64 values as Ruby integers internally while serializing their JSON +form as strings. The corrected local file was mounted into the local-source +Task 6G test image for the endpoint runs. The rule is specified by the R5 +[JSON representation](https://hl7.org/fhir/R5/json.html). + +The in-container R5 gate used: + +```sh +R5_DEFINITIONS_ARCHIVE=/sources/r5-definitions.json.zip \ +bundle exec ruby -Itest -e \ + 'ARGV.each { |file| require File.expand_path(file) }' \ + test/unit/r5_routing_test.rb \ + test/unit/r5_structure_test.rb +``` + +Raw logs, numeric exit-status files, source snapshots, and the disposable +Docker build context remain under `tmp/task-5h/` and are not committed. + +The archive snapshots do not contain `.git` metadata, so gemspec evaluation +emits non-fatal `not a git repository` diagnostics. Bundler installation and +all verification commands still exit successfully. + +## R5 Generator Compatibility Adjustments + +Task 6F keeps specification-valid resource generation separate from stricter +wire-format compatibility adjustments. The all-resource audit identified one +such adjustment: + +- The R5 StructureDefinition for + `ImagingSelection.instance.imageRegion2D.regionType` permits `point`, + `polyline`, `interpolated`, `circle`, and `ellipse`. +- The official R5 XML schema applies its shared 3D graphic-type enumeration to + both the 2D and 3D elements. It therefore rejects the otherwise valid 2D + values `interpolated` and `circle`. +- Generated 2D regions are restricted to the specification-valid intersection + `point`, `polyline`, and `ellipse`, while already compatible values are + preserved. + +This is an official-schema compatibility adjustment, not an endpoint-specific +server workaround. No server-specific generator adjustment was added. + +The same audit found no equivalent R5 invariant requirement for +`RequestOrchestration` or `DeviceUsage`. Their R4B predecessors +`RequestGroup` and `DeviceUseStatement` are not resolved from R5 invariant +dispatch. + +## Task 6G: Generator Regression Matrix + +Verification performed: 2026-07-28 through 2026-07-29. + +This matrix verifies that repeated generation remains valid for every +advertised R4, R4B, and concrete R5 resource. Each generated object graph is +checked for generator exceptions, model validation failures, model classes +from another FHIR version, and required elements that were left empty. + +### Local Source Provenance + +The committed top-level `Gemfile` still selects the published GitHub `master` +branches. It was deliberately not used for this pre-merge R5 verification, +because that copy of `fhir_models` does not yet provide the R5 models. + +A disposable Docker build context under `tmp/task-6d/docker-context` copied +the exact local source revisions below. Its temporary `plan-executor/Gemfile` +uses Bundler `path:` dependencies for every sibling model/client repository: + +| Repository | Revision | +| --- | --- | +| `plan-executor` | `10ea167f0a59968ccc926be990b2871c9ecdaf70` | +| `fhir_models` | `aad13e057050c7511c20cab6d24fdd03dba1a39e` | +| `fhir_client` | `4273d633730df70bdd58c3f5b14cd595edc04e95` | +| `fhir_stu3_models` | `71db01196b6cafe2310498135849cae356fe6f44` | +| `fhir_dstu2_models` | `66c58438d323f634116dc937446d42d9b4356687` | + +The tests ran in this self-contained image, with no sibling-source bind mount +or `RUBYLIB` override: + +```text +incendi/plan_executor:r5-task-6g-local +sha256:d09f63ecae581d6ffb0fa88bf63f95f64d20f0f106d83d05adb7fb1fe058b352 +``` + +Inside the image, Bundler resolved `fhir_models` from +`/workspace/fhir_models` and `fhir_client` from `/workspace/fhir_client`. +The image used Ruby 3.4.9, RubyGems 3.6.9, and Bundler 4.0.10. Its source +snapshots intentionally omit `.git` metadata, which causes five non-fatal +`not a git repository` gemspec diagnostics during test startup. + +The R5 definitions archive used by the R5 matrix was: + +| Artifact | SHA-256 | +| --- | --- | +| `tmp/task-5c/r5-definitions.json.zip` | `df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c` | + +### Results + +| Verification | Coverage | Result | +| --- | --- | --- | +| R5 repeated all-resource audit | 158 concrete resources x depths 2, 3, 4 x 2 iterations = 948 cases | 0 failures | +| R4 repeated all-resource audit | 148 resources x depths 2, 3, 4 x 2 iterations = 888 cases | 0 failures | +| R4B repeated all-resource audit | 143 resources x depths 2, 3, 4 x 2 iterations = 858 cases | 0 failures | +| Focused R5 generator tests | 42 tests, 934 assertions | 0 failures, 0 errors, 0 omissions | +| Complete unit suite in Docker | 1,313 tests, 4,793 assertions | 0 failures, 0 errors, 0 omissions | + +All repeated audits used base seed `20260728`. The R5 audit excludes exactly +four abstract model types: `Resource`, `DomainResource`, `CanonicalResource`, +and `MetadataResource`. No concrete R5 resource is intentionally unsupported. +The R4 and R4B audits cover every resource advertised by their respective +`RESOURCES` constants. No verification command reported a skip or omission. + +The R5 audit manifest is at +`tmp/task-6g/R5GenerationAudit/manifest.json`; the R4/R4B report is at +`tmp/task-6g/LegacyGenerationAudit.json`. Raw focused and complete-suite logs +are `tmp/task-6g/FocusedGeneratorSuite.log` and +`tmp/task-6g/FullUnitSuite.log`. These artifacts are retained locally and are +not committed. + +The R5 all-resource format audit that drove Task 6F was also rerun using the +same seed/depth/iteration matrix. It changed from 42 failures across 29 +resources before the invariants to 0 failures across all 948 cases after the +invariants and XML-schema compatibility adjustment. + +The main R5 command was: + +```sh +R5_DEFINITIONS_ARCHIVE=/sources/r5-definitions.json.zip \ +bundle exec rake 'crucible:audit_r5_resource_generation[/evidence/R5GenerationAudit,20260728,2]' +``` + +The complete unit suite command was: + +```sh +bundle exec ruby -Itest -e \ + 'Dir["test/unit/**/*_test.rb"].sort.each { |file| require File.expand_path(file) }' +``` + +## Task 7D: Format Suite Verification + +Verification performed: 2026-07-29. + +`FormatTest` now explicitly advertises `:r5`. The R5 audit adds the canonical +R5 `_format` values `application/fhir+xml` and `application/fhir+json` while +retaining the existing generic XML/JSON aliases and the DSTU2-specific MIME +selection. R5 response equality is strict: it ignores only server-managed +`id`, `meta.versionId`, and `meta.lastUpdated` fields. DSTU2, STU3, R4, and +R4B retain the prior warning-only comparison behavior. + +The existing `FormatSuiteTest` matrix ran under rbenv Ruby 3.4.9 using the +visible `tmp/task-7b/Gemfile` local-path dependencies. It covers STU3, R4, +R4B, and R5 without changing the legacy cases; the added canonical R5 aliases +raise the suite total from 22 to 26 cases for every version. + +| Verification | Result | +| --- | --- | +| `test/unit/format_suite_test.rb` | 4 tests, 27 assertions, 0 failures, 0 errors, 0 omissions | +| `test/unit/supported_versions_test.rb` | 8 tests, 11 assertions, 0 failures, 0 errors, 0 omissions | +| `test/unit/task_routing_test.rb` | 9 tests, 33 assertions, 0 failures, 0 errors, 0 omissions | +| R5 `FormatTest` endpoint run | 26 pass, 0 fail, 0 error, 0 skip | +| R5 default/JSON/XML POST content-type probe | 3 created `FHIR::R5::Patient` resources, each `201 Created` | + +The endpoint suite ran against the user-built +`sparkfhir/spark:r5-latest` and `sparkfhir/mongo:r5-latest` images using the +local-source Task 6G image `incendi/plan_executor:r5-task-6g-local`. It +verified header negotiation, generic and canonical `_format` values, XML and +JSON Bundle responses, cross-format resource equivalence, and the expected +`406 Not Acceptable` behavior for unsupported Accept and `_format` values. +The direct probe additionally verified default JSON, explicit JSON, and +explicit XML `Content-Type` values end-to-end; each response parsed as +`FHIR::R5::Patient`. + +Raw endpoint logs and the retained probe are under `tmp/task-7d/` and are not +committed. The endpoint image source snapshot intentionally omits `.git` +metadata, which accounts for its non-fatal `not a git repository` startup +diagnostics. + +## Task 7E: Transaction And Batch Suite Verification + +Verification performed: 2026-07-29. + +`TransactionAndBatchTest` now explicitly advertises `:r5`. Transaction +assertions require `transaction-response` and batch assertions require +`batch-response` across every supported FHIR version. The generator now +supplies the mandatory R5 +`Condition.clinicalStatus` alongside `verificationStatus`, using the selected +FHIR namespace for both R4B and R5 models. + +`R5TransactionSuiteTest` verifies R5 request construction and parsing for +POST, PUT, DELETE, GET search, conditional create, conditional update, +temporary `urn:uuid` references, failure `OperationOutcome` handling, and +transaction/batch response Bundle distinctions. Its recursive namespace check +rejects response graphs containing non-R5 model instances. + +| Verification | Result | +| --- | --- | +| `test/unit/r5_transaction_suite_test.rb` | 3 tests, 53 assertions, 0 failures, 0 errors | +| R5 `TransactionAndBatchTest` endpoint run | 8 pass, 0 fail, 0 error, 5 existing Spark issue skips | +| Independent R5 batch endpoint probe | `200 OK`, `batch-response`, 2 `201 Created` response entries, both `FHIR::R5::Observation` | + +The endpoint run used the clean local Spark master image containing Spark +commit `955b25e7` (`Engine: Return correct bundle response for +batch/transaction bundles`), plus `sparkfhir/mongo:r5-latest`. The Spark fix +applies to batch and transaction responses across all supported FHIR versions; +this audit verifies its R5 behavior. The five skips remain linked to existing +Spark issues: XFER4, XFER11, and XFER12 (`#305`), XFER5 (`#304`), and XFER10 +(`#306`). The independent batch probe is retained because those historical +batch cases remain skipped; it verifies that a live two-entry R5 batch now +returns one `batch-response` entry per submitted create. + +Raw endpoint logs and the retained probe are under `tmp/task-7e/` and are not +committed. As with prior local-source endpoint runs, the image source snapshot +omits `.git` metadata, causing non-fatal `not a git repository` diagnostics at +startup. + +## Task 7F: FHIRPath Patch Suite Verification + +Verification performed: 2026-07-29. + +`FhirPathPatchTest` now explicitly advertises `:r5`. Its R5 fixture uses the +required `MedicationRequest.medication` `CodeableReference` rather than the +STU3 `medicationCodeableConcept` representation. The PATCH client uses the +requested format for both the Parameters body and `Accept` header, so JSON and +XML patch requests negotiate matching JSON and XML representations. + +`R5FhirPathPatchSuiteTest` executes the suite lifecycle with an R5 client and +verifies R5 Parameters construction, JSON/XML round trips, choice-element +syntax, version changes, and stale-version rejection without a resource +mutation. The stale assertion sends the correct weak ETag form, +`If-Match: W/"[versionId]"`, and accepts the specification-valid `409` or +`412` result. + +| Verification | Result | +| --- | --- | +| `test/unit/r5_fhirpath_patch_suite_test.rb` | 3 tests, 57 assertions, 0 failures, 0 errors | +| R5 `FhirPathPatchTest` endpoint run | 6 pass, 0 fail, 0 error, 0 skip | +| Live version-aware probe | Matching `W/"1"`: `200`; stale `W/"1"`: `409 Conflict` with `FHIR::R5::OperationOutcome`; final status `completed`, final version `2` | + +The endpoint evidence uses a local R5 Spark image built from the shared engine +change that validates the versioned PATCH key before applying the patch. That +change sits in `Libraries/Spark.Engine/Service/FhirService.cs`, so the same +behavior applies to the STU3, R4, R4B, and R5 Spark applications. The R5 +endpoint used `sparkfhir/mongo:r5-latest` and the Task 6G local-source harness +image. + +Raw endpoint logs and the retained probe are under `tmp/task-7f/` and are not +committed. The harness source snapshot omits `.git` metadata, causing its +non-fatal `not a git repository` diagnostics at startup. + +## Task 7I: Incendilabs Search Regression Verification + +Verification performed: 2026-07-30. + +`ConsentSearchByPatientReferenceTest`, `ElementsSearchParameterTest`, and +`UnknownSearchParameterTest` now explicitly advertise `:r5`. Consent setup +uses the created Patient reference. R5 assigns this reference to +`Consent.subject`; STU3, R4, and R4B retain their respective +`Consent.patient` assignment. + +The R5 Consent `patient` search parameter is a `reference` parameter targeting +Patient and its expression includes `Consent.subject`. The focused endpoint +run returned exactly the newly created Consent. + +The `_elements=name,birthDate` search retained the Patient `id`, returned the +required `meta.tag` coding with system +`http://terminology.hl7.org/CodeSystem/v3-ObservationValue` and code +`SUBSETTED`, retained `name` and `birthDate`, and omitted the populated +`gender`. The existing read-with-`_elements` case remains the explicit Spark +#1336 skip. + +The camel-case `QuestionnaireResponse` parameter `basedOn` remains unknown in +R5; the registered parameter is `based-on`. Both GET and POST searches return +a searchset Bundle containing a warning `FHIR::R5::OperationOutcome` entry +with `search.mode=outcome`. + +| Verification | Result | +| --- | --- | +| `test/unit/r5_incendi_search_regressions_test.rb` | 5 tests, 23 assertions, 0 failures, 0 errors, 0 omissions | +| `test/unit/r5_incendi_search_regressions_test.rb`, `test/unit/supported_versions_test.rb`, `test/unit/task_routing_test.rb` | 22 tests, 93 assertions, 0 failures, 0 errors, 0 omissions | +| R5 `ConsentSearchByPatientReferenceTest` endpoint run | 1 pass, 0 fail, 0 error, 0 skip | +| R5 `ElementsSearchParameterTest` endpoint run | 1 pass, 0 fail, 0 error, 1 existing Spark #1336 skip | +| R5 `UnknownSearchParameterTest` endpoint run | 12 pass, 0 fail, 0 error, 0 skip | + +The endpoint runs used isolated local containers on `task7i-r5`: +`sparkfhir/spark:r5-task7h-local` +(`sha256:c476232d559e0f0cd1cbfe8eb7310853e60207eb97428ab20a86e7f1e1cccc9d`), +`sparkfhir/mongo:r5-task7g-local` +(`sha256:27943cfaf58ba5cd790ce0fe1bdd8441816c1a16d1644b224b04251214353fbd`), +and the local-source harness image +`incendi/plan_executor:r5-task7h-local-deps` +(`sha256:1624e45946bc5eed57538177ac669f3418f27dfa9e6082ecac42a8cbff770ba0`). +The raw suite and response-probe logs are retained under `tmp/task-7i/` and +are not committed. + +## Task 7J: Final R5 Suite Eligibility Verification + +Verification performed: 2026-07-30. + +The final R5 eligibility set contains the 12 explicitly audited suite classes: +`ConsentSearchByPatientReferenceTest`, `ElementsSearchParameterTest`, +`FhirPathPatchTest`, `FormatTest`, `HistoryTest`, `ReadTest`, `ResourceTest`, +`RobustSearchTest`, `SearchTest`, `SprinklerSearchTest`, +`TransactionAndBatchTest`, and `UnknownSearchParameterTest`. + +`crucible:list_suites[r5]` reported exactly those suites. The focused +metadata-routing test invokes `crucible:metadata[,r5]` for every entry; +the existing metadata rejection test continues to reject +`ConnectathonPatientTrackTest` for R5. TestScript artifacts remain explicitly +STU3-only and R5 TestScript task requests remain rejected. + +The first complete R5 `ResourceTest` exposed a nondeterministic generator +defect: `MeasureReport.group.stratifier.stratum.component.value[x]` could +select an empty `Range`, which serializes as `{}` and leaves the required +choice absent on the wire. The R5 generator now replaces an empty component +choice with a serializable `valueCodeableConcept`. Its focused invariant test +checks JSON/XML round trips; a 100-report R5 JSON generation probe found zero +invalid reports after the correction. + +| Verification | PASS | FAIL | ERROR | SKIP | +| --- | ---: | ---: | ---: | ---: | +| `ResourceTest` rerun | 2,340 | 0 | 0 | 468 | +| `FhirPathPatchTest` | 6 | 0 | 0 | 0 | +| `ReadTest` | 6 | 0 | 0 | 0 | +| `FormatTest` | 26 | 0 | 0 | 0 | +| `TransactionAndBatchTest` | 8 | 0 | 0 | 5 | +| `HistoryTest` | 11 | 0 | 0 | 0 | +| `SearchTest` | 1,092 | 0 | 0 | 0 | +| `RobustSearchTest` | 0 | 0 | 0 | 1 | +| `ConsentSearchByPatientReferenceTest` | 1 | 0 | 0 | 0 | +| `SprinklerSearchTest` | 36 | 0 | 0 | 2 | +| `ElementsSearchParameterTest` | 1 | 0 | 0 | 1 | +| `UnknownSearchParameterTest` | 12 | 0 | 0 | 0 | +| Clean `execute_all` aggregate | 3,539 | 0 | 0 | 477 | + +`ResourceTest` covers all 156 R5 CRUD-testable resources. Each reports 15 +passes and 3 expected `$validate` skips (Spark #205), for 2,340 passes and +468 skips. `SearchTest` covers those same 156 resources with 7 passes each, +for 1,092 passes. This additional R5 resource coverage is why the R5 pass +total must not be compared directly with R4B's raw total. + +Focused verification ran in +`incendi/plan_executor:r5-task7h-local-deps` +(`sha256:1624e45946bc5eed57538177ac669f3418f27dfa9e6082ecac42a8cbff770ba0`) +against clean `sparkfhir/spark:r5-task7h-local` and +`sparkfhir/mongo:r5-task7g-local` containers. The endpoint CapabilityStatement +reported FHIR `5.0.0`. The focused MeasureReport invariant suite passed with +17 tests and 313 assertions; the metadata routing suite passed with 10 tests +and 69 assertions. Their final combined Docker run, including the unchanged +R4-to-R4B eligibility parity assertion, passed with 35 tests and 394 +assertions. The retained raw evidence is under `tmp/task-7j/` and is not +committed. diff --git a/README.md b/README.md index 6bb55fd2..0268dcf9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Plan Executor [![Build Status](https://travis-ci.org/fhir-crucible/plan_executor.svg?branch=master)](https://travis-ci.org/fhir-crucible/plan_executor) Plan Executor runs test suites against a FHIR server. The harness recognizes -`DSTU2`, `STU3`, `R4`, and `R4B` versions of FHIR. Each suite declares its +`DSTU2`, `STU3`, `R4`, `R4B`, and `R5` versions of FHIR. Each suite declares its supported versions explicitly; recognizing a version does not make every suite compatible with it. Commands that execute suites require this version argument; omission is an @@ -20,38 +20,88 @@ $ bundle exec rake -T ## Listing Test Suites List all available Test Suites, excluding supported `TestScripts`. Pass the -version, which can be `dstu2`, `stu3`, `r4`, or `r4b`. Only suites explicitly -annotated for the selected version are listed. +version, which can be `dstu2`, `stu3`, `r4`, `r4b`, or `r5`. Only suites +explicitly annotated for the selected version are listed. ``` -$ bundle exec rake crucible:list_suites[dstu2] -$ bundle exec rake crucible:list_suites[r4b] +$ bundle exec rake "crucible:list_suites[dstu2]" +$ bundle exec rake "crucible:list_suites[r4b]" +$ bundle exec rake "crucible:list_suites[r5]" ``` ## Executing a Test Suite -Crucible tests can be executed by suite from the command-line by calling the `crucible-execute` rake task with the following parameters: +Crucible tests can be executed by suite from the command line by calling the +`crucible:execute` Rake task with the following parameters: * `url` the FHIR endpoint -* `version` the FHIR version (sequence): `dstu2`, `stu3`, `r4`, or `r4b`. +* `fhir_version` the explicit FHIR version: `dstu2`, `stu3`, `r4`, `r4b`, or + `r5` * `test` the name of the test suite (see `crucible:list_suites`) -* `resource` (optional) limit the `test` (applicable to "ResourceTest" or "SearchTest" suites) to a given resource (e.g. "Patient") +* `resource` (optional) limit `ResourceTest` or `SearchTest` to a resource such + as `Patient` +* `output` (optional) a pipe-separated selection of `html`, `json`, and + `stdout` -Run a R4 Suite limited by Resource +Run an R4 Suite limited by Resource ``` -$ bundle exec rake crucible:execute[http://hapi.fhir.org/r4,r4,ResourceTest,Patient] +$ bundle exec rake "crucible:execute[http://hapi.fhir.org/r4,r4,ResourceTest,Patient]" ``` Run a STU3 Suite limited by Resource ``` -$ bundle exec rake crucible:execute[http://hapi.fhir.org/baseDstu3,stu3,ResourceTest,Patient] +$ bundle exec rake "crucible:execute[http://hapi.fhir.org/baseDstu3,stu3,ResourceTest,Patient]" ``` Run a DSTU2 Suite ``` -$ bundle exec rake crucible:execute[http://hapi.fhir.org/baseDstu2,dstu2,TransactionAndBatchTest] +$ bundle exec rake "crucible:execute[http://hapi.fhir.org/baseDstu2,dstu2,TransactionAndBatchTest]" ``` +## R5 Harness Support + +R5 is an explicit harness version, not an alias for R4 or R4B. Registering it +does not enable any test suite automatically. A suite is eligible for R5 only +when its `supported_versions` includes `:r5`; listing, execution, and metadata +generation all use that annotation. Use the listing task to discover the +currently eligible suites: + +``` +$ bundle exec rake "crucible:list_suites[r5]" +$ bundle exec rake "crucible:list_all[r5]" +``` + +Supply `r5` explicitly when executing an eligible suite or generating its +metadata. Replace `EligibleSuite` with a suite returned by the listing task: + +``` +$ bundle exec rake "crucible:execute[https://server.example/fhir,r5,EligibleSuite]" +$ bundle exec rake "crucible:execute_all[https://server.example/fhir,r5,html|json|stdout]" +$ bundle exec rake "crucible:metadata[EligibleSuite,r5]" +``` + +FHIR TestScript tasks remain STU3-only. Passing `r5` to +`crucible:execute_all_testscripts` or `crucible:testreport` is rejected rather +than being routed through another FHIR version. + +The R5 specification navigation index is checked in at +`lib/FHIR_structure_r5.json`. It is generated from the official +`https://hl7.org/fhir/R5/definitions.json.zip` archive, pinned to SHA-256 +`df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c`. +The generator verifies the checksum and reads the exact archive entry +`profiles-resources.json`. + +Regenerate the checked-in index from a repository-local download: + +``` +$ mkdir -p tmp/r5-structure +$ curl --fail --location --output tmp/r5-structure/r5-definitions.json.zip https://hl7.org/fhir/R5/definitions.json.zip +$ bundle exec rake "crucible:generate_r5_structure[tmp/r5-structure/r5-definitions.json.zip]" +$ git diff --exit-code -- lib/FHIR_structure_r5.json +``` + +The downloaded archive is a source input and is not committed. + ## Adding a New Test Suite 1. Fork the repo diff --git a/docker-compose-r5.yml b/docker-compose-r5.yml new file mode 100644 index 00000000..31a41276 --- /dev/null +++ b/docker-compose-r5.yml @@ -0,0 +1,5 @@ +services: + spark: + image: sparkfhir/spark:r5-latest + mongodb: + image: sparkfhir/mongo:r5-latest diff --git a/docs/R5.md b/docs/R5.md new file mode 100644 index 00000000..fa3197e5 --- /dev/null +++ b/docs/R5.md @@ -0,0 +1,2483 @@ +# R5 Support Action Points + +## Goal + +Add explicit FHIR R5 `5.0.0` support across: + +- `../fhir_models` +- `../fhir_client` +- this `plan-executor` repository + +Use the R4B implementation as the architectural baseline, while treating R5 as +a separate specification version with its own generated models, definitions, +schemas, routing, fixtures, compatibility annotations, and endpoint +verification. + +## Design Rules Carried Forward From R4B + +- Treat `:r4`, `:r4b`, and `:r5` as distinct versions. +- Expose R5 models through `FHIR::R5::*`. +- Never allow R5 to resolve through R4 or R4B fallback behavior. +- Keep version selection explicit at client, task, structure, fixture, and + resource-generator boundaries. +- Omitted and unknown versions must fail fast. +- Keep suite `supported_versions` annotations explicit. R4 or R4B support does + not imply R5 support. +- Normative status does not imply cross-version compatibility. +- Keep R5 models in `fhir_models`; do not create a separate R5 model gem. +- Preserve the existing top-level `FHIR::*` R4 API and the `FHIR::R4B::*` + namespace. +- Keep downloaded source artifacts separate from generated artifacts. +- Check generated Ruby models, runtime definitions, and XML schemas into + `fhir_models`. +- Keep generation deterministic and verify that repeated runs produce + byte-identical output. +- Keep TestScript execution STU3-only unless R5 TestScripts are deliberately + introduced and audited separately. + +## Source Artifact Policy + +Keep the downloaded R5 inputs in the visible repository-local working +directory: + +```text +../fhir_models/tmp/r5-sources/ +``` + +Do not add this directory to `.gitignore` automatically. The source files are +working inputs and are not intended to be committed. The generated outputs are +committed under `lib/fhir_models/`. + +The following official R5 `5.0.0` inputs have been downloaded and verified: + +| Input | Official URL | SHA-256 | +| --- | --- | --- | +| JSON definitions | `https://hl7.org/fhir/R5/definitions.json.zip` | `df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c` | +| Required-binding expansions | `https://hl7.org/fhir/R5/hl7.fhir.r5.expansions.tgz` | `f2fe00691fd8dfbe39193b46fb3988d8e128b6a37e1d654454de065ef675c32b` | +| Global extensions | `https://packages2.fhir.org/packages/hl7.fhir.uv.extensions.r5/1.0.0` | `b60edfadff29ef16a5a253083f33b1c6f83646b3cda1691745453162edbd86b9` | +| XML schemas | `https://hl7.org/fhir/R5/fhir-all-xsd.zip` | `99ec737f19b257de339148f8a7d42f78fcb193ea84e09fee916e25786a138835` | + +The official [R5 downloads page](https://hl7.org/fhir/R5/downloads.html) +identifies the JSON definitions as the preferred code-generation source and +publishes the expansion package and validation schemas separately. + +## Confirmed R5 Input Differences + +These differences must be handled deliberately instead of copying the R4B +generation task unchanged: + +- R4B definition files are under `definitions.json/` inside the ZIP. R5 places + them at the ZIP root. +- The R5 definitions ZIP does not contain `extension-definitions.json`. +- R5 moved globally defined extensions out of core into the independently + versioned `hl7.fhir.uv.extensions.r5` package. +- R4B uses one `expansions.json` Bundle as input. R5 publishes an NPM package + containing 773 individual `ValueSet-*.json` files. +- R4B XSD files are under `fhir-all-xsd/` inside the ZIP. R5 XSD files are at + the ZIP root. +- R5 adds the `integer64` primitive. +- R5 adds the complex datatypes `Availability`, `ExtendedContactDetail`, + `MonetaryComponent`, and `VirtualServiceDetail`, with other datatype list + changes that must come from generated metadata. +- The R5 archive contains 158 concrete resources. +- R5 adds 24 resources relative to R4B and removes or replaces seven R4B + resources. + +R5-only resources relative to R4B: + +```text +ActorDefinition +ArtifactAssessment +BiologicallyDerivedProductDispense +ConditionDefinition +DeviceAssociation +DeviceDispense +DeviceUsage +EncounterHistory +FormularyItem +GenomicStudy +ImagingSelection +InventoryItem +InventoryReport +NutritionIntake +Permission +RequestOrchestration +Requirements +SubstanceNucleicAcid +SubstancePolymer +SubstanceProtein +SubstanceReferenceInformation +SubstanceSourceMaterial +TestPlan +Transport +``` + +R4B resources absent from R5: + +```text +CatalogEntry +DeviceUseStatement +DocumentManifest +Media +RequestGroup +ResearchDefinition +ResearchElementDefinition +``` + +## Architecture Decision + +### R5 Extension Definitions + +R5 core no longer includes the global extension registry. R5 runtime +definitions will include the release-aligned +`hl7.fhir.uv.extensions.r5#1.0.0` package so the existing Definitions API +continues to expose global extensions. + +The package identity, version, declared FHIR version, and source checksum are +validated independently from FHIR core `5.0.0`. Generation never selects the +latest available extension package implicitly because extension packages evolve +independently from the core specification. + +## Task 1: Generalize R5 Generation Inputs In `fhir_models` + +### Task 1A: Generalize Definition ZIP Layouts + +- [x] Refactor `FHIR::Boot::JsonDefinitions` so the archive entry root is + explicit configuration rather than the hard-coded `definitions.json/` + prefix. +- [x] Reject missing and ambiguous entries instead of searching the archive + loosely. +- [x] Support both the existing R4B nested layout and the R5 ZIP-root layout. +- [x] Keep the R4B task configured with its current entry root and checksum. + +Tests that must land in this commit: + +- [x] Verify an explicitly configured nested entry root reads the R4B fixture + archive. +- [x] Verify an explicitly configured ZIP-root layout reads the R5 fixture + archive. +- [x] Verify missing, duplicate, and incorrectly rooted entries fail clearly. +- [x] Verify the reader does not silently select a similarly named entry from + another directory. +- [x] Verify incorrect source checksums fail before archive contents are used. +- [x] Verify existing R4B definition generation remains byte-identical. + +Atomic commit: + +```text +Support versioned FHIR definition archive layouts +``` + +### Task 1B: Add Deterministic Expansion Package Ingestion + +- [x] Add a reader for FHIR NPM `.tgz` packages. +- [x] Validate package name, package version, declared FHIR versions, and source + checksum before accepting resources. +- [x] Read only `ValueSet-*.json` resources from + `hl7.fhir.r5.expansions#5.0.0`. +- [x] Reject malformed resources, duplicate canonical URLs, and unexpected + resource types. +- [x] Combine the individual ValueSets into a deterministic runtime + `expansions.json` Bundle. +- [x] Define stable entry ordering and stable Bundle metadata. +- [x] Keep the existing R4B standalone expansion Bundle path unchanged. + +Tests that must land in this commit: + +- [x] Verify the expected package name, version, FHIR version, and checksum are + accepted. +- [x] Verify mismatched package metadata and checksums fail before output is + written. +- [x] Verify malformed JSON, non-ValueSet resources, duplicate canonical URLs, + and unsafe archive paths are rejected. +- [x] Verify only the intended `package/ValueSet-*.json` entries are included. +- [x] Verify nested expansion content is preserved. +- [x] Verify stable ordering is independent of archive entry order. +- [x] Verify repeated package conversion produces byte-identical output. +- [x] Verify the existing R4B standalone expansion Bundle path remains + unchanged. + +Atomic commit: + +```text +Add deterministic FHIR expansion package ingestion +``` + +### Task 1C: Implement The Extension Source Decision + +This task starts only after the R5 extension-definition decision is settled. + +If an extension package is included: + +- [x] Reuse the NPM package reader where practical. +- [x] Pin and validate the exact extension package independently from R5 core. +- [x] Extract only applicable R5 `StructureDefinition` extension resources. +- [x] Build a deterministic runtime `extension-definitions.json` Bundle. + +Tests that must land in the package-backed commit: + +- [x] Verify the expected extension package identity, version, FHIR + compatibility, and checksum. +- [x] Verify only extension `StructureDefinition` resources applicable to R5 + are included. +- [x] Verify profiles, examples, unrelated conformance resources, duplicate + canonicals, and incompatible FHIR versions are rejected or excluded by an + explicit rule. +- [x] Verify stable ordering and byte-identical repeated generation. +- [x] Verify existing R4 and R4B extension providers remain unchanged. + +Core-only alternative (not selected): + +- Make extension definitions explicitly optional in the source and runtime + providers. +- Preserve the existing R4 and R4B extension behavior. + +Tests that would have been required for the core-only alternative: + +- Verify generation succeeds without an extension source. +- Verify the generated runtime artifact contract clearly represents the + absence of an extension registry. +- Verify extension lookup has a documented empty or unavailable result and + never falls back to R4 or R4B. +- Verify existing R4 and R4B extension providers remain unchanged. + +Atomic commit, depending on the decision: + +```text +Add package-backed FHIR R5 extension definitions +``` + +or: + +```text +Support FHIR definition sets without an extension registry +``` + +### Task 1D: Generalize XML Schema ZIP Layouts + +- [x] Extract the current R4B schema archive handling into a focused, + version-configurable component. +- [x] Support the R4B `fhir-all-xsd/` entry root and the R5 ZIP-root layout. +- [x] Require an explicit expected entry root and source checksum. +- [x] Reject missing schemas, duplicate basenames, path traversal entries, and + archives without `fhir-all.xsd`. +- [x] Keep using `FHIR::Boot::Preprocess.pre_process_schema`. + +Tests that must land in this commit: + +- [x] Verify the configured R4B nested schema layout is extracted. +- [x] Verify the configured R5 ZIP-root schema layout is extracted. +- [x] Verify incorrect checksums, incorrect entry roots, missing + `fhir-all.xsd`, duplicate basenames, and path traversal entries fail clearly. +- [x] Verify only XSD files under the configured entry root are written. +- [x] Verify every written schema is passed through + `FHIR::Boot::Preprocess.pre_process_schema`. +- [x] Verify existing R4B schema generation remains byte-identical. + +Atomic commit: + +```text +Support versioned FHIR XML schema archive layouts +``` + +Task 1 commits contain reusable input plumbing and focused tests only. They do +not include generated R5 models, runtime definitions, or schemas. + +## Task 2: Generate And Check In R5 Model Artifacts + +### Task 2A: Generate The `FHIR::R5` Model Classes + +- [x] Add `FHIR::R5::FHIR_VERSION = '5.0.0'`. +- [x] Add pinned source checksums and source metadata. +- [x] Add `FHIR::R5::Model`, `FHIR::R5::Json`, and `FHIR::R5::Xml` parsing. +- [x] Register the R5 namespace from the main `fhir_models` entry point without + changing the top-level R4 API. +- [x] Add a composable `fhir:generate_r5_models` task using explicit source + arguments. +- [x] Generate R5 metadata, datatypes, resources, JSON/XML serializers, and + validation methods under `lib/fhir_models/r5/`. +- [x] Ensure `integer64` parses and serializes as a Ruby `Integer` without + truncation or floating-point conversion. +- [x] Ensure Bundle entries, contained resources, references, and generated + nested types remain entirely in `FHIR::R5`. +- [x] Ensure the generated resource list comes from the R5 definitions rather + than an R4B seed or merge. +- [x] Generate the model output twice from clean directories and compare it + byte-for-byte. + +Tests that must land in this commit: + +- [x] Verify `require 'fhir_models'` exposes `FHIR::R5` without changing R4 or + R4B constants. +- [x] Verify generated primitive, datatype, and resource metadata matches the + R5 StructureDefinitions rather than a copied R4B list. +- [x] Verify all 158 concrete R5 resources and the expected abstract base + resource classes are represented. +- [x] Verify `CatalogEntry`, `DeviceUseStatement`, `DocumentManifest`, `Media`, + `RequestGroup`, `ResearchDefinition`, and `ResearchElementDefinition` are + absent from `FHIR::R5::RESOURCES`. +- [x] Verify representative R5-only resources and datatypes can be + instantiated. +- [x] Verify `integer64` metadata, JSON parsing, XML parsing, and serialization + preserve values outside the 32-bit integer range. +- [x] Verify a generated Bundle entry and contained resource are + `FHIR::R5` instances. +- [x] Verify generated files load without syntax errors and contain no + accidental `FHIR::R4B` class references. +- [x] Verify missing source arguments and incorrect source checksums fail + before output is replaced. +- [x] Run focused R4 and R4B model-loading regressions. + +Atomic commit: + +```text +Add generated FHIR R5 models +``` + +### Task 2B: Generate R5 Runtime Definitions + +- [x] Add `FHIR::R5::Definitions` configured against + `lib/fhir_models/definitions/r5/`. +- [x] Add a composable `fhir:generate_r5_definitions` task. +- [x] Add `fhir:generate_r5` as the reproducible orchestration task for model + classes and runtime definitions. +- [x] Generate and check in StructureDefinitions, profiles, search parameters, + ValueSets, expansions, and `version.info`. +- [x] Generate `extension-definitions.json` according to the Task 1C decision. +- [x] Verify `version.info` is R5 `5.0.0` before loading definitions. +- [x] Ensure returned definitions are `FHIR::R5::StructureDefinition` + instances. +- [x] Preserve terminology lookup, profile lookup, dynamic profile generation, + and search-parameter behavior through the shared Definitions API. +- [x] Generate runtime definitions twice from clean directories and compare + them byte-for-byte. + +Tests that must land in this commit: + +- [x] Verify every required runtime artifact exists and is a preprocessed FHIR + Bundle where applicable. +- [x] Verify `version.info` reports `5.0.0`. +- [x] Verify missing `version.info`, wrong version metadata, and missing + runtime artifacts fail clearly. +- [x] Verify resource, datatype, profile, and extension lookups return + `FHIR::R5::StructureDefinition` instances. +- [x] Verify dynamic profile generation creates classes under `FHIR::R5`. +- [x] Verify terminology lookup reads the generated expansion Bundle, + including nested expansion entries and inherited code systems. +- [x] Verify search-parameter lookup reads the generated R5 definitions. +- [x] Verify extension lookup follows the Task 1C policy. +- [x] Verify R5 lookup does not fall back to populated R4 or R4B definition + providers when an R5 definition is absent. +- [x] Run the existing R4 and R4B Definitions tests unchanged. + +Atomic commit: + +```text +Add FHIR R5 runtime definitions +``` + +### Task 2C: Generate R5 XML Schema Validation + +- [x] Add `fhir:generate_r5_schema` using the pinned R5 schema archive. +- [x] Generate and preprocess the R5 XML schema set under + `lib/fhir_models/definitions/r5/schema/`. +- [x] Route `FHIR::R5::Xml.validate` exclusively through the R5 schema root. +- [x] Ensure R5 validation never falls back to the R4 or R4B schema set. +- [x] Verify at least one R5-only resource and one changed R5 structure against + the generated schemas. +- [x] Verify malformed R5 XML produces validation errors. +- [x] Generate the schema output twice from a clean directory and compare it + byte-for-byte. + +Tests that must land in this commit: + +- [x] Verify the generated schema directory contains `fhir-all.xsd`, + supporting schemas, and representative R5-only resource schemas. +- [x] Verify a valid R5-only resource passes `FHIR::R5::Xml.validate`. +- [x] Verify an R5 structure changed from R4B validates against R5. +- [x] Verify malformed XML and schema-invalid R5 XML return validation errors. +- [x] Verify the configured R5 schema root is used even while valid R4 and R4B + schema roots are available. +- [x] Verify a missing R5 schema set raises a clear error rather than falling + back to another version. +- [x] Run existing R4 and R4B XML validation tests unchanged. + +Atomic commit: + +```text +Add FHIR R5 XML schema validation +``` + +### Task 2D: Document R5 Model Generation + +- [x] Document the repository-local source directory. +- [x] Document every official source URL, package identity, version, and + checksum. +- [x] Document the exact model, runtime-definition, schema, and aggregate + generation commands. +- [x] Document which downloaded inputs remain uncommitted and which generated + outputs are checked in. +- [x] Document the independently pinned extension-package version and policy. +- [x] Document deterministic regeneration and comparison steps. +- [x] Document the distinction between the `fhir_models` gem version and FHIR + specification version `5.0.0`. + +Atomic commit: + +```text +Document FHIR R5 model generation +``` + +The generated model, runtime-definition, and schema commits remain separate +because each is large, independently reproducible, and independently +reviewable. + +## Task 3: Validate `fhir_models` + +The focused generation tests belong in Tasks 2A through 2C and must land with +the implementation they protect. Task 3 adds broader behavioral and +cross-version acceptance coverage after all generated artifacts are available. + +### Task 3A: Add R5 Serialization Acceptance Coverage + +Acceptance-test implementation: + +- [x] Add a dedicated R5 serialization acceptance test file. +- [x] Add small, reviewable R5 JSON and XML fixtures for an unchanged resource, + a structurally changed resource, and an R5-only resource. +- [x] Add reusable assertions that recursively inspect a resource graph for its + owning model namespace. +- [x] Keep acceptance fixtures separate from generated specification artifacts. + +Tests that must land in this commit: + +- [x] Parse and round-trip representative R5 JSON resources. +- [x] Parse and round-trip representative R5 XML resources. +- [x] Cover at least one unchanged resource, one structurally changed resource, + and one R5-only resource. +- [x] Cover at least one R5-only datatype. +- [x] Cover minimum, typical, and large `integer64` values without + floating-point conversion or truncation. +- [x] Verify primitive extensions survive JSON and XML round trips. +- [x] Verify Bundle entries and contained resources remain entirely within + `FHIR::R5`. +- [x] Verify nested references and choice elements deserialize into R5 classes. + +Atomic commit: + +```text +Add FHIR R5 serialization acceptance coverage +``` + +### Task 3B: Add R5 Runtime Definition Acceptance Coverage + +Acceptance-test implementation: + +- [x] Add a dedicated R5 runtime-definition acceptance test file. +- [x] Add controlled definition fixtures for wrong-version metadata, nested + terminology expansions, inherited code systems, and dynamic profiles. +- [x] Isolate provider configuration and caches so tests cannot pass because a + previous version provider was initialized first. +- [x] Reuse the checked-in R5 runtime artifacts for end-to-end lookup coverage. + +Tests that must land in this commit: + +- [x] Verify resource and datatype definition lookup returns + `FHIR::R5::StructureDefinition`. +- [x] Verify profile lookup and dynamic profile generation use R5 classes. +- [x] Verify search-parameter lookup uses the R5 search parameter definitions. +- [x] Verify terminology lookup uses the generated R5 expansion Bundle. +- [x] Verify nested expansion entries and inherited code systems are handled. +- [x] Verify abstract and inactive terminology entries remain available in the + runtime definitions but are distinguishable from selectable generator codes. +- [x] Verify extension lookup follows the selected Task 1C policy. +- [x] Verify wrong-version definition metadata is rejected. + +Atomic commit: + +```text +Add FHIR R5 runtime definition acceptance coverage +``` + +### Task 3C: Prove Cross-Version Model Isolation + +Acceptance-test implementation: + +- [x] Add a dedicated cross-version isolation test file that loads R4, R4B, and + R5 in the same process. +- [x] Add one explicit R5-versus-R4B structural witness fixture. +- [x] Add recursive namespace assertions for resources, datatypes, Bundle + entries, and contained resources. +- [x] Ensure provider and schema assertions use independently configured roots + for each version. + +Tests that must land in this commit: + +- [x] Assert the R4, R4B, and R5 model classes are distinct. +- [x] Test an R5-only field or changed structure that R4B rejects. +- [x] Test an R4B-only or removed resource is unavailable from `FHIR::R5`. +- [x] Verify R5 parsing never constructs top-level R4 or `FHIR::R4B` objects. +- [x] Verify R4 and R4B parsing never constructs `FHIR::R5` objects. +- [x] Verify R5 definition and schema lookup never falls back to R4 or R4B. +- [x] Verify the R4 top-level API and R4B namespace behavior remain unchanged. + +Completion gate: + +```text +FHIR::Patient != FHIR::R4B::Patient != FHIR::R5::Patient +``` + +Atomic commit: + +```text +Verify FHIR model version isolation +``` + +### Task 3D: Run And Record The Model Regression Matrix + +Verification work: + +- [x] Define the exact commands for the R5, R4B, and R4 model suites. +- [x] Start from clean generated-output comparison directories inside the + repository. +- [x] Run each suite independently so a failure identifies its owning version. +- [x] Preserve command exit status separately from test-framework totals. +- [x] Update verification documentation only after all required commands have + completed. + +Verification required before the evidence commit: + +- [x] Run the complete R5 model test suite. +- [x] Run the complete existing R4B model test suite unchanged. +- [x] Run the complete existing R4 model test suite unchanged. +- [x] Run generation determinism checks from clean output directories. +- [x] Record Ruby version, gem version, FHIR specification versions, source + checksums, test counts, and exact dependency revisions. +- [x] Record any expected skips or known limitations separately from failures. + +Atomic commit, only when results are added to repository documentation: + +```text +Document FHIR R5 model verification +``` + +## Task 4: Add Explicit R5 Routing In `fhir_client` + +### Task 4A: Add Explicit R5 Client Selection + +- [x] Add `use_r5`. +- [x] Map explicit `fhir_version: :r5` to `FHIR::R5`. +- [x] Add R5 to `fhir_namespace`, `versioned_resource_class`, and + resource-class namespace detection. +- [x] Check `FHIR::R5::Model` ancestry before the top-level `FHIR::Model` + fallback. +- [x] Keep R5 on the standard R4-and-later resource format constants unless an + R5-specific format difference is identified. +- [x] Preserve explicit failure for omitted, `:auto`, and unsupported versions + at resource-operation boundaries. + +Tests that must land in this commit: + +- [x] Verify `FHIR::Client.new(url, fhir_version: :r5)` selects `FHIR::R5`. +- [x] Verify `use_r5` and `use_fhir_version(:r5)` select the same namespace and + format behavior. +- [x] Verify `versioned_resource_class(:Patient)` returns + `FHIR::R5::Patient`. +- [x] Verify an R5 class is detected as R5 even though `FHIR::R5::Model` + inherits shared model behavior. +- [x] Verify `:auto` rejects resource-class access before discovery. +- [x] Verify unknown symbols fail rather than falling back to R4. +- [x] Run focused R4, R4B, STU3, and DSTU2 selection regressions. + +Atomic commit: + +```text +Add explicit FHIR R5 client selection +``` + +### Task 4B: Route R5 Responses Through R5 Models + +- [x] Route JSON reply parsing through `FHIR::R5::Json`. +- [x] Route XML reply parsing through `FHIR::R5::Xml`. +- [x] Attach Bundle extras to `FHIR::R5::Bundle`. +- [x] Preserve the R5 namespace across Bundle pagination. +- [x] Parse successful read, search, create, and update responses using the + requested or active R5 resource class. +- [x] Parse R5 OperationOutcome responses without using R4 or R4B classes. +- [x] Preserve response format and FHIR version metadata on client replies. + +Tests that must land in this commit: + +- [x] Verify R5 Patient JSON and XML replies parse as `FHIR::R5::Patient`. +- [x] Verify R5 search responses parse as `FHIR::R5::Bundle` with R5 entry + resources. +- [x] Verify contained resources remain in `FHIR::R5`. +- [x] Verify JSON and XML OperationOutcome responses use + `FHIR::R5::OperationOutcome`. +- [x] Verify paginated `next_bundle` responses remain R5. +- [x] Verify R5 MIME types, generic FHIR MIME types, and explicit format + parameters retain R5 parsing. +- [x] Verify a mismatched or unknown class does not trigger R4 fallback. +- [x] Run focused R4 and R4B reply-parsing regressions. + +Atomic commit: + +```text +Route FHIR R5 client responses through R5 models +``` + +### Task 4C: Support R5 Client Workflows + +- [x] Attach Reference extras to `FHIR::R5::Reference`. +- [x] Resolve relative, absolute, contained, and versioned references through + the R5 namespace and active client. +- [x] Build transaction and batch Bundles with R5 classes. +- [x] Route transaction, operation, patch, patient-record, encounter-record, + and terminology responses through R5. +- [x] Ensure request serialization uses the owning resource namespace rather + than the top-level R4 namespace. + +Tests that must land in this commit: + +- [x] Verify create and update serialize R5 resources and parse R5 responses. +- [x] Verify transaction request and response Bundles contain R5 resources. +- [x] Verify operation and terminology responses parse R5 Parameters or Bundle + resources as appropriate. +- [x] Verify relative, absolute, contained, and versioned R5 references resolve + to R5 classes. +- [x] Verify patient-record and encounter-record helpers return R5 Bundles. +- [x] Verify JSON and XML patch responses remain R5. +- [x] Verify request replay after authentication or retry retains the selected + R5 version. +- [x] Run the existing cross-version workflow tests unchanged. + +Atomic commit: + +```text +Support FHIR R5 client workflows +``` + +### Task 4D: Detect R5 CapabilityStatements Explicitly + +- [x] Map CapabilityStatement `fhirVersion` values in the supported `5.0.x` + technical-correction line to `:r5`. +- [x] Preserve `fhir_version: :auto` as discovery-only until metadata selects a + concrete version. +- [x] Parse the CapabilityStatement with `FHIR::R5` only after R5 has been + identified. +- [x] Reject R5 ballot, snapshot, CI-build, and unknown `5.x` versions unless + they are deliberately supported. +- [x] Preserve the exact R4 `4.0.x` and R4B `4.3.x` mappings. + +Tests that must land in this commit: + +- [x] Detect R5 `5.0.0` from JSON metadata. +- [x] Detect R5 `5.0.0` from XML metadata. +- [x] Verify accepted `5.0.x` technical-correction values map to `:r5`. +- [x] Verify the cached CapabilityStatement is + `FHIR::R5::CapabilityStatement`. +- [x] Verify resource operations remain blocked when discovery fails. +- [x] Reject `5.0.0-ballot*`, `5.0.0-snapshot*`, `5.0.0-cibuild`, `5.1.x`, and + unknown major versions. +- [x] Run existing R4, R4B, STU3, and DSTU2 autodetection tests unchanged. + +Atomic commit: + +```text +Detect FHIR R5 endpoints explicitly +``` + +### Task 4E: Document And Version R5 Client Support + +- [x] Add explicit R5 construction and autodetection examples to the README. +- [x] Document supported FHIR version lines and unsupported prerelease labels. +- [x] Update the changelog with R5 model, parsing, workflow, and discovery + support. +- [x] Update the gem version according to the repository's release policy. +- [x] Keep the `fhir_models` dependency compatible with the merged R5 model + revision. + +Validation required in this commit: + +- [x] Build the gem and verify the expected files are packaged. +- [x] Verify the installed gem loads `FHIR::R5` through its declared + dependencies. +- [x] Verify every README construction example supplies `fhir_version:`. +- [x] Verify no documentation describes R5 as an alias of R4B. + +Atomic commit: + +```text +Document FHIR R5 client support +``` + +### Task 4F: Run And Record The Client Regression Matrix + +Verification work: + +- [x] Define exact commands for the complete client suite and focused + per-version tests. +- [x] Run tests against the merged or explicitly recorded local + `fhir_models` revision. +- [x] Preserve command exit status separately from test-framework totals. +- [x] Record Ruby, Bundler, gem, FHIR model, and dependency versions. + +Verification required before the evidence commit: + +- [x] Run the complete `fhir_client` unit suite. +- [x] Run explicit R5 selection, parsing, workflow, and autodetection tests. +- [x] Run existing R4B, R4, STU3, and DSTU2 tests unchanged. +- [x] Build and install the gem in an isolated repository-local directory. +- [x] Record test counts, expected skips, failures, and exact dependency + revisions. + +Atomic commit, only when results are added to repository documentation: + +```text +Document FHIR R5 client verification +``` + +## Task 5: Add R5 Structure And Version Routing In `plan-executor` + +### Task 5A: Register R5 As A Known Harness Version + +- [x] Add `r5: 'FHIR::R5'` to the central version registry. +- [x] Resolve `r5` and `R5` only to `:r5`; never fall back to top-level R4. +- [x] Ensure class ownership detection checks nested namespaces before `FHIR`. +- [x] Keep TestScript execution explicitly STU3-only. +- [x] Do not add `:r5` to any suite's `supported_versions` yet. + +Tests that must land in this commit: + +- [x] Verify resolution, namespace lookup, and `FHIR::R5::Patient` ownership. +- [x] Verify omitted and unknown versions fail and list R5 among supported + versions. +- [x] Verify R4, R4B, STU3, and DSTU2 routing remains unchanged. +- [x] Verify R5 TestScript execution is rejected. + +Atomic commit: + +```text +Register FHIR R5 as an explicit harness version +``` + +### Task 5B: Generalize FHIR Structure Index Generation + +- [x] Replace R4B-specific constants with immutable per-version configuration + containing the version label, source URL, SHA-256 checksum, archive entry, + category overrides, template, and output. +- [x] Update generator entry points to require an explicit version + configuration and local archive path. +- [x] Require exact ZIP entry paths instead of searching for similarly named + files. +- [x] Preserve deterministic ordering and byte-identical R4B output. +- [x] Include the selected version in checksum, archive-entry, and category + errors. + +Tests that must land in this commit: + +- [x] Cover the nested R4B and ZIP-root R5 archive layouts. +- [x] Reject incorrect checksums, missing entries, malformed categories, and + missing resource categories. +- [x] Verify repeated generation is byte-identical. +- [x] Verify regenerated R4B output matches the checked-in artifact. + +Atomic commit: + +```text +Generalize FHIR structure index generation +``` + +### Task 5C: Generate And Check In The R5 Structure Index + +- [x] Add `crucible:generate_r5_structure[definitions_archive]`. +- [x] Pin `https://hl7.org/fhir/R5/definitions.json.zip` with SHA-256 + `df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c`. +- [x] Read the exact ZIP-root entry `profiles-resources.json`. +- [x] Use the existing R4 structure as the category-tree template, clear its + resource leaves, and repopulate it exclusively from R5 definitions. +- [x] Check in `lib/FHIR_structure_r5.json`; do not check in the downloaded + archive. + +Tests that must land in this commit: + +- [x] Verify the index contains exactly the concrete `FHIR::R5::RESOURCES`, + excluding the abstract `Resource`, `DomainResource`, `CanonicalResource`, + and `MetadataResource` bases. +- [x] Verify all 158 expected concrete resources appear once. +- [x] Verify representative R5-only resources are present. +- [x] Verify removed R4B resources are absent. +- [x] Verify all categories are valid and HTML entities are decoded. +- [x] Verify repeated generation produces no diff. + +Atomic commit: + +```text +Add the generated FHIR R5 structure index +``` + +### Task 5D: Add R5 Fixture Selection And Validation + +- [x] Recognize `.r5.json` and `.r5.xml` fixture overrides. +- [x] Prefer an R5 override over the unversioned base fixture. +- [x] Use the base fixture only when no R5 override exists, then parse it + through `FHIR::R5`. +- [x] Route fixture validation through the explicit version registry instead + of namespace probing with an R4 fallback. +- [x] Produce clear errors for unknown fixture versions or invalid R5 content. + +Tests that must land in this commit: + +- [x] Verify R5 JSON and XML overrides are selected. +- [x] Verify base fallback produces R5 model instances. +- [x] Verify R4B fixtures cannot be selected for an R5 request. +- [x] Verify invalid R5 fixtures fail parsing or validation clearly. +- [x] Run existing fixture-selection and fixture-validation tests unchanged. + +Atomic commit: + +```text +Add FHIR R5 fixture selection and validation +``` + +### Task 5E: Route Harness Model Operations Through R5 + +- [x] Route `BaseTest`, `BaseSuite`, resource lookup, and resource validation + through `FHIR::R5`. +- [x] Route generated resources and nested datatypes through the selected + namespace. +- [x] Parse Bundle, OperationOutcome, CapabilityStatement, and ordinary + resource responses through R5. +- [x] Derive structure lookup from resource class ownership so R5 instances + select the R5 index. +- [x] Remove any remaining implicit "non-STU3 means R4" behavior from shared + paths. + +Tests that must land in this commit: + +- [x] Verify R5 resource lookup and validation. +- [x] Verify generated Patient and Bundle graphs contain only `FHIR::R5` model + objects. +- [x] Verify OperationOutcome and CapabilityStatement parsing returns R5 + classes. +- [x] Verify R4 and R4B objects cannot leak into recursively generated R5 + resources. +- [x] Run existing R4B and R4 routing tests unchanged. + +Atomic commit: + +```text +Route plan-executor through FHIR R5 models +``` + +### Task 5F: Wire R5 Through Harness Tasks And Metadata + +- [x] Accept explicit `r5` arguments in execute, execute-all, custom execution, + listing, and metadata-generation tasks. +- [x] Construct clients with `fhir_version: :r5`. +- [x] Keep suite eligibility determined exclusively by each suite's + `supported_versions`. +- [x] Ensure listing and execution apply the same eligibility rules. +- [x] Keep TestScript tasks STU3-only. + +Tests that must land in this commit: + +- [x] Verify task argument resolution and client construction for R5. +- [x] Verify unknown and omitted task versions fail before execution. +- [x] Verify R5 is known but no suite becomes eligible implicitly. +- [x] Verify list and execute behavior agree for unsupported suites. +- [x] Verify TestScript tasks reject R5. + +Atomic commit: + +```text +Wire FHIR R5 through plan-executor tasks +``` + +### Task 5G: Document Harness-Level R5 Support + +- [x] Add explicit R5 task examples to the README. +- [x] Document that registering R5 does not enable suites automatically. +- [x] Document the checked-in structure artifact and reproducible generation + command. +- [x] Document that FHIR TestScripts remain STU3-only. + +Validation required in this commit: + +- [x] Verify every R5 command supplies the version explicitly. +- [x] Verify documentation never describes R5 as an R4 or R4B alias. +- [x] Verify documented paths and task names match the implementation. + +Atomic commit: + +```text +Document FHIR R5 harness routing +``` + +### Task 5H: Run And Record The Routing Regression Matrix + +Verification work: + +- [x] Run focused version, structure-generator, structure-index, fixture, + routing, task, and metadata tests. +- [x] Run the complete plan-executor unit suite in Docker. +- [x] Verify R5 namespace purity and checked-in structure reproducibility + inside the container. +- [x] Run unchanged R4B and R4 routing regression tests. +- [x] Record Ruby, Bundler, model, client, and plan-executor revisions with + test totals. + +Atomic commit, only when repository documentation records the evidence: + +```text +Document FHIR R5 harness verification +``` + +Suite compatibility remains outside Task 5. Adding `:r5` to individual +`supported_versions` annotations and auditing those suites belongs to the later +suite-compatibility task. + +## Task 6: Audit Resource Generation For R5 + +### Task 6A: Stabilize The Cross-Version Observation Baseline + +- [x] Reproduce the existing nondeterministic Observation Quantity comparator + validation defect across R4 and R4B. +- [x] Identify every generated Observation Quantity location where the + comparator is prohibited by the owning datatype or profile. +- [x] Clear only prohibited comparators; preserve comparators where the + definition permits them. +- [x] Keep the fix namespace-neutral so R4, R4B, and R5 use the same rule where + their definitions agree. + +Tests that must land in this commit: + +- [x] Generate R4 and R4B Observations repeatedly at each audited depth. +- [x] Verify root values, components, and reference ranges never retain an + invalid comparator. +- [x] Verify Quantity elements that permit a comparator remain unaffected. +- [x] Validate every generated Observation against its owning model version. + +Atomic commit: + +```text +Stabilize generated Observation quantities +``` + +### Task 6B: Add A Repeatable R5 Resource Generation Audit + +- [x] Extend the all-resource generator test matrix to every concrete + `FHIR::R5::RESOURCES` entry except abstract base resources. +- [x] Generate each resource repeatedly at the same depth levels used by the + existing R4, STU3, and DSTU2 matrix. +- [x] Report the resource, depth, iteration, exception, validation errors, and + serialized fixture for each failure under the repository-local test output + directory. +- [x] Add recursive checks for owning namespace and required elements that were + left empty. +- [x] Keep the audit deterministic enough to reproduce a reported iteration, + recording any random seed or generated fixture needed for replay. + +Tests that must land in this commit: + +- [x] Verify the audit enumerates every concrete R5 resource exactly once per + configured depth and iteration. +- [x] Verify controlled generator exceptions and validation failures produce + actionable diagnostics. +- [x] Verify namespace contamination and empty required elements fail the + audit. +- [x] Verify successful audit cases leave no error artifacts. + +Atomic commit: + +```text +Add repeatable FHIR R5 resource generation audit +``` + +### Task 6C: Support R5 Primitive And Choice Element Generation + +- [x] Add explicit `integer64` generation within the signed 64-bit FHIR range. +- [x] Ensure generated `integer64` values serialize without floating-point + conversion or precision loss. +- [x] Audit R5 `MULTIPLE_TYPES` metadata and choose only types allowed by the + owning R5 element. +- [x] Ensure exactly one concrete value is populated for each generated choice + element. +- [x] Preserve the existing DSTU2 Quantity choice exception without applying it + to R4, R4B, or R5. + +Tests that must land in this commit: + +- [x] Cover the minimum, maximum, zero, positive, and negative `integer64` + boundaries accepted by the models. +- [x] Verify generated `integer64` JSON and XML round trips preserve the value. +- [x] Cover representative R5 choice elements whose allowed types differ from + R4B. +- [x] Verify unselected choice properties remain empty and generated resources + validate. +- [x] Run existing cross-version primitive and choice tests unchanged. + +Atomic commit: + +```text +Support R5 primitive and choice generation +``` + +### Task 6D: Enforce Selectable R5 Terminology Bindings + +- [x] Resolve R5 bindings through `FHIR::R5::Definitions` and the generated R5 + expansion index. +- [x] Normalize versioned canonical binding URLs before expansion lookup. +- [x] Recursively collect selectable codes while inheriting nested code-system + values. +- [x] Exclude abstract and inactive expansion entries from generated required + bindings. +- [x] Intersect expansion results with each field's own `valid_codes` subset so + cached expansion data cannot broaden the field. +- [x] Preserve the existing behavior for optional external required bindings + that have no locally selectable code. + +Tests that must land in this commit: + +- [x] Verify representative R5 required bindings generate valid selectable + codes. +- [x] Verify abstract, inactive, and nested non-selectable entries are never + generated. +- [x] Verify canonical URLs with and without version suffixes resolve to the + same expansion. +- [x] Verify cache entries remain isolated by namespace, canonical URL, and + field metadata subset. +- [x] Run the existing R4B Questionnaire selectable-code regressions unchanged. + +Atomic commit: + +```text +Enforce selectable R5 terminology bindings +``` + +### Task 6E: Bound Recursive R5 Resource Generation + +- [x] Audit required recursive resources, backbones, datatypes, and references + at each configured generation depth. +- [x] Retain finite generation for required recursive paths without silently + omitting their minimum cardinality. +- [x] Apply the existing `CodeableReference` invariant to R5 using the owning + namespace. +- [x] Populate exactly one of `CodeableReference.concept` or + `CodeableReference.reference` when a generated value would otherwise be + empty. +- [x] Fail with the full element path when a required recursive structure + cannot be generated within the loop guard. + +Tests that must land in this commit: + +- [x] Verify representative direct and indirect R5 recursion terminates at + every audited depth. +- [x] Verify required recursive elements are populated and validate. +- [x] Verify generated R5 `CodeableReference` instances remain entirely in the + R5 namespace. +- [x] Verify an already populated `CodeableReference` is preserved. +- [x] Run existing R4B recursion and `CodeableReference` tests unchanged. + +Atomic commit: + +```text +Bound recursive FHIR R5 resource generation +``` + +### Task 6F: Add R5 Resource-Specific Invariants + +- [x] Classify failures from the all-resource audit by resource and invariant + instead of copying the complete R4 invariant table into R5. +- [x] Add narrowly scoped invariants for every failing R5-only resource and + every changed resource shared with R4B. +- [x] Remove R4B-only resource assumptions such as `RequestGroup` and + `DeviceUseStatement` from R5 dispatch paths. +- [x] Use R5 replacements such as `RequestOrchestration` and `DeviceUsage` only + where their definitions require equivalent handling. +- [x] Construct every invariant-owned datatype through the resource's explicit + namespace. +- [x] Document any server-compatibility adjustment that is stricter than model + validation and keep it separate from specification-valid generation. + +Tests that must land in this commit: + +- [x] Add one focused regression test for every generator defect fixed by an + R5-specific invariant. +- [x] Cover representative R5-only resources and changed R4B-to-R5 resources. +- [x] Verify removed R4B resource constants are never resolved on an R5 path. +- [x] Recursively verify all invariant-created children belong to `FHIR::R5`. +- [x] Verify each corrected resource passes R5 JSON and XML validation. + +Atomic commit: + +```text +Add FHIR R5 resource generation invariants +``` + +### Task 6G: Run And Record The Generator Regression Matrix + +Verification work: + +- [x] Run the complete repeated R5 all-resource matrix with no exceptions, + validation failures, namespace contamination, or empty required elements. +- [x] Run focused primitive, choice, terminology, recursion, and + resource-specific invariant tests. +- [x] Run the unchanged R4B and R4 all-resource matrices. +- [x] Run the complete plan-executor unit suite in Docker. +- [x] Record generation depths, iteration count, seeds, model revisions, test + totals, expected skips, and any intentionally unsupported generation cases. + +Atomic commit, only when repository documentation records the evidence: + +```text +Document FHIR R5 generator verification +``` + +Implementation rule: + +Do not add broad resource exceptions merely to make endpoint tests pass. +Generated resources must either satisfy the R5 definitions or have a narrowly +documented server-compatibility adjustment. + +## Task 7: Audit Suite Compatibility + +### Task 7A: Establish The R5 Suite Compatibility Inventory + +- [x] Record the 12 currently R4B-capable suites as the complete initial R5 + audit inventory. +- [x] Track each suite as unaudited, compatible, conditionally compatible, or + incompatible, with the reason and supporting test evidence. +- [x] Start with no suite implicitly supporting R5. +- [x] Require each suite to add `:r5` explicitly only in the commit that + completes its compatibility audit. +- [x] Keep suite listing and execution eligibility driven by the same + `supported_versions` annotation. +- [x] Keep STU3 TestScript artifacts outside the R5 inventory and execution + path. + +Tests that must land in this commit: + +- [x] Verify the inventory contains exactly the 12 suites currently supporting + R4B. +- [x] Verify registering R5 does not make any suite eligible implicitly. +- [x] Verify listed and executable R5 suite sets are identical. +- [x] Verify TestScript suites remain STU3-only. + +Atomic commit: + +```text +Add the FHIR R5 suite compatibility inventory +``` + +### Task 7B: Audit R5 Read And History Suites + +- [x] Audit `ReadTest` and `HistoryTest` against R5 read, vread, update, + conditional read, delete, and history semantics. +- [x] Verify expected success, not-modified, not-found, and gone response codes + against the R5 REST specification. +- [x] Ensure setup, teardown, response parsing, and version metadata use R5 + model classes. +- [x] Remove assumptions that depend on R4B-specific resource fields or + history Bundle contents. +- [x] Add `:r5` to each suite only after its focused endpoint run passes. + +Tests that must land in this commit: + +- [x] Verify both suites advertise R5 and still advertise their existing + versions. +- [x] Verify mocked R5 read, vread, conditional read, delete, and history + responses are parsed through `FHIR::R5`. +- [x] Verify R5 Bundle entries, deleted resources, and version IDs are asserted + according to R5 semantics. +- [x] Run targeted `ReadTest` and `HistoryTest` against an R5 endpoint. + +Atomic commit: + +```text +Enable R5 read and history suites +``` + +### Task 7C: Audit The R5 Resource Suite + +- [x] Audit `ResourceTest` against every concrete R5 resource exposed by the + checked-in structure index. +- [x] Ensure R5-only resources are included and removed R4B resources are not + instantiated or listed. +- [x] Audit create, read, update, vread, history, search, and delete behavior + without assuming every endpoint implements every optional interaction. +- [x] Distinguish specification-valid generated resources from narrowly + documented endpoint compatibility adjustments. +- [x] Add `:r5` only after representative unchanged, changed, and R5-only + resources pass targeted endpoint runs. + +Tests that must land in this commit: + +- [x] Verify ResourceTest expands to exactly the concrete R5 resource set. +- [x] Verify representative R5-only resources receive ResourceTest cases. +- [x] Verify removed R4B resources receive no R5 ResourceTest cases. +- [x] Verify all ResourceTest-created and parsed objects stay in `FHIR::R5`. +- [x] Run targeted R5 ResourceTest cases for unchanged, changed, and R5-only + resources before the full ResourceTest run. + +Atomic commit: + +```text +Enable the resource suite for FHIR R5 +``` + +### Task 7D: Audit The R5 Format Suite + +- [x] Audit `FormatTest` using R5 JSON and XML media types, `_format` values, + Accept headers, and Content-Type headers. +- [x] Verify response comparison ignores only server-managed fields and does + not hide R5 serialization differences. +- [x] Ensure JSON-to-XML and XML-to-JSON comparisons parse both representations + through `FHIR::R5`. +- [x] Preserve version-specific MIME behavior for DSTU2, STU3, R4, and R4B. +- [x] Add `:r5` only after all focused format cases pass against an R5 endpoint. + +Tests that must land in this commit: + +- [x] Cover R5 JSON and XML requests through headers, `_format`, and default + negotiation. +- [x] Verify returned resources are `FHIR::R5` and equivalent across formats. +- [x] Verify unsupported media types retain the expected R5 response behavior. +- [x] Run the existing per-version FormatTest unit matrix unchanged. +- [x] Run targeted `FormatTest` against an R5 endpoint. + +Atomic commit: + +```text +Enable the format suite for FHIR R5 +``` + +### Task 7E: Audit The R5 Transaction And Batch Suite + +- [x] Audit `TransactionAndBatchTest` against R5 transaction and batch Bundle + rules. +- [x] Verify request methods, request URLs, `fullUrl` references, conditional + operations, response entries, and processing order. +- [x] Check search parameters embedded in transaction requests against R5 + SearchParameter definitions. +- [x] Ensure request and response Bundle graphs remain entirely in the R5 + namespace. +- [x] Add `:r5` only after focused transaction and batch endpoint runs pass. + +Tests that must land in this commit: + +- [x] Cover R5 transaction and batch request construction and response parsing. +- [x] Cover conditional create, conditional update, search, delete, and failed + transaction OperationOutcome handling. +- [x] Verify temporary full URLs and internal references resolve correctly. +- [x] Verify transaction and batch response assertions distinguish their Bundle + types. +- [x] Run targeted `TransactionAndBatchTest` against an R5 endpoint. + +Atomic commit: + +```text +Enable transaction and batch tests for FHIR R5 +``` + +Audit result, 2026-07-29: + +- R5 `XFER0` initially exposed that `ResourceGenerator.minimal_condition` + omitted mandatory R5 `Condition.clinicalStatus`. The helper now populates + `clinicalStatus` and `verificationStatus` through the selected namespace; + R4B and R5 regression tests cover the generated values. +- Spark master commit `955b25e7` returns the correct transaction or batch + response Bundle type across all supported FHIR versions. An independent R5 + two-entry endpoint batch probe verified two `201 Created` response entries + in request order. +- The historical endpoint cases XFER4, XFER5, XFER10, XFER11, and XFER12 + remain explicitly skipped for existing Spark issues `#305`, `#304`, and + `#306`. The suite is therefore conditionally compatible for R5; it is + enabled with the verified coverage recorded in `R5Verification.md`. + +### Task 7F: Audit The R5 FHIRPath Patch Suite + +- [x] Audit `FhirPathPatchTest` against the R5 FHIRPath Patch operation and + Parameters wire format. +- [x] Verify patched resource names, paths, choice values, and status codes are + valid for R5. +- [x] Construct every Parameters part and patch value through `FHIR::R5`. +- [x] Replace the stale-version skip with a complete, specification-backed + assertion. +- [x] Add `:r5` only after JSON and XML patch runs pass against an R5 endpoint. + +Tests that must land in this commit: + +- [x] Verify R5 patch Parameters serialize and parse in JSON and XML. +- [x] Verify successful replacement returns an R5 MedicationRequest with an + updated version ID when supplied by the server. +- [x] Verify stale-version behavior does not silently accept an unintended + update. +- [x] Run targeted `FhirPathPatchTest` against an R5 endpoint. + +Atomic commit: + +```text +Enable FHIRPath patch tests for FHIR R5 +``` + +Audit result, 2026-07-29: + +- The R5-specific `MedicationRequest` fixture must use the required R5 + `medication` `CodeableReference`; the inherited STU3 fixture uses the removed + `medicationCodeableConcept` representation. +- The PATCH client now uses the requested FHIR JSON or XML format for both the + Parameters request body and `Accept` header. The targeted R5 endpoint suite + passes FPP01, FPP02, and FPP03 in both formats: `PASS: 6`. +- A direct version-aware PATCH probe sent the correct stale weak ETag, + `If-Match: W/"1"`, after a first patch advanced the resource to version `2`. + Spark now returns `409 Conflict` with an R5 `OperationOutcome`; the resource + remains `completed` at version `2`. +- The shared Spark engine already receives the versioned key from each + STU3/R4/R4B/R5 controller. `Libraries/Spark.Engine/Service/FhirService.cs` + validates it before applying the patch. The shared-engine correction applies + to every Spark FHIR version. +- Retained evidence: `tmp/task-7f/FhirPathPatchEndpointAfterSparkFix.log` and + `tmp/task-7f/StalePatchProbeAfterSparkFix.log`. + +### Task 7G: Audit The General R5 Search Suites + +- [x] Audit `SearchTest` and `RobustSearchTest` independently against the R5 + SearchParameter resources. +- [x] Build expected parameter sets from R5 definitions rather than copying the + R4B set. +- [x] Verify parameter names, types, modifiers, chaining, inclusion, sorting, + paging, and Bundle assertions used by each suite. +- [x] Keep endpoint-advertised search support separate from parameters defined + by the specification. +- [x] Add `:r5` to each suite only after its focused endpoint run passes. + +Tests that must land in this commit: + +- [x] Verify SearchTest compares CapabilityStatement declarations with R5 + SearchParameter definitions. +- [x] Cover representative string, token, reference, date, number, and quantity + searches supported by the suites. +- [x] Verify RobustSearchTest setup and results remain in `FHIR::R5`. +- [x] Verify unsupported optional searches skip with an explicit reason rather + than passing through R4B assumptions. +- [x] Run targeted `SearchTest` and `RobustSearchTest` against an R5 endpoint. + +Atomic commit: + +```text +Enable general search suites for FHIR R5 +``` + +Audit note: `SearchTest` validates all CapabilityStatement search parameter +names and types against the R5 SearchParameter definitions, and executes its +existing `_id` and `_count` GET/POST search cases. It does not exercise +modifiers, chaining, inclusion, sorting, paging, or arbitrary typed search +values; those are outside this suite's behavioral surface and remain covered +by Task 7H. `RobustSearchTest` only contains MPI `$match`, which remains an +explicit Spark issue #310 skip. + +### Task 7H: Audit The R5 Sprinkler Search Suite + +- [x] Audit every `SprinklerSearchTest` parameter and expected match set against + R5 SearchParameter definitions. +- [x] Recheck quantity prefixes, precision boundaries, UCUM code/system usage, + reference chaining, inclusion, and malformed parameter behavior. +- [x] Make indexing delays and eventual-consistency handling explicit without + weakening result assertions. +- [x] Ensure setup resources and returned Bundle entries remain entirely in + `FHIR::R5`. +- [x] Add `:r5` only after the complete focused suite passes against an R5 + endpoint. + +Tests that must land in this commit: + +- [x] Add focused R5 tests for quantity equality and boundary searches. +- [x] Cover token, string, date, reference, chained, include, unknown, and + malformed parameter cases used by the suite. +- [x] Verify search result IDs and counts independently of Bundle ordering. +- [x] Run the existing R4B quantity-search regressions unchanged. +- [x] Run targeted `SprinklerSearchTest` against an R5 endpoint. + +Atomic commit: + +```text +Enable sprinkler search tests for FHIR R5 +``` + +Audit note: `_revinclude` remains an explicit skip for Spark issue #307. R5 +`Condition:patient` inclusion exposed that all Firely R5 search definitions have +FHIRPath expressions but no generated paths or XPaths. Spark now evaluates the +search parameter expression with the same resolver setup used by indexing. + +### Task 7I: Audit The Incendilabs R5 Search Regressions + +- [x] Audit `ConsentSearchByPatientReferenceTest`, + `ElementsSearchParameterTest`, and `UnknownSearchParameterTest` + independently. +- [x] Verify Consent patient references and the applicable R5 search parameter. +- [x] Verify `_elements` returns requested fields plus fields mandated by R5. +- [x] Verify the unknown-parameter test still uses a name that is unknown in + R5 and checks the R5 searchset OperationOutcome representation. +- [x] Add `:r5` to each suite only after its focused endpoint run passes. + +Tests that must land in this commit: + +- [x] Verify Consent search by Patient reference returns the created R5 + Consent. +- [x] Verify `_elements=name,birthDate` keeps required identity and metadata + fields without returning unrelated populated fields. +- [x] Verify unknown-parameter handling returns the expected R5 Bundle and + OperationOutcome entry semantics. +- [x] Verify all setup, result, and teardown resources stay in `FHIR::R5`. +- [x] Run all three suites against an R5 endpoint. + +Atomic commit: + +```text +Enable Incendilabs search regressions for FHIR R5 +``` + +Follow-up outside this task: + +- [ ] Correct the generated Consent models against the pinned STU3, R4, R4B, + and R5 StructureDefinitions. `Consent.patient` is a DSTU2 element; + `Consent.subject` replaced it in STU3 and remains the element in later + versions. The current generated STU3/R4/R4B artifacts expose `patient`, + which indicates a defect in the model-generation source or element-name + mapping. Trace and correct that defect in its owning model repository in a + separate atomic change, with cross-version model assertions, before + simplifying this harness compatibility branch. + +### Task 7J: Finalize And Verify The R5 Suite Eligibility Set + +- [x] Update the compatibility inventory with the final status and evidence for + all 12 suites. +- [x] Add focused unit tests asserting the exact final R5 suite set. +- [x] Verify suite listing, metadata generation, targeted execution, and full + execution expose the same eligible set. +- [x] Keep incompatible suites excluded with a documented specification or + implementation reason. +- [x] Keep STU3 TestScript artifacts out of the R5 suite run. + +Verification required before the evidence commit: + +- [x] Run every R5-enabled suite individually. +- [x] Run the combined R5 suite set and record per-suite and per-resource + totals. +- [x] Run the unchanged R4B and R4 eligibility tests. +- [x] Record expected skips separately from failures and errors. +- [x] Compare eligible suites and per-resource coverage instead of requiring + equal raw pass totals across R4B and R5. + +Atomic commit, only when repository documentation records the evidence: + +```text +Document FHIR R5 suite compatibility +``` + +R5 has more concrete resources than R4B, so the final R5 pass count is not +expected to equal the R4B count. Compare eligible suites and per-resource +coverage, not raw totals alone. + +## Task 8: Integrate Dependencies In Order + +### Task 8A: Establish Explicit Local Dependency Wiring + +- [x] Record the branch, HEAD, gem version, and locked revision for + `fhir_models`, `fhir_client`, and `plan-executor` before changing dependency + resolution. +- [x] Test `fhir_client` against the sibling `../fhir_models` checkout through + an explicit repository-local Bundler configuration or local Gemfile. +- [x] Test plan-executor against both `../fhir_models` and `../fhir_client` + through explicit Bundler local overrides. +- [x] Keep temporary local manifests and Bundler overrides uncommitted. +- [x] Record every override and its removal command so the final verification + cannot accidentally continue using a sibling checkout. +- [x] Do not use a feature-branch SHA as the final dependency revision because + rebase merging changes the commit identity. + +Validation required before downstream implementation continues: + +- [x] Verify `bundle exec` reports the sibling paths and expected feature-branch + HEADs for both dependencies. +- [x] Verify `FHIR::R5::Patient` loads from the sibling `fhir_models` checkout. +- [x] Verify an explicitly R5-configured client loads from the sibling + `fhir_client` checkout. +- [x] Verify R4 and R4B still load through the same local dependency chain. + +No product commit is created for local-only dependency overrides. If the exact +workflow is added to repository documentation, use: + +```text +Document local FHIR R5 dependency wiring +``` + +### Task 8B: Finalize And Merge `fhir_models` + +- [x] Complete and review all `fhir_models` commits from Tasks 1 through 3. +- [x] Assign a new gem version for the R5-capable model package according to + repository release policy; do not reuse version `4.1.0`. +- [x] Build the gem and verify R5 generated files, runtime definitions, and XML + schemas are included. +- [x] Run the complete model suite before merging. +- [x] Rebase-merge the reviewed branch into `incendilabs/fhir_models` master. +- [x] Fetch master after the merge and record the resulting master SHA and gem + version as the only downstream dependency baseline. + +Merged `fhir_models` baseline: + +```text +master SHA: e19f0ea2709c367f441c31ad01892275645bbe92 +gem version: 5.0.0 +``` + +Validation required before updating `fhir_client`: + +- [x] Install the gem built from merged master into a repository-local isolated + directory. +- [x] Verify the installed gem exposes R4, R4B, and R5. +- [x] Verify the installed package contains no downloaded source archives or + local working files. +- [x] Verify the merged master SHA differs from any superseded feature-branch + SHA where the rebase changed commit identity. + +The version change must be included in the final documentation/versioning +commit from Task 2D. Do not create a second version-only commit in Task 8. + +### Task 8C: Bind `fhir_client` To Merged R5 Models + +- [x] Replace the temporary local model override with the merged + `fhir_models` master source. +- [x] Ensure the client development bundle resolves `fhir_models` from + `https://github.com/incendilabs/fhir_models.git` on `master`. +- [x] Update the client dependency requirement to the R5-capable model gem + version selected in Task 8B. +- [x] Refresh the client lockfile so it records the final merged model SHA, not + a feature-branch SHA. +- [x] Keep the client gem version and changelog update in the Task 4E commit. + +Task 8C resolution record: + +```text +fhir_models source: https://github.com/incendilabs/fhir_models.git +fhir_models branch: master +fhir_models SHA: e19f0ea2709c367f441c31ad01892275645bbe92 +fhir_models version: 5.0.0 +fhir_client version: 5.1.0 +temporary lockfile: ../fhir_client/tmp/task-8c/Gemfile.lock +``` + +Tests that must pass before this commit: + +- [x] Verify Bundler reports the merged model repository, master branch, SHA, + and gem version. +- [x] Run the complete `fhir_client` unit suite. +- [x] Run explicit R5 selection, parsing, workflows, and endpoint-detection + tests. +- [x] Run unchanged R4B, R4, STU3, and DSTU2 client tests. +- [x] Build the client gem and verify its dependency metadata requires the + R5-capable model version. + +Atomic commit: + +```text +Require R5-capable fhir_models in fhir_client +``` + +### Task 8D: Finalize And Merge `fhir_client` + +- [x] Complete and review all `fhir_client` commits from Task 4, including the + merged-model dependency commit from Task 8C. +- [x] Remove every local `fhir_models` override before final verification. +- [x] Run the client regression matrix using only the merged remote + `fhir_models` dependency. +- [x] Rebase-merge the reviewed branch into `incendilabs/fhir_client` master. +- [x] Fetch master after the merge and record the resulting client master SHA, + gem version, and resolved model SHA. + +Merged `fhir_client` baseline: + +```text +master SHA: 23da8eda52a7f338bf28b1c5cd20d2c8cd981431 +gem version: 5.1.0 +resolved fhir_models SHA: e19f0ea2709c367f441c31ad01892275645bbe92 +``` + +Validation required before updating plan-executor: + +- [x] Install the client gem built from merged master into a repository-local + isolated directory. +- [x] Verify the installed client selects and parses R5 through the merged + model dependency. +- [x] Verify no local path appears in the client lockfile or Bundler + configuration used for the final run. +- [x] Verify the recorded client SHA is the post-rebase master SHA. + +No additional commit is created in this subtask; it verifies and merges the +atomic commits from Tasks 4 and 8C. + +### Task 8E: Bind Plan-Executor To Merged Dependencies + +- [x] Remove the plan-executor Bundler overrides for both sibling repositories. +- [x] Resolve `fhir_models` and `fhir_client` from their GitHub master branches. +- [x] Refresh only the relevant `Gemfile.lock` entries after both repositories + have been merged. +- [x] Verify the lockfile records the final post-rebase master SHA and expected + gem version for each dependency. +- [x] Preserve the existing DSTU2 and STU3 model sources and revisions unless a + dependency resolution conflict requires a separately reviewed change. +- [x] Do not commit local paths, temporary Gemfiles, source archives, or + unrelated lockfile churn. + +Tests that must pass before this commit: + +- [x] Verify Bundler resolves both dependencies from the expected GitHub URLs + without local overrides. +- [x] Verify `FHIR::R5`, explicit R5 clients, and R5 harness routing load from + the locked revisions. +- [x] Run focused plan-executor R5 version, fixture, structure, generator, and + suite-eligibility tests. +- [x] Run unchanged R4B and R4 routing tests. +- [x] Build the plan-executor Docker image from the refreshed lockfile. + +Verification notes: + +- `fhir_models` resolves to `5.0.0` at `e19f0ea2709c367f441c31ad01892275645bbe92`. +- `fhir_client` resolves to `5.1.0` at `23da8eda52a7f338bf28b1c5cd20d2c8cd981431`. +- The lockfile changes only those two Git revisions, versions, and the + `fhir_models >= 5.0.0` dependency requirement. DSTU2, STU3, and Rubygems + entries remain unchanged. +- Focused routing/version/eligibility checks pass with 65 tests and 243 + assertions. Structure and generation checks pass with 1,096 tests and 3,337 + assertions. The explicit R5 client smoke test passes. +- `fixtures_test.rb` passes 87 tests and 260 assertions. Versioned fixture + filenames such as `*.r5.xml` are validated using their recognized suffix + rather than their containing directory. +- R5 `integer64` JSON values are intentionally serialized as strings on the + wire while remaining Ruby integers after parsing; the plan-executor test + follows the merged `fhir_models` contract. +- Docker image build verified as `incendi/plan_executor:r5-task-8e-local`. + +Atomic commit: + +```text +Update plan-executor to merged R5 dependencies +``` + +### Task 8F: Verify The Clean Dependency Chain + +- [x] Start from clean checkouts or clean repository-local Bundler install + directories with no local overrides. +- [x] Install `fhir_models`, then `fhir_client`, then plan-executor strictly + from the committed manifests and lockfiles. +- [x] Verify the dependency graph contains one resolved `fhir_models` version + and that `fhir_client` and plan-executor use the same instance. +- [x] Confirm every recorded SHA exists on the expected remote master branch. +- [x] Record Ruby, Bundler, gem versions, repository SHAs, dependency sources, + and test totals. + +Verification required before the evidence commit: + +- [x] Run the complete `fhir_models` suite from merged master. +- [x] Run the complete `fhir_client` suite from merged master. +- [x] Run the complete plan-executor unit suite in Docker from its committed + lockfile. +- [x] Verify R5 namespace smoke tests and R4/R4B regressions in the resulting + image. +- [x] Search committed files for sibling paths and temporary dependency + overrides. + +Task 8F final evidence: + +- The clean Docker run used `incendi/plan_executor:r5-task-8e-local`, image + `sha256:8e27592bbcb3dd62e8edf5e3a58949cfb0894ecef9b3e079ab2a8500b01a5e8f`, + rebuilt from the three Task 8E commits and the committed `Gemfile.lock`. +- The image resolved `fhir_models` 5.0.0 at + `e19f0ea2709c367f441c31ad01892275645bbe92` and `fhir_client` 5.1.0 at + `23da8eda52a7f338bf28b1c5cd20d2c8cd981431` through their GitHub master + sources. Ruby was 3.4.9 and Bundler was 4.0.10. +- The complete Docker unit suite passed with 1,347 tests and 20,871 + assertions, zero failures, errors, and omissions, and exit status 0. The + pinned R5 definitions archive was mounted so the reproducibility test ran. +- The tracked-file audit found no runtime Bundler override or sibling checkout + dependency. Historical `R4B.md` documentation still mentions temporary + local path wiring as architecture context. + +Atomic commit, only when repository documentation records the evidence: + +```text +Document FHIR R5 dependency integration +``` + +Dependency order remains strict: + +1. Merge `fhir_models`. +2. Resolve and merge `fhir_client` against merged `fhir_models`. +3. Resolve plan-executor against both merged repositories. + +Do not leave any repository depending on an unrecorded local checkout or a +superseded feature-branch SHA. + +## Task 9: Add R5 Docker And CI Verification + +The upstream Spark repository already contains: + +```text +.docker/linux/Spark.R5.Dockerfile +.docker/linux/Mongo.R5.Dockerfile +``` + +### Task 9A: Add The R5 Docker Compose Topology + +- [x] Add `docker-compose-r5.yml` as an override of the base Compose + configuration, following the R4B pattern. +- [x] Select `sparkfhir/spark:r5-latest` for the Spark service. +- [x] Select `sparkfhir/mongo:r5-latest` for the MongoDB service. +- [x] Preserve the base service names, network, endpoint, credentials, ports, + result volumes, and plan-executor image. +- [x] Require every R5 command to use both `docker-compose.yml` and + `docker-compose-r5.yml`. +- [x] Do not change the R4, R4B, or STU3 Compose configurations in this commit. + +Validation required in this commit: + +- [x] Run `docker compose config` with the base and R5 files. +- [x] Verify the rendered configuration contains only the expected R5 Spark + and Mongo image tags. +- [x] Verify Spark still connects to the Compose MongoDB service and advertises + `http://spark:8080/fhir`. +- [x] Verify the result directories remain mounted read-write into + plan-executor. +- [x] Verify `up`, `run`, `logs`, and `down` commands use the same file pair. + +Lifecycle validation used project `task9a` and the base plus R5 Compose files +for all four commands. `up`, `run`, `logs`, and `down` all exited successfully; +the resulting containers and network were removed by `down`. + +Atomic commit: + +```text +Add the FHIR R5 Docker Compose configuration +``` + +### Task 9B: Add The Complete R5 CI Workflow + +- [x] Add `.github/workflows/ci-r5.yml`, following the established R4B + workflow without refactoring unrelated workflows. +- [x] Run on manual dispatch, pushes to master, and pull requests for the + `incendilabs/plan-executor` repository. +- [x] Check out plan-executor and build `incendi/plan_executor:latest` from the + current workflow revision. +- [x] Check out `FirelyTeam/spark` explicitly at `master` into the repository + workspace and record the resolved Spark commit SHA. +- [x] Build `sparkfhir/spark:r5-latest` from + `.docker/linux/Spark.R5.Dockerfile`. +- [x] Build `sparkfhir/mongo:r5-latest` from + `.docker/linux/Mongo.R5.Dockerfile`. +- [x] Start Spark with the base and R5 Compose files and wait for metadata using + a bounded retry instead of assuming `docker compose up` means the endpoint is + ready. +- [x] Fetch `http://spark:8080/fhir/metadata`, parse it through `FHIR::R5`, and + require CapabilityStatement `fhirVersion` to equal `5.0.0` before executing + suites. +- [x] Run `execute_all.sh` against `http://spark:8080/fhir` with explicit + version `r5` and outputs `html|json|stdout`. +- [x] Preserve the suite command's exit status independently from result + combination and artifact-upload steps. +- [x] Capture Spark, MongoDB, and plan-executor logs with `if: always()` so + startup and test failures still have diagnostics. +- [x] Combine JSON results into `annotations.json` without masking the suite + exit status. +- [x] Publish R5-named logs, HTML summaries, JSON results, and annotations with + `if: always()`. +- [x] Attach annotations only outside pull-request runs, matching the existing + security boundary. +- [x] Tear down the same base and R5 Compose project with `if: always()`. + +Tests and validation that must pass before this commit: + +- [x] Parse the workflow YAML successfully. +- [x] Verify every Compose invocation uses the same base and R5 file pair. +- [x] Verify both Spark R5 Dockerfiles exist in a fresh Spark checkout. +- [x] Verify artifact names include `r5` and the plan-executor commit SHA. +- [x] Verify a simulated failing suite command still reaches log capture, + result combination, artifact upload, and cleanup while leaving the job + failed. +- [x] Verify the workflow contains no R4, R4B, or STU3 image tag or execution + version in an R5 step. + +Task 9B implementation evidence: + +- `.github/workflows/ci-r5.yml` parses successfully. Every Compose invocation + uses `docker-compose.yml` and `docker-compose-r5.yml`; all artifact names + contain `r5` and `${{ github.sha }}`. +- The workflow records the Spark `master` checkout SHA, builds both R5 Spark + images, waits with 30 bounded attempts, and validates the CapabilityStatement + through `FHIR::R5` before running the suite. +- The local equivalent startup path passed against the available R5 images: + Spark startup, R5 metadata validation, logs capture, and Compose teardown all + exited successfully. +- Failure-status simulation confirms a nonzero suite status remains authoritative + when result combination succeeds or fails; diagnostics, combination, artifact, + evaluation, and cleanup steps are guarded with `always()` as appropriate. +- Full R5 `execute_all.sh` execution and GitHub Actions execution remain Task 9C + verification work. + +Atomic commit: + +```text +Add FHIR R5 integration CI +``` + +### Task 9C: Run And Record The R5 Docker Verification + +Verification work: + +- [x] Build plan-executor from the committed dependency lockfile. +- [x] Build the Spark and MongoDB R5 images from a recorded Spark revision. +- [x] Render and inspect the merged R5 Compose configuration. +- [x] Start the R5 endpoint and verify the CapabilityStatement reports + `5.0.0`. +- [x] Run an R5 namespace smoke test inside the built plan-executor image. +- [x] Run targeted R5 FormatTest, ResourceTest, SearchTest, and + TransactionAndBatchTest cases before the full suite. +- [x] Run the full explicitly eligible R5 suite set. +- [x] Preserve PASS, FAIL, ERROR, SKIP, non-TODO skip, and process exit-status + values separately. +- [x] Verify backend logs, harness logs, HTML summaries, JSON results, and + annotations are all present and correspond to the same run. +- [x] Exercise the workflow through GitHub Actions. +- [x] Compare its totals with the equivalent local Docker run. + +Verification required before the evidence commit: + +- [x] Confirm the local and CI runs use the same effective plan-executor tree, + dependency revisions, Spark revision, Compose files, endpoint URL, and FHIR + version. Independently built local and CI image IDs are recorded separately; + they cannot be identical because the images are built on different hosts. +- [x] Confirm no local Bundler override or sibling checkout is used inside the + plan-executor image. +- [x] Confirm failed commands cannot be reported as successful by the result + combiner or later artifact steps. +- [x] Run unchanged R4B and R4 CI configuration checks. +- [x] Record any expected endpoint-specific skip without weakening suite + eligibility or assertions. + +Task 9C local Docker evidence, 2026-08-01: + +- The refreshed local verification used plan-executor + `0a10ac65e01461137c263b578144beec2d5138a0` plus the working-tree + `Gemfile.lock`. The locked dependency revisions were `fhir_client` + `e32abec25f8d6ca1b6997d159c263bb70f54abfd`, `fhir_models` + `19ed4a6070f08a18b3f839b6b74aba51a7240c67`, `fhir_dstu2_models` + `4f8b32300c2991d2803b888adff5c097bbfb018e`, and `fhir_stu3_models` + `e37cfa7b49518b918727d093c41954be1f1406aa`. +- The Spark checkout was `a22f3ea9a8e6743d2fac56ab0cb2356c356352d4`. The + locally built image IDs were plan-executor + `sha256:6975940f2dc2bd156129405fcd0c4c184bbb2c96297179dade0ef782aa47c9ad`, + Spark R5 + `sha256:4be6e182c3e4a5a463de103ac0e80311840ea54f4f9b5e8f22803e255cf27a2c`, + and MongoDB R5 + `sha256:343e893adda62daa88c87296df7422b75bfd2f2c24a68c51c00e91b0823f515f`. +- The rendered Compose configuration used only + `sparkfhir/spark:r5-latest`, `sparkfhir/mongo:r5-latest`, and + `incendi/plan_executor:latest`, with the base `docker-compose.yml` and + `docker-compose-r5.yml` file pair. The endpoint metadata check parsed through + `FHIR::R5::Json` and passed with `fhirVersion == "5.0.0"`. +- The R5 namespace smoke test passed inside the built image. The eligible suite + inventory contained exactly 12 suites: `FormatTest`, `ResourceTest`, + `TransactionAndBatchTest`, `HistoryTest`, `SearchTest`, + `RobustSearchTest`, `ConsentSearchByPatientReferenceTest`, + `SprinklerSearchTest`, `ElementsSearchParameterTest`, + `UnknownSearchParameterTest`, `FhirPathPatchTest`, and `ReadTest`. +- Targeted endpoint results passed before the full run: `FormatTest` was + `PASS: 26`; representative `ResourceTest` runs for `Patient`, `DeviceUsage`, + and `RequestOrchestration` were each `PASS: 15, SKIP: 3`; `SearchTest` was + `PASS: 1092`; and `TransactionAndBatchTest` was `PASS: 8, SKIP: 5`. +- The full `execute_all.sh` command used endpoint + `http://spark:8080/fhir`, explicit version `r5`, and output + `html|json|stdout`. It exited `0` with `PASS: 3539`, `FAIL: 0`, + `ERROR: 0`, and `SKIP: 477`. The refreshed isolated run produced 158 JSON + result files, 157 HTML summaries, backend logs for Spark and MongoDB, a + plan-executor harness log, and a summary annotation matching those totals. +- All 477 full-run skips were TODO skips; the direct result scan found zero + non-TODO skips, failures, or errors. The five transaction/batch skips and + the resource validation skips are the documented Spark TODOs, including + issues `#205`, `#305`, `#304`, and `#306`; the search and `_elements` skips + are the documented issues `#307` and `#1336`. +- `FHIR::R5::RESOURCES` contained 162 names in the built image. The existing + six-resource `BaseSuite::EXCLUDED_RESOURCES` list removed abstract/base + resources and `OperationOutcome`/`Parameters`, yielding the observed 156 + `ResourceTest_*` expansions; no unexpected resource names were executed. +- The plan-executor image was built from the lockfile with only the repository + source copied into the image. The Compose service mounts only the output + directories, so no sibling checkout or local Bundler override was used. + The isolated Compose project was torn down successfully after logs and + results were preserved under `tmp/task-9c/`. +- The R5 diagnostics workflow preserves the two harness files separately: + `execute_all.sh` writes `logs/execute_all.log`, while the Rake harness writes + `logs/plan_executor.log`. It does not overwrite the container-created logger + file from the runner, avoiding the root-owned bind-mount failure seen in the + initial CI attempt. +- The R4, R4B, STU3, and R5 workflow files all parsed successfully. The + already-verified workflow failure-status simulation confirms suite failure + remains authoritative through diagnostics, result combination, artifact + upload, evaluation, and cleanup. + +The final GitHub Actions R5 execution was run from PR head `6bb93ee` (the +workflow checked out merge commit `a8efcbdebc7df3e60b8e61bde9bd68387ff0ce1d`) +and completed green. The merge tree differs from the PR head only in the +unrelated Docker publishing workflow action version. Its recorded Spark +revision was the same +`a22f3ea9a8e6743d2fac56ab0cb2356c356352d4` used locally. CI used the same +locked `fhir_client`, `fhir_models`, `fhir_dstu2_models`, and +`fhir_stu3_models` revisions, the same Compose files, endpoint URL, and +explicit `r5` execution. CI reported `PASS: 3539`, `FAIL: 0`, `ERROR: 0`, and +`SKIP: 477`, matching the local run exactly. + +CI build identities were plan-executor +`sha256:636042beec572a23cf4f7fb75b5a6389f10ba52cb3a64fb806fae233b6614f64`, +Spark R5 `sha256:d15a91ac5f2904a30fd3f42d22849590b9f80ca1d6d290de79f1f383ad229bae`, +and MongoDB R5 +`sha256:e8059d2a7d9753367ec01cba14ff29ab7c26907ebee64f583a836ae14c898271`. +The local image identities are recorded above; their difference from CI is +expected for independently built images. + +Atomic commit, only when repository documentation records the evidence: + +```text +Document FHIR R5 Docker and CI verification +``` + +## Task 10: Verification Matrix + +### Task 10A: Pass The Merged Model And Client Gates + +Verification work: + +- [x] Check out the exact merged `fhir_models` and `fhir_client` revisions + locked by plan-executor. +- [x] Run the complete `fhir_models` unit suite. +- [x] Regenerate the R5 model, runtime-definition, and XML schema artifacts + from pinned inputs and verify the checked-in output is unchanged. +- [x] Build and install the model gem in a repository-local isolated + directory. +- [x] Run the complete `fhir_client` unit suite against that merged model + revision. +- [x] Build and install the client gem in a repository-local isolated + directory. +- [x] Run explicit R5 selection, parsing, workflow, and CapabilityStatement + detection tests. +- [x] Run explicit R4B, R4, STU3, and DSTU2 client-selection regressions. + +Acceptance criteria: + +- [x] Both complete unit suites exit successfully. +- [x] Regeneration produces no tracked diff. +- [x] Installed packages contain the expected R5 runtime files and no source + archives or repository-local working files. +- [x] R5 operations return only `FHIR::R5` model objects. +- [x] Existing versions retain their owning namespaces and behavior. +- [x] Test totals, expected skips, Ruby, Bundler, gem versions, and repository + SHAs are recorded. + +Task 10A verification evidence, 2026-08-01: + +- The plan-executor lockfile resolves `fhir_models` `5.0.0` at + `19ed4a6070f08a18b3f839b6b74aba51a7240c67` and `fhir_client` `5.1.0` at + `e32abec25f8d6ca1b6997d159c263bb70f54abfd`. The same SHAs were checked out + in `../fhir_models` and `../fhir_client`. The locked DSTU2 and STU3 model + revisions were also retained: `4f8b32300c2991d2803b888adff5c097bbfb018e` + and `e37cfa7b49518b918727d093c41954be1f1406aa`. +- The complete current `fhir_models` versioned unit coverage passed using + Ruby `3.4.9` and Bundler `4.0.10`: R5 `39 tests, 3,882 assertions`, R4B + `18 tests, 2,833 assertions`, and R4 `15,314 tests, 21,412 assertions`. + Every suite exited `0` with zero failures and errors; R4 reported its 11 + existing slow-test omissions. The R5 generation warnings for unavailable + terminology expansions remained non-fatal and did not change generated + output. +- R5 generation used the four SHA-256-pinned inputs under + `../fhir_models/tmp/r5-sources`. Two model generations, two runtime + definition generations, and two XML schema generations were compared + against each other and the checked-in outputs. All comparisons passed, with + 211 model files, 5 structure files, 2 value-set files, 1 version file, and + 164 schema files in the corresponding generated output trees. +- `fhir_models-5.0.0.gem` was built and installed under + `../fhir_models/tmp/task-10a/model-gems`, and `fhir_client-5.1.0.gem` was + built and installed under `../fhir_client/tmp/task-10a/client-gems`. The + package-member audit found the expected R5 runtime files and no downloaded + source archives, `tmp` paths, or sibling checkout paths. +- The complete current `fhir_client` suite passed with `147 tests, 415 + assertions, 0 failures, 0 errors, 0 omissions`. The explicit R5 and legacy + regression set passed with `83 tests, 266 assertions, 0 failures, 0 errors`. + It covered R5 selection, response parsing, workflows, and CapabilityStatement + detection, plus R4B, R4, STU3, and DSTU2 selection and parsing. +- The installed-package smoke test resolved `fhir_client` and `fhir_models` + from the two isolated directories, selected `FHIR::R5`, verified + `FHIR::R5::FHIR_VERSION == '5.0.0'`, and confirmed an explicit R5 client + reports the R5 namespace. No repository-local Bundler override was used. + +No verification-only product commit is created. Any defect must be fixed and +tested in the owning Task 1 through 4 commit. + +Atomic evidence commit: + +```text +Document Task 10A model and client verification +``` + +### Task 10B: Pass The Plan-Executor Harness Gates + +Verification work: + +- [x] Build the plan-executor Docker image from the committed lockfile with no + local Bundler overrides. +- [x] Run the complete plan-executor unit suite inside the image. +- [x] Run the R5 namespace smoke test inside the image. +- [x] Run R5 version routing, fixture selection, structure-index consistency, + all-resource generation, and suite-eligibility tests. +- [x] Verify `crucible:list_suites[r5]`, metadata generation, and execution + expose the same explicitly audited suite set. +- [x] Run targeted R5 `FormatTest`. +- [x] Run targeted R5 `ResourceTest` for representative unchanged, changed, + and R5-only resources. +- [x] Run targeted R5 `SearchTest` and `TransactionAndBatchTest`. + +Acceptance criteria: + +- [x] The complete unit suite exits successfully. +- [x] No generated or parsed R5 resource contains R4 or R4B model objects. +- [x] The R5 structure index exactly matches the concrete R5 model resources. +- [x] Removed R4B resources are absent from R5 enumeration and metadata. +- [x] No unaudited suite is listed or executable for R5. +- [x] STU3 TestScript artifacts remain excluded from R5. + +Task 10B verification evidence, 2026-08-01: + +- The Docker image was built from the committed lockfile with + `docker build --no-cache -t incendi/plan_executor:r5-task-10b .`. Its image + ID was `sha256:390d5277d958116a737d098b73312e93ae321a751ebcba4c175560a600a4fe95`. + The image resolved `fhir_models` `5.0.0` at + `19ed4a6070f08a18b3f839b6b74aba51a7240c67`, `fhir_client` `5.1.0` at + `e32abec25f8d6ca1b6997d159c263bb70f54abfd`, `fhir_dstu2_models` at + `4f8b32300c2991d2803b888adff5c097bbfb018e`, and `fhir_stu3_models` at + `e37cfa7b49518b918727d093c41954be1f1406aa`. No sibling model or client + checkout was present in the image. +- The complete unit suite passed inside the image with `1,347 tests, 20,833 + assertions, 0 failures, 0 errors, 0 pendings, 0 omissions`. The authoritative + run mounted the repository-local `tmp/task-10b/logs` directory, matching the + harness execution layout. The focused R5 routing, fixture, structure, + generation, resource, suite, and version tests passed with `194 tests, + 16,743 assertions, 0 failures, 0 errors`. +- The namespace smoke test selected `FHIR::R5`, verified + `FHIR::R5::FHIR_VERSION == '5.0.0'`, and completed an R5 Patient + serialization/parsing round trip. The focused tests also verified explicit + R5 routing, the R5-only resource inventory, structure-index consistency, + removed R4B resource exclusion, suite eligibility, and STU3 TestScript + exclusion. +- `crucible:list_suites[r5]` exposed the audited set of 12 suites: + `TransactionAndBatchTest`, `HistoryTest`, `SearchTest`, + `RobustSearchTest`, `ConsentSearchByPatientReferenceTest`, + `SprinklerSearchTest`, `ElementsSearchParameterTest`, + `UnknownSearchParameterTest`, `FhirPathPatchTest`, `ReadTest`, + `ResourceTest`, and `FormatTest`. Metadata generation succeeded for every + suite, and the targeted executions used only names from that set. +- The R5 endpoint reported FHIR `5.0.0` from `tmp/task-10b/metadata.json`. + Local Spark and MongoDB image IDs were respectively + `sha256:4be6e182c3e4a5a463de103ac0e80311840ea54f4f9b5e8f22803e255cf27a2c` + and `sha256:343e893adda62daa88c87296df7422b75bfd2f2c24a68c51c00e91b0823f515f`. + The targeted endpoint results were: `FormatTest` 26 passes; + `ResourceTest` Patient, DeviceUsage, and RequestOrchestration 15 passes and + 3 expected validation skips each; `SearchTest` 1,092 passes; and + `TransactionAndBatchTest` 8 passes and 5 expected TODO skips. + +Atomic evidence commit: + +```text +Document Task 10B Docker harness verification +``` + +No verification-only product commit is created. Any failure must return to the +owning Task 5 through 7 implementation and focused tests. + +### Task 10C: Pass The Full R5 Endpoint Acceptance Gate + +Verification work: + +- [x] Use the committed R5 Compose and CI configuration from Task 9. +- [x] Record the plan-executor revision, locked dependency revisions, Spark + revision, and Spark and MongoDB image IDs before execution. +- [x] Verify the endpoint CapabilityStatement reports FHIR `5.0.0`. +- [x] Run the targeted endpoint suites before the full run to distinguish + configuration failures from broad suite failures. +- [x] Run the complete explicitly eligible R5 suite set. +- [x] Capture Spark, MongoDB, plan-executor, and test-result artifacts even + when execution fails. +- [x] Classify failures as model/generator, client parsing, harness logic, + endpoint behavior, infrastructure, or unsupported optional capability. + +Acceptance criteria: + +- [x] The full R5 command exits zero. +- [x] FAIL and ERROR totals are zero. +- [x] Every SKIP is expected and documented; non-TODO skips are reported + separately and must not be hidden by aggregate totals. +- [x] The eligible suite count and per-resource expansion match Task 7. +- [x] JSON results, HTML summaries, annotations, backend logs, and harness logs + describe the same execution. +- [x] R5 success is evaluated by eligible suites and per-resource coverage, not + equality with the R4B raw PASS total. + +The Task 9C local or CI run may satisfy this gate without rerunning when it uses +the exact final revisions, images, Compose files, endpoint configuration, and +eligible suite set required here. + +Task 10C verification evidence, satisfied by the Task 9C local and CI runs on +2026-08-01: + +- Task 9C used the committed R5 Compose pair, explicit `r5` execution, the + eligible 12-suite inventory, endpoint `http://spark:8080/fhir`, and the same + locked revisions carried by the final plan-executor lockfile. The subsequent + changes were dependency source declarations retaining those locked revisions + and documentation; no runtime harness, Compose, endpoint, or eligible-suite + behavior changed. A rerun was therefore not required under the rule above. +- The recorded plan-executor revision was `0a10ac65e01461137c263b578144beec2d5138a0` + for the local run. The locked revisions were `fhir_client` + `e32abec25f8d6ca1b6997d159c263bb70f54abfd`, `fhir_models` + `19ed4a6070f08a18b3f839b6b74aba51a7240c67`, `fhir_dstu2_models` + `4f8b32300c2991d2803b888adff5c097bbfb018e`, and `fhir_stu3_models` + `e37cfa7b49518b918727d093c41954be1f1406aa`. Spark used revision + `a22f3ea9a8e6743d2fac56ab0cb2356c356352d4`. +- The local image IDs were plan-executor + `sha256:6975940f2dc2bd156129405fcd0c4c184bbb2c96297179dade0ef782aa47c9ad`, + Spark R5 + `sha256:4be6e182c3e4a5a463de103ac0e80311840ea54f4f9b5e8f22803e255cf27a2c`, + and MongoDB R5 + `sha256:343e893adda62daa88c87296df7422b75bfd2f2c24a68c51c00e91b0823f515f`. + The final CI run independently recorded matching green execution with its + own independently built image IDs. +- The endpoint CapabilityStatement reported FHIR `5.0.0`. Targeted suites + passed before the full run: `FormatTest` 26, representative `ResourceTest` + runs 15 each with 3 expected skips, `SearchTest` 1,092, and + `TransactionAndBatchTest` 8 with 5 expected TODO skips. +- The full explicitly eligible R5 run exited zero with `PASS: 3539`, + `FAIL: 0`, `ERROR: 0`, and `SKIP: 477`. All skips were documented TODOs; + the result scan found no non-TODO skips, failures, or errors. It produced + 158 JSON results, 157 HTML summaries, Spark and MongoDB backend logs, the + plan-executor harness log, and a matching summary annotation. +- The 12-suite inventory and 156 concrete `ResourceTest` expansions matched + Task 7. Success was evaluated against that eligible inventory and per-resource + coverage rather than against the R4B raw pass total. + +Atomic evidence commit: + +```text +Document Task 10C R5 acceptance verification +``` + +### Task 10D: Pass The Cross-Version Regression Gates + +Verification work: + +- [x] Build or select fresh Spark and MongoDB images for R4B and R4. +- [x] Run the full R4B endpoint suite from the final plan-executor image. +- [x] Run the full R4 endpoint suite from the same plan-executor revision. +- [x] Run the existing STU3 endpoint baseline with its established eligible + suite set. +- [x] Confirm DSTU2 is excluded from this matrix because it is deprecated in + this project context. +- [x] Keep the deferred STU3 FHIR TestScript run separate from the ordinary + suite regression matrix. +- [x] Compare per-suite and per-resource results with the recorded pre-R5 + baselines. + +Acceptance criteria: + +- [x] R4B, R4, and STU3 commands retain successful exit status; DSTU2 is + intentionally excluded as deprecated. +- [x] No version gains or loses suite eligibility unintentionally. +- [x] No new FAIL, ERROR, or non-TODO SKIP is introduced in the retained + R4B, R4, and STU3 matrix. +- [x] Resource counts change only where the corresponding version's model + inventory intentionally changed. +- [x] R4 remains top-level `FHIR`, R4B remains `FHIR::R4B`, and neither path + resolves through `FHIR::R5`. +- [x] Any accepted baseline change is explained by a specification or + intentional test correction rather than normalized away in totals. + +Task 10D verification evidence, 2026-08-01: + +- The final plan-executor image was built from commit `6cb8e4d` and the + committed lockfile as `incendi/plan_executor:task-10d`, image ID + `sha256:bb66cc7557012493aafd2d9855be11a31832c6e805c902f6301ff45bdd3827f3`. + The R4B and STU3 Spark/Mongo images were built from the local Spark checkout + at `a22f3ea9a8e6743d2fac56ab0cb2356c356352d4` with its existing working-tree + changes. The later Spark commit `f5cd6c00274e7996bcb7af1a6ca7997fe1d2aa15` + added the Docker-context fix for local settings; the final R4 image was + rebuilt from that commit. +- Fresh R4B image IDs were Spark + `sha256:98db286be8b3431ad0dbe219784d3a8050eb29923c06d565d20ccaf9b1be857f` + and MongoDB + `sha256:185ca69adb1b4ddf043675d586064d8829de808f3d3d7480711de36c949c89cc`. + The final R4 image IDs were Spark + `sha256:5a8190942b8e2acd5da6f40aa1ab9150f7717a0f7eb75d25b6aa8cd5e68404cf` + and MongoDB + `sha256:604f81987267d38f03dec4b79ce5e60ebeb4f71c8981939c5474fa57cb74c678`. + STU3 image IDs were Spark + `sha256:b1cbbe54ca6ec5e6d8d1240346f5107d70f51159d1ba4139cedae1f19bdc576a` + and MongoDB + `sha256:a41591ff11dd83f7717a3183b8cf2abfc9d9b7a86548d3ac1798bf324a34f24b`. +- The complete R4B run exited `0` with `PASS: 3165`, `FAIL: 0`, `ERROR: 0`, + and `SKIP: 426`, producing 288 HTML reports and 289 JSON files including + the summary. R4 exited `0` with `PASS: 3275`, `FAIL: 0`, `ERROR: 0`, and + `SKIP: 441`, producing 298 HTML reports and 299 JSON files. STU3 exited `0` + with `PASS: 2884`, `FAIL: 0`, `ERROR: 0`, and `SKIP: 411`, producing 324 + HTML reports and 325 JSON files. +- The R4 image initially failed to start because the dirty Spark checkout's + `Settings/appsettings.local.json` configured an unavailable HTTPS endpoint. + The Spark commit recorded above excludes that local-only file from Docker + contexts. The rebuilt R4 image was verified not to contain the file and the + full R4 run then passed; the temporary derivative used for diagnosis is kept + only under `tmp/task-10d/`. +- R4 and R4B exposed the same 12 eligible suites. STU3 exposed its established + 25-suite Connectathon and ordinary-suite inventory. The retained version + inventories matched the prior `task7j` JSON baselines in the repository. + Per-test/resource key comparison found zero additions or removals for all + three versions. R4 and STU3 had zero status changes. R4B had one improvement: + `ResourceTest_Observation` `x012_conditional_create_(no_matches)_test` + changed from `fail` to `pass`; no regression was introduced. +- All skips in the retained R4B, R4, and STU3 runs were TODO skips. The + deferred STU3 TestScript execution remained separate and was not included in + these totals. +- DSTU2 was not pursued further because it is deprecated in this project + context. The already completed diagnostic HTTPS run is preserved under + `tmp/task-10d/dstu2-https/`; it reached the endpoint and produced + `PASS: 1647`, `FAIL: 453`, `ERROR: 0`, and `SKIP: 294`. Its failures were + endpoint-semantics differences in HAPI, including conditional-update and + history behavior, rather than a retained-version regression gate. + +Atomic evidence commit: + +```text +Document Task 10D cross-version regression verification +``` + +No regression-only product commit is created. Corrections must land with +focused tests in the owning repository and task. + +### Task 10E: Record Final R5 Verification Evidence + +- [x] Produce one final verification record linking the accepted outputs from + Tasks 10A through 10D. +- [x] Record every repository SHA, gem version, dependency source, lockfile + revision, Spark revision, Docker image ID, Compose file, endpoint URL, and + CapabilityStatement FHIR version. +- [x] Record eligible suite counts and PASS, FAIL, ERROR, SKIP, non-TODO SKIP, + and command exit-status values for every endpoint run. +- [x] Record unit-suite totals and deterministic-generation results for models, + client, and plan-executor. +- [x] Link backend logs, harness logs, JSON results, HTML summaries, annotations, + and CI workflow runs. +- [x] Confirm committed files contain no sibling paths, temporary dependency + overrides, downloaded source archives, credentials, or generated test + artifacts. +- [x] Mark every accepted skip or baseline difference with its owner and + rationale. + +Validation required before the evidence commit: + +- [x] Verify every referenced artifact exists and belongs to the recorded run. +- [x] Verify no result is copied from a stale revision or superseded rebase + SHA. +- [x] Verify all required commands completed and no aggregate report masked a + nonzero command exit status. +- [x] Verify unresolved failures remain failures rather than documentation + exceptions. + +Task 10E final verification record, 2026-08-01: + +**Accepted evidence** + +The final record links the accepted evidence in [Task 10A](#task-10a-pass-the-merged-model-and-client-gates), [Task 10B](#task-10b-pass-the-plan-executor-harness-gates), [Task 10C](#task-10c-pass-the-full-r5-endpoint-acceptance-gate), and [Task 10D](#task-10d-pass-the-cross-version-regression-gates). The R5 CI workflow is [`.github/workflows/ci-r5.yml`](../.github/workflows/ci-r5.yml), and its workflow-run history is available from [GitHub Actions](https://github.com/incendilabs/plan-executor/actions/workflows/ci-r5.yml). + +**Repository and package identity** + +| Component | Version and source identity | +| --- | --- | +| plan-executor | `1.8.0`, verification source revision `3e01b95666740b88a42090f8a7c16650bfe04d0c` | +| fhir_models | `5.0.0`, `19ed4a6070f08a18b3f839b6b74aba51a7240c67` from `https://github.com/incendilabs/fhir_models.git`, branch `master` | +| fhir_client | `5.1.0`, `e32abec25f8d6ca1b6997d159c263bb70f54abfd` from `https://github.com/incendilabs/fhir_client.git`, branch `master` | +| fhir_dstu2_models | `1.0.10`, `4f8b32300c2991d2803b888adff5c097bbfb018e` from `https://github.com/incendilabs/fhir_dstu2_models.git`, branch `master` | +| fhir_stu3_models | `3.0.1`, `e37cfa7b49518b918727d093c41954be1f1406aa` from `https://github.com/incendilabs/fhir_stu3_models.git`, branch `master` | +| Ruby and Bundler | Ruby `3.4.9`, Bundler `4.0.10` | + +The source declarations are the four Git dependencies in `Gemfile`; the +revisions above are the corresponding entries in `Gemfile.lock`. No sibling +checkout, local path override, temporary dependency override, credential, or +downloaded source archive is tracked. The root-level downloaded archives and +generated run outputs were verified to remain untracked. + +**Unit and generation evidence** + +| Component | Result | +| --- | --- | +| fhir_models R5 | `39 tests, 3882 assertions, 0 failures, 0 errors` | +| fhir_models R4B | `18 tests, 2833 assertions, 0 failures, 0 errors` | +| fhir_models R4 | `15314 tests, 21412 assertions, 0 failures, 0 errors, 11 omissions` | +| fhir_client | `147 tests, 415 assertions, 0 failures, 0 errors, 0 omissions` | +| fhir_client focused compatibility set | `83 tests, 266 assertions, 0 failures, 0 errors` | +| plan-executor Docker unit suite | `1347 tests, 20833 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions`, exit `0` | +| plan-executor focused R5 suite | `194 tests, 16743 assertions, 0 failures, 0 errors`, exit `0` | +| R5 generation | Two identical generations: 211 model files, 5 structure files, 2 value-set files, 1 version file, and 164 schema files | + +The model and client package audits found no source archives, sibling paths, or +repository-local working files in the installed packages. The R5 source +inputs were SHA-256 pinned under `../fhir_models/tmp/r5-sources`; the +generation output was compared twice and against the checked-in output. +The built packages are `../fhir_models/tmp/task-10a/model-build/fhir_models-5.0.0.gem` +and `../fhir_client/tmp/task-10a/client-build/fhir_client-5.1.0.gem`; their +isolated installation caches are under `model-gems/` and `client-gems/`. +Detailed logs are under `../fhir_models/tmp/task-10a/` and +`../fhir_client/tmp/task-10a/`; the plan-executor unit and generation logs are +under `tmp/task-10b/`. + +**Endpoint verification matrix** + +All endpoint commands used the version-specific explicit argument and the +endpoint URL `http://spark:8080/fhir`. Metadata was retrieved from each +endpoint and parsed through its owning model namespace. R5 reported +CapabilityStatement `fhirVersion == "5.0.0"`; the R4B, R4, and STU3 metadata +checks also passed. The plan-executor image used for Task 10D was +`incendi/plan_executor:task-10d`, image +`sha256:bb66cc7557012493aafd2d9855be11a31832c6e805c902f6301ff45bdd3827f3`. + +| FHIR version | CapabilityStatement `fhirVersion` | Compose files | Eligible suites | PASS | FAIL | ERROR | SKIP | Non-TODO SKIP | Status | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| R5 | `5.0.0` | `docker-compose.yml`, `docker-compose-r5.yml` | 12 suites, 156 resource expansions | 3539 | 0 | 0 | 477 | 0 | exit `0` | +| R4B | `4.3.0` | `docker-compose.yml`, `docker-compose-r4b.yml` | 12 suites | 3165 | 0 | 0 | 426 | 0 | exit `0` | +| R4 | `4.0.1` | `docker-compose.yml` | 12 suites | 3275 | 0 | 0 | 441 | 0 | exit `0` | +| STU3 | `3.0.2` | `docker-compose-stu3.yml` | 25 suites | 2884 | 0 | 0 | 411 | 0 | exit `0` | + +R5 local Docker image IDs were plan-executor +`sha256:6975940f2dc2bd156129405fcd0c4c184bbb2c96297179dade0ef782aa47c9ad`, +Spark `sha256:4be6e182c3e4a5a463de103ac0e80311840ea54f4f9b5e8f22803e255cf27a2c`, +and MongoDB `sha256:343e893adda62daa88c87296df7422b75bfd2f2c24a68c51c00e91b0823f515f`. +The CI image IDs were plan-executor +`sha256:636042beec572a23cf4f7fb75b5a6389f10ba52cb3a64fb806fae233b6614f64`, +Spark `sha256:d15a91ac5f2904a30fd3f42d22849590b9f80ca1d6d290de79f1f383ad229bae`, +and MongoDB `sha256:e8059d2a7d9753367ec01cba14ff29ab7c26907ebee64f583a836ae14c898271`. +The local R5 run and the GitHub Actions run used the same locked dependencies, +Spark revision `a22f3ea9a8e6743d2fac56ab0cb2356c356352d4`, Compose pair, +endpoint, explicit `r5` argument, and eligible suite set. Both reported the +same totals and exit status. + +The retained-version Spark source revisions were `a22f3ea9a8e6743d2fac56ab0cb2356c356352d4` +for R4B and STU3, and `f5cd6c00274e7996bcb7af1a6ca7997fe1d2aa15` for the final +R4 image. The retained-version Spark and MongoDB image IDs were, respectively: +R4B `sha256:98db286be8b3431ad0dbe219784d3a8050eb29923c06d565d20ccaf9b1be857f` +and `sha256:185ca69adb1b4ddf043675d586064d8829de808f3d3d7480711de36c949c89cc`; +R4 `sha256:5a8190942b8e2acd5da6f40aa1ab9150f7717a0f7eb75d25b6aa8cd5e68404cf` +and `sha256:604f81987267d38f03dec4b79ce5e60ebeb4f71c8981939c5474fa57cb74c678`; +and STU3 `sha256:b1cbbe54ca6ec5e6d8d1240346f5107d70f51159d1ba4139cedae1f19bdc576a` +and `sha256:a41591ff11dd83f7717a3183b8cf2abfc9d9b7a86548d3ac1798bf324a34f24b`. + +The retained-version image IDs, Spark revisions, and exact result counts are +recorded in Task 10D. Their result directories are +`tmp/task-10d/r4b/`, `tmp/task-10d/r4-fixed/`, and `tmp/task-10d/stu3/`. +Each contains the endpoint metadata, Spark and MongoDB logs, harness log, +`execute_all` log, annotations, JSON results, HTML summaries, suite list, and +status files. The R5 equivalents are under `tmp/task-9c/` and +`tmp/task-10b/`. The retained run artifact counts were R4B `289 JSON` and +`288 HTML`, R4 `299 JSON` and `298 HTML`, and STU3 `325 JSON` and `324 HTML`. + +**Skips, baselines, and validation conclusions** + +- Every accepted skip in R5, R4B, R4, and STU3 was a TODO skip. R5 skips are + owned by the existing Spark TODOs documented in Task 9C, including issues + `#205`, `#305`, `#304`, `#306`, `#307`, and `#1336`; no skip was reclassified + to hide a failure. The retained-version TODO skips remain owned by their + existing suite and endpoint TODOs. +- Per-test/resource key comparison against the recorded task7j baselines found + zero additions or removals for R4B, R4, and STU3. R4 and STU3 had zero status + changes. R4B had one improvement owned by the endpoint regression correction: + `ResourceTest_Observation` `x012_conditional_create_(no_matches)_test` + changed from `fail` to `pass`. +- The only nonzero diagnostic result was the intentionally excluded DSTU2 + HTTPS run, which exited `1` with `PASS: 1647`, `FAIL: 453`, `ERROR: 0`, and + `SKIP: 294`. DSTU2 is deprecated in this project context, so this diagnostic + is preserved for historical analysis under `tmp/task-10d/dstu2-https/` and + is not an acceptance exception or an unresolved retained-version failure. +- Every required command had an explicit status file or recorded CI result; + the aggregate reports were checked against the raw JSON scan and did not + mask a nonzero command status. No unresolved failure was converted into a + documentation exception. + +Atomic commit: + +```text +Document final FHIR R5 verification +``` + +## Primary Pitfalls + +1. Treating R5 as R4B plus a namespace. +2. Parsing R5 through an R4 or R4B fallback. +3. Feeding the R5 NPM expansion package to the R4B Bundle reader unchanged. +4. Depending on a moving latest extension pack without an explicit pin. +5. Missing `integer64` serialization or validation behavior. +6. Retaining removed R4B resources in R5 metadata or suite enumeration. +7. Reusing R4B fixtures without R5 validation. +8. Enabling every R4B suite for R5 before semantic review. +9. Comparing raw pass totals despite the different R5 resource inventory. +10. Checking downloaded source archives into Git instead of generated outputs. +11. Regenerating non-deterministic output that cannot be meaningfully reviewed. +12. Declaring success from unit tests without a real R5 endpoint run. +13. Assuming host Bundler local overrides are available inside a Docker build; + sibling repositories are outside the plan-executor build context unless + they have already been merged and resolved through committed dependencies. +14. Locking feature-branch SHAs before rebase merges and leaving downstream + repositories pinned to commits that are not the final master revisions. +15. Reusing the existing model gem version or permissive client dependency + floor, allowing an R5-capable client to resolve an older model package + without `FHIR::R5`. +16. Letting suite listing, metadata generation, and execution derive different + R5 eligibility sets. +17. Allowing result combination, artifact upload, or cleanup commands to mask + the nonzero exit status of the actual suite run. +18. Starting endpoint tests before Spark is ready or without first verifying + that its CapabilityStatement reports FHIR `5.0.0`. + +## Recommended Execution Order + +1. Settle and pin the R5 extension-definition source. +2. Generalize definition, expansion-package, and schema input handling. +3. Generate R5 models, runtime definitions, and XML schemas as separate atomic + commits. +4. Complete the `fhir_models` acceptance and cross-version regression gates, + assign a new model gem version, and rebase-merge `fhir_models`. +5. Record the resulting `fhir_models` master SHA, then develop and validate + `fhir_client` against that merged revision. +6. Complete the client regression gates, update its model dependency floor and + lockfile, assign the client gem version, and rebase-merge `fhir_client`. +7. Record the resulting `fhir_client` master SHA and resolve both merged + dependencies in plan-executor before building its Docker image. +8. Add explicit plan-executor R5 registration, structure generation, fixture + selection, model routing, and task wiring. +9. Add the R5 Compose topology so targeted endpoint checks use the same + services and image names intended for CI. +10. Stabilize the R4/R4B Observation generation baseline, then run the repeated + R5 all-resource audit and fix primitive, choice, terminology, recursion, + and resource-specific defects. +11. Audit each of the 12 candidate suites individually against the R5 endpoint, + adding `:r5` only to suites that pass their focused compatibility gate. +12. Refresh and commit plan-executor's lockfile using only the final + post-rebase model and client master SHAs, with no local overrides. +13. Add the complete R5 CI workflow, including readiness checks, metadata + verification, preserved suite exit status, artifacts, and cleanup. +14. Run the merged model/client package gates and the complete plan-executor + Docker unit and targeted harness gates. +15. Run the full R5 endpoint suite, followed by the R4B, R4, STU3, and + applicable DSTU2 regression matrix. +16. Record final revisions, image IDs, test totals, exit statuses, expected + skips, and artifact locations in the verification evidence commit. diff --git a/fixtures/stu3/patch/medicationrequest-simple.r5.xml b/fixtures/stu3/patch/medicationrequest-simple.r5.xml new file mode 100644 index 00000000..873119a1 --- /dev/null +++ b/fixtures/stu3/patch/medicationrequest-simple.r5.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/lib/FHIR_structure_r5.json b/lib/FHIR_structure_r5.json new file mode 100644 index 00000000..f1fce1ce --- /dev/null +++ b/lib/FHIR_structure_r5.json @@ -0,0 +1,987 @@ +{ + "name": "FHIR", + "children": [ + { + "name": "OPERATIONS", + "children": [ + { + "name": "RESTful API", + "children": [ + { + "name": "Instance Level Interactions", + "children": [ + { + "name": "read" + }, + { + "name": "vread" + }, + { + "name": "update" + }, + { + "name": "conditional-update" + }, + { + "name": "delete" + }, + { + "name": "history" + }, + { + "name": "patch" + } + ] + }, + { + "name": "Type Level Interactions", + "children": [ + { + "name": "create" + }, + { + "name": "conditional-create" + }, + { + "name": "search" + }, + { + "name": "history-type" + } + ] + }, + { + "name": "Whole System Interactions", + "children": [ + { + "name": "capabilities-system" + }, + { + "name": "transaction-system" + }, + { + "name": "history-system" + }, + { + "name": "search-system" + }, + { + "name": "batch-system" + } + ] + } + ] + }, + { + "name": "Extended Operations", + "children": [ + { + "name": "$validate" + }, + { + "name": "$meta" + }, + { + "name": "$meta-add" + }, + { + "name": "$meta-delete" + }, + { + "name": "$convert" + }, + { + "name": "$graph-ql" + }, + { + "name": "$graph" + }, + { + "name": "$apply" + }, + { + "name": "$data-requirements" + }, + { + "name": "$subset" + }, + { + "name": "$implements" + }, + { + "name": "$conforms" + }, + { + "name": "$versions" + }, + { + "name": "$submit" + }, + { + "name": "$submit-data" + }, + { + "name": "$collect-data" + }, + { + "name": "$care-gaps" + }, + { + "name": "$preferred-id" + }, + { + "name": "$stats" + }, + { + "name": "$lastn" + }, + { + "name": "$transform" + }, + { + "name": "$snapshot" + }, + { + "name": "$subsumes" + }, + { + "name": "$find-matches" + }, + { + "name": "$document" + }, + { + "name": "$translate" + }, + { + "name": "$closure" + }, + { + "name": "$everything" + }, + { + "name": "$find" + }, + { + "name": "$process-message" + }, + { + "name": "$populate" + }, + { + "name": "$questionnaire" + }, + { + "name": "$expand" + }, + { + "name": "$lookup" + }, + { + "name": "$validate-code" + }, + { + "name": "$guidance" + }, + { + "name": "$guidance-requirements" + }, + { + "name": "$match" + } + ] + } + ] + }, + { + "name": "RESOURCES", + "children": [ + { + "name": "Foundation", + "children": [ + { + "name": "Conformance", + "children": [ + { + "name": "capability statement" + }, + { + "name": "compartment definition" + }, + { + "name": "graph definition" + }, + { + "name": "implementation guide" + }, + { + "name": "message definition" + }, + { + "name": "operation definition" + }, + { + "name": "search parameter" + }, + { + "name": "structure definition" + }, + { + "name": "structure map" + } + ] + }, + { + "name": "Terminology", + "children": [ + { + "name": "code system" + }, + { + "name": "concept map" + }, + { + "name": "naming system" + }, + { + "name": "terminology capabilities" + }, + { + "name": "value set" + } + ] + }, + { + "name": "Security", + "aka": [ + "Security & Privacy" + ], + "children": [ + { + "name": "audit event" + }, + { + "name": "consent" + }, + { + "name": "permission" + }, + { + "name": "provenance" + } + ] + }, + { + "name": "Documents", + "aka": [ + "Documents & Questionnaires" + ], + "children": [ + { + "name": "composition" + } + ] + }, + { + "name": "Other", + "aka": [ + "Exchange", + "Structure", + "Structures" + ], + "children": [ + { + "name": "basic" + }, + { + "name": "binary" + }, + { + "name": "bundle" + }, + { + "name": "linkage" + }, + { + "name": "message header" + }, + { + "name": "operation outcome" + }, + { + "name": "parameters" + }, + { + "name": "subscription" + }, + { + "name": "subscription status" + }, + { + "name": "subscription topic" + } + ] + } + ] + }, + { + "name": "Base", + "aka": [ + "Administration", + "Administrative", + "Identification" + ], + "children": [ + { + "name": "Individuals", + "children": [ + { + "name": "group" + }, + { + "name": "patient" + }, + { + "name": "person" + }, + { + "name": "practitioner" + }, + { + "name": "practitioner role" + }, + { + "name": "related person" + } + ] + }, + { + "name": "Entities", + "children": [ + { + "name": "biologically derived product" + }, + { + "name": "device" + }, + { + "name": "device metric" + }, + { + "name": "endpoint" + }, + { + "name": "healthcare service" + }, + { + "name": "location" + }, + { + "name": "nutrition product" + }, + { + "name": "organization" + }, + { + "name": "organization affiliation" + }, + { + "name": "substance" + } + ] + }, + { + "name": "Workflow", + "aka": [ + "Scheduling", + "Events", + "Order Management" + ], + "children": [ + { + "name": "appointment" + }, + { + "name": "appointment response" + }, + { + "name": "schedule" + }, + { + "name": "slot" + }, + { + "name": "task" + }, + { + "name": "transport" + }, + { + "name": "verification result" + } + ] + }, + { + "name": "Management", + "aka": [ + "Patient Management" + ], + "children": [ + { + "name": "encounter" + }, + { + "name": "encounter history" + }, + { + "name": "episode of care" + }, + { + "name": "flag" + }, + { + "name": "library" + }, + { + "name": "list" + } + ] + } + ] + }, + { + "name": "Clinical", + "children": [ + { + "name": "Summary", + "aka": [ + "General Clinical" + ], + "children": [ + { + "name": "adverse event" + }, + { + "name": "allergy intolerance" + }, + { + "name": "clinical impression" + }, + { + "name": "condition" + }, + { + "name": "detected issue" + }, + { + "name": "family member history" + }, + { + "name": "procedure" + } + ] + }, + { + "name": "Diagnostics", + "children": [ + { + "name": "body structure" + }, + { + "name": "diagnostic report" + }, + { + "name": "document reference" + }, + { + "name": "genomic study" + }, + { + "name": "imaging selection" + }, + { + "name": "imaging study" + }, + { + "name": "molecular sequence" + }, + { + "name": "observation" + }, + { + "name": "questionnaire response" + }, + { + "name": "specimen" + } + ] + }, + { + "name": "Medications", + "aka": [ + "Medication & Immunization" + ], + "children": [ + { + "name": "formulary item" + }, + { + "name": "immunization" + }, + { + "name": "immunization evaluation" + }, + { + "name": "immunization recommendation" + }, + { + "name": "medication" + }, + { + "name": "medication administration" + }, + { + "name": "medication dispense" + }, + { + "name": "medication knowledge" + }, + { + "name": "medication request" + }, + { + "name": "medication statement" + } + ] + }, + { + "name": "Care Provision", + "children": [ + { + "name": "care plan" + }, + { + "name": "care team" + }, + { + "name": "goal" + }, + { + "name": "nutrition intake" + }, + { + "name": "nutrition order" + }, + { + "name": "request orchestration" + }, + { + "name": "risk assessment" + }, + { + "name": "service request" + }, + { + "name": "vision prescription" + } + ] + }, + { + "name": "Request & Response", + "children": [ + { + "name": "biologically derived product dispense" + }, + { + "name": "communication" + }, + { + "name": "communication request" + }, + { + "name": "device association" + }, + { + "name": "device dispense" + }, + { + "name": "device request" + }, + { + "name": "device usage" + }, + { + "name": "guidance response" + }, + { + "name": "inventory item" + }, + { + "name": "inventory report" + }, + { + "name": "supply delivery" + }, + { + "name": "supply request" + } + ] + } + ] + }, + { + "name": "Financial", + "children": [ + { + "name": "Support", + "children": [ + { + "name": "coverage" + }, + { + "name": "coverage eligibility request" + }, + { + "name": "coverage eligibility response" + }, + { + "name": "enrollment request" + }, + { + "name": "enrollment response" + } + ] + }, + { + "name": "Billing", + "children": [ + { + "name": "claim" + }, + { + "name": "claim response" + }, + { + "name": "invoice" + } + ] + }, + { + "name": "Payment", + "children": [ + { + "name": "payment notice" + }, + { + "name": "payment reconciliation" + } + ] + }, + { + "name": "General", + "aka": [ + "Other" + ], + "children": [ + { + "name": "account" + }, + { + "name": "charge item" + }, + { + "name": "charge item definition" + }, + { + "name": "contract" + }, + { + "name": "explanation of benefit" + }, + { + "name": "insurance plan" + } + ] + } + ] + }, + { + "name": "Specialized", + "children": [ + { + "name": "Public Health & Research", + "aka": [ + "Research" + ], + "children": [ + { + "name": "research study" + }, + { + "name": "research subject" + } + ] + }, + { + "name": "Definitional Artifacts", + "children": [ + { + "name": "activity definition" + }, + { + "name": "actor definition" + }, + { + "name": "condition definition" + }, + { + "name": "device definition" + }, + { + "name": "event definition" + }, + { + "name": "example scenario" + }, + { + "name": "observation definition" + }, + { + "name": "plan definition" + }, + { + "name": "questionnaire" + }, + { + "name": "requirements" + }, + { + "name": "specimen definition" + } + ] + }, + { + "name": "Evidence-Based Medicine", + "children": [ + { + "name": "artifact assessment" + }, + { + "name": "citation" + }, + { + "name": "evidence" + }, + { + "name": "evidence report" + }, + { + "name": "evidence variable" + } + ] + }, + { + "name": "Quality Reporting & Testing", + "aka": [ + "Clinical Reasoning", + "Quality Reporting" + ], + "children": [ + { + "name": "measure" + }, + { + "name": "measure report" + }, + { + "name": "test plan" + }, + { + "name": "test report" + }, + { + "name": "test script" + } + ] + }, + { + "name": "Medication Definition", + "children": [ + { + "name": "administrable product definition" + }, + { + "name": "clinical use definition" + }, + { + "name": "ingredient" + }, + { + "name": "manufactured item definition" + }, + { + "name": "medicinal product definition" + }, + { + "name": "packaged product definition" + }, + { + "name": "regulated authorization" + }, + { + "name": "substance definition" + }, + { + "name": "substance nucleic acid" + }, + { + "name": "substance polymer" + }, + { + "name": "substance protein" + }, + { + "name": "substance reference information" + }, + { + "name": "substance source material" + } + ] + } + ] + } + ] + }, + { + "name": "FORMAT", + "children": [ + { + "name": "XML" + }, + { + "name": "JSON" + } + ] + }, + { + "name": "SECURITY", + "children": [ + { + "name": "General Security", + "children": [ + { + "name": "Authorization/Access Control" + }, + { + "name": "OAuth2" + }, + { + "name": "Audit Logging" + }, + { + "name": "Digital Signatures" + } + ] + }, + { + "name": "Security Labels", + "children": [ + { + "name": "Confidentiality Codes" + }, + { + "name": "Celebrity / VIP" + }, + { + "name": "Staff" + }, + { + "name": "Keep information from patient" + }, + { + "name": "Contact/Employment Details Confidential" + }, + { + "name": "Diagnosis-related confidentiality" + }, + { + "name": "Author consent needed" + }, + { + "name": "Delete After Use" + }, + { + "name": "Do Not Reuse" + }, + { + "name": "Break The Glass" + }, + { + "name": "Confidentiality Classification" + }, + { + "name": "Sensitivity Category" + }, + { + "name": "Compartment Category" + }, + { + "name": "Integrity Category" + }, + { + "name": "Handling Caveat" + }, + { + "name": "US Privacy Law" + } + ] + } + ] + }, + { + "name": "MESSAGING", + "children": [ + { + "name": "Consequence" + }, + { + "name": "Currency" + }, + { + "name": "Notification" + } + ] + }, + { + "name": "DOCUMENTS", + "children": [ + + ] + }, + { + "name": "PROFILES", + "children": [ + { + "name": "validate-profile" + } + ] + }, + { + "name": "EXTENSIONS", + "children": [ + { + "name": "extensions" + }, + { + "name": "modifying extensions" + }, + { + "name": "complex extensions" + }, + { + "name": "primitive extensions" + } + ] + } + ] +} diff --git a/lib/data/fhir_structure.rb b/lib/data/fhir_structure.rb index 2bc1b984..4b8dc3a5 100644 --- a/lib/data/fhir_structure.rb +++ b/lib/data/fhir_structure.rb @@ -1,8 +1,13 @@ module Crucible class FHIRStructure def self.get(fhir_version) + version = Crucible::FHIRVersion.resolve(fhir_version) root = File.expand_path File.join('..','..'), File.dirname(File.absolute_path(__FILE__)) - JSON.parse(File.read(File.join(root, 'lib', "FHIR_structure_#{fhir_version.to_s}.json"))) + JSON.parse(File.read(File.join(root, 'lib', "FHIR_structure_#{version}.json"))) + end + + def self.for_resource(resource) + get(Crucible::FHIRVersion.for_class(resource)) end end end diff --git a/lib/data/fhir_structure_generator.rb b/lib/data/fhir_structure_generator.rb index 146eaf01..d2729f4f 100644 --- a/lib/data/fhir_structure_generator.rb +++ b/lib/data/fhir_structure_generator.rb @@ -1,48 +1,136 @@ require 'cgi' require 'digest' +require 'json' require 'open3' module Crucible class FHIRStructureGenerator - DEFINITIONS_URL = 'https://www.hl7.org/fhir/R4B/definitions.json.zip' - DEFINITIONS_SHA256 = 'a2793a06853c2d4540db8a72fc1c6d972528b01d113c2bb70ae2d80dc062e963' - PROFILES_ENTRY = 'definitions.json/profiles-resources.json' - CATEGORY_URL = 'http://hl7.org/fhir/StructureDefinition/structuredefinition-category' - # These two base definitions omit the category extension in the official archive. - CATEGORY_OVERRIDES = { - 'ResearchDefinition' => 'Specialized.Evidence-Based Medicine', - 'ResearchElementDefinition' => 'Specialized.Evidence-Based Medicine' + ROOT = File.expand_path('../..', __dir__).freeze + CATEGORY_URL = 'http://hl7.org/fhir/StructureDefinition/structuredefinition-category'.freeze + + class Configuration + attr_reader :version, :label, :source_url, :sha256, :archive_entry, + :category_overrides, :template_path, :output_path + + def initialize(version:, label:, source_url:, sha256:, archive_entry:, + category_overrides:, template_path:, output_path:) + @version = version.to_sym + @label = immutable_string(label) + @source_url = immutable_string(source_url) + @sha256 = immutable_string(sha256) + @archive_entry = immutable_string(archive_entry) + @category_overrides = category_overrides.each_with_object({}) do |(resource, category), overrides| + overrides[immutable_string(resource)] = immutable_string(category) + end.freeze + @template_path = immutable_string(File.expand_path(template_path)) + @output_path = immutable_string(File.expand_path(output_path)) + freeze + end + + private + + def immutable_string(value) + value.to_s.dup.freeze + end + end + + R4B_CONFIGURATION = Configuration.new( + version: :r4b, + label: 'R4B', + source_url: 'https://www.hl7.org/fhir/R4B/definitions.json.zip', + sha256: 'a2793a06853c2d4540db8a72fc1c6d972528b01d113c2bb70ae2d80dc062e963', + archive_entry: 'definitions.json/profiles-resources.json', + category_overrides: { + # These base definitions omit the category extension in the official archive. + 'ResearchDefinition' => 'Specialized.Evidence-Based Medicine', + 'ResearchElementDefinition' => 'Specialized.Evidence-Based Medicine' + }, + template_path: File.join(ROOT, 'lib', 'FHIR_structure_r4.json'), + output_path: File.join(ROOT, 'lib', 'FHIR_structure_r4b.json') + ) + R5_CONFIGURATION = Configuration.new( + version: :r5, + label: 'R5', + source_url: 'https://hl7.org/fhir/R5/definitions.json.zip', + sha256: 'df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c', + archive_entry: 'profiles-resources.json', + category_overrides: {}, + template_path: File.join(ROOT, 'lib', 'FHIR_structure_r4.json'), + output_path: File.join(ROOT, 'lib', 'FHIR_structure_r5.json') + ) + CONFIGURATIONS = { + r4b: R4B_CONFIGURATION, + r5: R5_CONFIGURATION }.freeze - def self.from_archive(archive_path, template_path) + def self.from_archive(configuration, archive_path) checksum = Digest::SHA256.file(archive_path).hexdigest - raise "Unexpected R4B definitions checksum: #{checksum}" unless checksum == DEFINITIONS_SHA256 - - profiles_json, status = Open3.capture2('unzip', '-p', archive_path, PROFILES_ENTRY) - raise "Unable to read #{PROFILES_ENTRY} from #{archive_path}" unless status.success? + unless checksum == configuration.sha256 + raise "Unexpected #{configuration.label} definitions checksum: " \ + "expected #{configuration.sha256}, got #{checksum}" + end - generate(JSON.parse(profiles_json), JSON.parse(File.read(template_path))) + profiles_json = read_exact_archive_entry(configuration, archive_path) + template = JSON.parse(File.read(configuration.template_path)) + generate(configuration, JSON.parse(profiles_json), template) end - def self.generate(structure_definitions, template) + def self.generate(configuration, structure_definitions, template) result = JSON.parse(JSON.generate(template)) resource_root = result.fetch('children').find { |child| child['name'] == 'RESOURCES' } raise 'FHIR structure template has no RESOURCES branch' unless resource_root categories = reset_resource_categories(resource_root) concrete_resources(structure_definitions).each do |resource| - category_path = resource_category(resource) - category = categories[category_path] || add_category(resource_root, categories, category_path) + category_path = resource_category(configuration, resource) + category = categories[category_path] || + add_category(configuration, resource_root, categories, resource, category_path) category.fetch('children') << { 'name' => humanize(resource.fetch('name')) } end result end - def self.write_from_archive(archive_path, template_path, output_path) - structure = from_archive(archive_path, template_path) - File.write(output_path, "#{JSON.pretty_generate(structure)}\n") + def self.write_from_archive(configuration, archive_path) + structure = from_archive(configuration, archive_path) + File.write(configuration.output_path, serialize(structure)) end + def self.read_exact_archive_entry(configuration, archive_path) + entries, list_status = Open3.capture2e('unzip', '-Z1', archive_path) + unless list_status.success? + raise "Unable to list #{configuration.label} definitions archive #{archive_path}" + end + + unless entries.lines(chomp: true).count(configuration.archive_entry) == 1 + raise "Unable to read #{configuration.label} archive entry " \ + "#{configuration.archive_entry} from #{archive_path}" + end + + contents, extract_status = Open3.capture2e( + 'unzip', + '-p', + archive_path, + configuration.archive_entry + ) + unless extract_status.success? + raise "Unable to read #{configuration.label} archive entry " \ + "#{configuration.archive_entry} from #{archive_path}" + end + + contents + end + private_class_method :read_exact_archive_entry + + def self.serialize(structure) + json = JSON.pretty_generate(structure) + json = json.gsub(/^(\s*)"children": \[\]$/) do + indentation = Regexp.last_match(1) + "#{indentation}\"children\": [\n\n#{indentation}]" + end + "#{json}\n" + end + private_class_method :serialize + def self.reset_resource_categories(resource_root) resource_root.fetch('children').each_with_object({}) do |section, categories| section.fetch('children').each do |category| @@ -60,19 +148,24 @@ def self.concrete_resources(structure_definitions) end private_class_method :concrete_resources - def self.resource_category(resource) + def self.resource_category(configuration, resource) extension = resource.fetch('extension', []).find { |item| item['url'] == CATEGORY_URL } category = extension && extension['valueString'] - category ||= CATEGORY_OVERRIDES[resource.fetch('name')] - raise "No R4B resource category for #{resource.fetch('name')}" unless category + category ||= configuration.category_overrides[resource.fetch('name')] + unless category + raise "No #{configuration.label} resource category for #{resource.fetch('name')}" + end CGI.unescapeHTML(category) end private_class_method :resource_category - def self.add_category(resource_root, categories, category_path) + def self.add_category(configuration, resource_root, categories, resource, category_path) section_name, category_name = category_path.split('.', 2) - raise "Invalid R4B resource category: #{category_path}" unless category_name + if section_name.to_s.empty? || category_name.to_s.empty? + raise "Invalid #{configuration.label} resource category for " \ + "#{resource.fetch('name')}: #{category_path}" + end section = resource_root.fetch('children').find { |child| child['name'] == section_name } unless section diff --git a/lib/data/resources.rb b/lib/data/resources.rb index 7db34a87..00307352 100644 --- a/lib/data/resources.rb +++ b/lib/data/resources.rb @@ -3,9 +3,10 @@ module Generator class Resources FIXTURE_DIR = File.join(File.expand_path(File.join('..','..','..'),File.absolute_path(__FILE__)), 'fixtures') + class InvalidFixtureError < StandardError; end def initialize(fhir_version) - @fhir_version = fhir_version + @fhir_version = Crucible::FHIRVersion.resolve(fhir_version) @namespace = Crucible::FHIRVersion.namespace(@fhir_version) end @@ -239,15 +240,64 @@ def tag_metadata(resource) def load_fixture(path, extension) - full_path = File.join(fixture_path, "#{path}.#{extension.to_s}") versioned_path = File.join(fixture_path, "#{path}.#{@fhir_version}.#{extension}") full_path = versioned_path if File.exist?(versioned_path) - tag_metadata(@namespace.from_contents(File.read(full_path))) + resource = parse_fixture(File.read(full_path), extension, full_path) + tag_metadata(resource) end private + def parse_fixture(contents, extension, full_path) + format = extension.to_s.downcase.to_sym + format_namespace = case format + when :json + @namespace.const_get(:Json) + when :xml + @namespace.const_get(:Xml) + else + raise ArgumentError, "Unsupported fixture format: #{extension}" + end + resource = deserialize_fixture(format_namespace, format, contents) + unless resource + raise InvalidFixtureError, + "Invalid #{fixture_version_label} #{format.to_s.upcase} fixture #{full_path}: " \ + 'content did not deserialize to a FHIR resource' + end + + errors = format == :xml ? format_namespace.validate(contents) : resource.validate + validation_messages = flatten_validation_messages(errors) + unless validation_messages.empty? + raise InvalidFixtureError, + "Invalid #{fixture_version_label} #{format.to_s.upcase} fixture #{full_path}: " \ + "#{validation_messages.join('; ')}" + end + + resource + rescue InvalidFixtureError + raise + rescue StandardError => error + raise InvalidFixtureError, + "Invalid #{fixture_version_label} #{format.to_s.upcase} fixture #{full_path}: " \ + "#{error.message}" + end + + def deserialize_fixture(format_namespace, format, contents) + return format_namespace.from_json(contents) if format == :json + + format_namespace.from_xml(contents) + end + + def flatten_validation_messages(errors) + values = errors.is_a?(Hash) ? errors.values.flatten : errors + values.map { |error| error.respond_to?(:message) ? error.message : error.to_s } + end + + def fixture_version_label + @fhir_version.to_s.upcase + end + # FIXME: Determine a better way to share fixture data with Crucible def fixture_path if File.exist?(FIXTURE_DIR) diff --git a/lib/ext/client.rb b/lib/ext/client.rb index cd5f4d04..c2bc2a63 100644 --- a/lib/ext/client.rb +++ b/lib/ext/client.rb @@ -55,6 +55,7 @@ def fhir_patch(klass, id, patchset, options = {}, format = nil, additional_heade options = { resource: klass, id: id, format: format }.merge options headers = {} headers[:content_type] = "#{format}" + headers[:accept] = format headers[:prefer] = @return_preference if @use_return_preference headers.merge!(additional_header) if format == FHIR::Formats::ResourceFormat::RESOURCE_XML diff --git a/lib/fhir_version.rb b/lib/fhir_version.rb index b9587659..517f65c4 100644 --- a/lib/fhir_version.rb +++ b/lib/fhir_version.rb @@ -4,13 +4,14 @@ module FHIRVersion dstu2: 'FHIR::DSTU2', stu3: 'FHIR::STU3', r4: 'FHIR', - r4b: 'FHIR::R4B' + r4b: 'FHIR::R4B', + r5: 'FHIR::R5' }.freeze KNOWN = NAMESPACES.keys.freeze class UnsupportedVersionError < ArgumentError; end - def self.resolve(value) + def self.resolve(value = nil) normalized = value.to_s.strip.downcase if normalized.empty? raise UnsupportedVersionError, diff --git a/lib/resource_generator.rb b/lib/resource_generator.rb index 5f3cf77c..86faade7 100644 --- a/lib/resource_generator.rb +++ b/lib/resource_generator.rb @@ -2,21 +2,53 @@ module Crucible module Tests class ResourceGenerator + class RequiredElementGenerationError < StandardError; end # Allow to embed this many extra levels if min != 0. # We no longer cut off generation if an element has a min requirement. # This just guards an infinite loop case, if it is possible in FHIR EMBEDDED_LOOP_GUARD = 10 + INTEGER64_MIN = -(2**63) + INTEGER64_MAX = (2**63) - 1 + INTEGER64_RANGE = INTEGER64_MAX - INTEGER64_MIN + 1 + MIME_TYPE_BINDINGS = [ + 'http://hl7.org/fhir/ValueSet/mimetypes', + 'http://hl7.org/fhir/ValueSet/content-type', + 'http://www.rfc-editor.org/bcp/bcp13.txt' + ].freeze + REQUIRED_BINDING_FALLBACKS = { + 'http://tools.ietf.org/html/bcp47' => + 'http://hl7.org/fhir/ValueSet/languages', + 'http://hl7.org/fhir/ValueSet/ucum-units' => + 'http://hl7.org/fhir/ValueSet/units-of-time' + }.freeze + SELECTABLE_CODE_FALLBACKS = { + 'http://tools.ietf.org/html/bcp47' => { + 'urn:ietf:bcp:47' => ['en-US'].freeze + }.freeze + }.freeze + SIMPLE_QUANTITY_FIELDS = { + 'Range' => [:low, :high], + 'SampledData' => [:origin], + 'Observation::ReferenceRange' => [:low, :high] + }.freeze + R5_SAMPLED_DATA_PATTERN = /\A(?:-?\d*\.?\d+|[EUL])(?: (?:-?\d*\.?\d+|[EUL]))*\z/ + R5_IMAGING_SELECTION_2D_SCHEMA_CODES = %w[ + point + polyline + ellipse + ].freeze # # Generate a FHIR resource for the given class `klass` # If `embedded` is greater than zero, alledded children will also # be generated. # - def self.generate(klass,embedded=0) + def self.generate(klass,embedded=0, path: nil) resource = klass.new namespace = Crucible::FHIRVersion.namespace_name(Crucible::FHIRVersion.for_class(klass)) + path ||= klass.name Time.zone = 'UTC' - set_fields!(resource, namespace, embedded) + set_fields!(resource, namespace, embedded, path: path) resource.id=nil if resource.respond_to?(:id=) resource.versionId=nil if resource.respond_to?(:versionId=) resource.version=nil if resource.respond_to?(:version=) @@ -28,28 +60,34 @@ def self.generate(klass,embedded=0) # # Set the fields of this resource to have some random values. # - def self.set_fields!(resource, namespace, embedded=0) - - unselected_multiples = [] - if resource.class.constants.include? :MULTIPLE_TYPES - multiples = resource.class::MULTIPLE_TYPES.keys - all_multiples = multiples.map{|k| resource.class::MULTIPLE_TYPES[k].map{|d| "#{k}#{d.titleize.split.join}" }}.flatten - - # In DSTU2 Quantity sometimes can't be used directly, but the concrete type must be used instead. - # For example, Condition.abatementQuantity should be of type Age, but there's no such type - # definition in the DSTU2 models. So here, we're just skipping Quantity choice, - # and selecting some other (probably primitive) type for the multi-choice FHIR property. - ignore_multiple_types = ['Meta'] - ignore_multiple_types += ['Quantity'] if namespace == 'FHIR::DSTU2' - selected_multiples = multiples.map { |k| "#{k}#{resource.class::MULTIPLE_TYPES[k].reject { |t| ignore_multiple_types.include?(t) }.sample.titleize.split.join}" } - unselected_multiples = all_multiples - selected_multiples + def self.set_fields!( + resource, + namespace, + embedded=0, + choice_selector: nil, + path: nil + ) + path ||= resource.class.name + choice_selector ||= ->(types) { types.sample } + all_multiple_fields = multiple_type_fields(resource.class) + selected_multiples = selectable_multiple_type_fields( + resource.class, + namespace + ).filter_map do |_prefix, fields| + next if fields.empty? + + fields.fetch(choice_selector.call(fields.keys)) end + unselected_multiples = all_multiple_fields.values.flat_map(&:values) - + selected_multiples unselected_multiples.each do |key| resource.method("#{key}=").call(nil) end resource.class::METADATA.each do |key, meta| type = meta['type'] + method = meta['local_name'] || key + field_path = "#{path}.#{method}" next if type == 'Meta' next if ['id','contained','version','versionId','implicitRules'].include? key next if unselected_multiples.include?(key) @@ -62,12 +100,17 @@ def self.set_fields!(resource, namespace, embedded=0) elsif type == 'id' gen = SecureRandom.uuid elsif type == 'code' - if meta['valid_codes'] - gen = selectable_valid_codes(meta, namespace).values.flatten.sample - elsif meta['binding'] && ['http://tools.ietf.org/html/bcp47','http://hl7.org/fhir/ValueSet/languages'].include?(meta['binding']['uri']) - gen = 'en-US' - elsif meta['binding'] && ['http://www.rfc-editor.org/bcp/bcp13.txt','http://hl7.org/fhir/ValueSet/content-type'].include?(meta['binding']['uri']) + selectable_codes = selectable_valid_codes(meta, namespace) + if selectable_codes && !selectable_codes.empty? + gen = selectable_codes.values.flatten.sample + elsif MIME_TYPE_BINDINGS.include?(normalize_binding_uri(meta.dig('binding', 'uri'))) gen = MIME::Types.to_a.sample.content_type + elsif required_binding?(meta) + if meta.fetch('min', 0).zero? + gen = nil + else + raise unselectable_binding_message(meta) + end else gen = SecureRandom.base64 end @@ -89,7 +132,9 @@ def self.set_fields!(resource, namespace, embedded=0) gen = DateTime.now.strftime("%T") elsif type == 'boolean' gen = (SecureRandom.random_number(100) % 2 == 0) - elsif type == 'positiveInt' || type == 'unsignedInt' || type == 'integer' + elsif type == 'integer64' + gen = random_integer64 + elsif ['positiveInt', 'unsignedInt', 'integer'].include?(type) gen = (SecureRandom.random_number(100) + 1) # add one in case this is a "positiveInt" which must be > 0 elsif type == 'decimal' gen = SecureRandom.random_number @@ -99,35 +144,60 @@ def self.set_fields!(resource, namespace, embedded=0) elsif type == 'base64Binary' gen = SecureRandom.base64 elsif "#{namespace}::RESOURCES".constantize.include?(type) - if embedded > 0 || ((meta['binding'] || meta['min'] != 0) && EMBEDDED_LOOP_GUARD + embedded > 0) - type = 'Patient' if type == 'Resource' # can't use abstract "Resource" here, or can?? - gen = generate_child(type, namespace, embedded-1) - end + type = 'Patient' if type == 'Resource' # can't use abstract "Resource" here, or can?? + gen = generate_complex_field( + type, + namespace, + embedded, + meta, + field_path + ) elsif "#{namespace}::TYPES".constantize.include?(type) - if embedded > 0 || ((type == 'Coding' || meta['binding'] || meta['min'] != 0) && EMBEDDED_LOOP_GUARD + embedded > 0) - gen = generate_child(type, namespace, embedded-1) + gen = generate_complex_field( + type, + namespace, + embedded, + meta, + field_path, + force: type == 'Coding' + ) + if gen # apply bindings - if type == 'CodeableConcept' && meta['valid_codes'] && meta['binding'] - valid_codes = selectable_valid_codes(meta, namespace) - gen.coding.each do |c| - c.system = valid_codes.keys.sample - c.code = valid_codes[c.system].sample - display = "#{namespace}::Definitions".constantize.get_display(c.system, c.code) if "#{namespace}::Definitions".constantize.respond_to?('get_display') - c.display = display ? display : nil - end - elsif type == 'CodeableConcept' && meta['binding'] && meta['binding']['uri'] == 'http://hl7.org/fhir/ValueSet/use-context' + if type == 'CodeableConcept' && meta['binding'] && meta['binding']['uri'] == 'http://hl7.org/fhir/ValueSet/use-context' gen.coding.each do |c| c.system = 'https://www.usps.com/' c.code = ['CA','TX','NY','MA','DC'].sample end - elsif type == 'CodeableConcept' && meta['binding'] && meta['binding']['strength'] == 'required' && !meta['valid_codes'] && meta['min'] == 0 - gen = nil # Cannot generate valid code for external required binding (e.g. LOINC/SNOMED); field is optional so safe to skip - elsif type == 'Coding' && meta['valid_codes'] && meta['binding'] + elsif type == 'CodeableConcept' && meta['binding'] valid_codes = selectable_valid_codes(meta, namespace) - gen.system = valid_codes.keys.sample - gen.code = valid_codes[gen.system].sample - display = "#{namespace}::Definitions".constantize.get_display(gen.system, gen.code) if "#{namespace}::Definitions".constantize.respond_to?('get_display') - gen.display = display ? display : nil + if valid_codes && !valid_codes.empty? + gen.coding.each do |c| + c.system = valid_codes.keys.sample + c.code = valid_codes[c.system].sample + display = "#{namespace}::Definitions".constantize.get_display(c.system, c.code) if "#{namespace}::Definitions".constantize.respond_to?('get_display') + c.display = display ? display : nil + end + elsif required_binding?(meta) + if meta.fetch('min', 0).zero? + gen = nil + else + raise unselectable_binding_message(meta) + end + end + elsif type == 'Coding' && meta['binding'] + valid_codes = selectable_valid_codes(meta, namespace) + if valid_codes && !valid_codes.empty? + gen.system = valid_codes.keys.sample + gen.code = valid_codes[gen.system].sample + display = "#{namespace}::Definitions".constantize.get_display(gen.system, gen.code) if "#{namespace}::Definitions".constantize.respond_to?('get_display') + gen.display = display ? display : nil + elsif required_binding?(meta) + if meta.fetch('min', 0).zero? + gen = nil + else + raise unselectable_binding_message(meta) + end + end elsif type == 'Reference' gen.reference = nil gen.display = "#{meta['type_profiles'].map{|x|x.split('/').last}.sample} #{gen.display}" if meta['type_profiles'] @@ -139,58 +209,120 @@ def self.set_fields!(resource, namespace, embedded=0) end end elsif resource.class.constants.include? type.demodulize.to_sym - if embedded > 0 || ((meta['binding'] || meta['min'] != 0) && EMBEDDED_LOOP_GUARD + embedded > 0) - # CHILD component - gen = generate_child(type, namespace, embedded-1) - end + # CHILD component + gen = generate_complex_field( + type, + namespace, + embedded, + meta, + field_path + ) elsif ancestor_fhir_classes(resource.class, namespace).include? type.demodulize.to_sym - if embedded > 0 || ((meta['binding'] || meta['min'] != 0) && EMBEDDED_LOOP_GUARD + embedded > 0) - gen = generate_child(type, namespace, embedded-1) - end + gen = generate_complex_field( + type, + namespace, + embedded, + meta, + field_path + ) elsif ("#{namespace}::#{type}".constantize rescue nil) - if embedded > 0 || ((meta['binding'] || meta['min'] != 0) && EMBEDDED_LOOP_GUARD + embedded > 0) - gen = generate_child(type, namespace, embedded-1) - end + gen = generate_complex_field( + type, + namespace, + embedded, + meta, + field_path + ) else puts "Unable to generate field #{key} for #{resource.class} -- unrecognized type: #{type}" end - method = meta['local_name'] ? meta['local_name'] : key gen = [gen] if meta['max'] > 1 && !gen.nil? resource.method("#{method}=").call(gen) if !gen.nil? end resource end + def self.multiple_type_fields(klass) + return {} unless klass.const_defined?(:MULTIPLE_TYPES, false) + + metadata = klass.const_get(:METADATA, false) + klass.const_get(:MULTIPLE_TYPES, false).each_with_object({}) do |(prefix, types), groups| + groups[prefix] = types.each_with_object({}) do |type, fields| + field = "#{prefix}#{type[0].upcase}#{type[1..]}" + fields[type] = field if metadata.key?(field) + end + end + end + + def self.selectable_multiple_type_fields(klass, namespace) + ignored_types = ['Meta'] + # Some DSTU2 choices advertise abstract Quantity where only a concrete + # subtype is valid, so retain the established DSTU2-only exclusion. + ignored_types << 'Quantity' if namespace == 'FHIR::DSTU2' + + multiple_type_fields(klass).each_with_object({}) do |(prefix, fields), selectable| + selectable[prefix] = fields.reject do |type, _field| + ignored_types.include?(type) + end + end + end + + def self.random_integer64(random: SecureRandom) + INTEGER64_MIN + random.random_number(INTEGER64_RANGE) + end + def self.selectable_valid_codes(meta, namespace) valid_codes = meta['valid_codes'] - binding_uri = meta.dig('binding', 'uri') - return valid_codes unless binding_uri + binding_uris = selectable_binding_uris(meta) + return valid_codes if binding_uris.empty? + + @selectable_valid_codes_cache ||= {} + cache_key = [ + namespace, + binding_uris, + valid_codes_fingerprint(valid_codes) + ] + return @selectable_valid_codes_cache[cache_key] if + @selectable_valid_codes_cache.key?(cache_key) + selectable_codes = binding_uris.filter_map do |binding_uri| + expansion_codes(binding_uri, namespace) + end.find { |codes| !codes.empty? } + selectable_codes ||= binding_uris.filter_map do |binding_uri| + SELECTABLE_CODE_FALLBACKS[binding_uri] + end.find { |codes| !codes.empty? } + + result = if selectable_codes && valid_codes + valid_codes.each_with_object({}) do |(system, codes), filtered| + selectable = codes & selectable_codes.fetch(system, []) + filtered[system] = selectable unless selectable.empty? + end + elsif selectable_codes + selectable_codes + else + valid_codes + end + @selectable_valid_codes_cache[cache_key] = result + end + + def self.expansion_codes(binding_uri, namespace) definitions = "#{namespace}::Definitions".constantize - return valid_codes unless definitions.respond_to?(:expansions) + return unless definitions.respond_to?(:expansions) @selectable_expansion_codes_cache ||= {} - normalized_uri = binding_uri.sub(/\|[A-Za-z0-9.\-]+\z/, '') - cache_key = [namespace, normalized_uri] - selectable_codes = @selectable_expansion_codes_cache[cache_key] - unless @selectable_expansion_codes_cache.key?(cache_key) - value_set = definitions.expansions.find { |resource| resource['url'] == normalized_uri } - selectable_codes = if value_set - collect_selectable_expansion_codes( - value_set.dig('expansion', 'contains'), - {} - ) - end - @selectable_expansion_codes_cache[cache_key] = selectable_codes - end - return valid_codes unless selectable_codes + cache_key = [namespace, binding_uri] + return @selectable_expansion_codes_cache[cache_key] if + @selectable_expansion_codes_cache.key?(cache_key) - filtered_codes = valid_codes.each_with_object({}) do |(system, codes), filtered| - selectable = codes & selectable_codes.fetch(system, []) - filtered[system] = selectable unless selectable.empty? + value_set = definitions.expansions.find do |resource| + resource['url'] == binding_uri end - - filtered_codes.empty? ? valid_codes : filtered_codes + @selectable_expansion_codes_cache[cache_key] = if value_set + collect_selectable_expansion_codes( + value_set.dig('expansion', 'contains'), + {} + ) + end end def self.collect_selectable_expansion_codes(entries, codes, inherited_system = nil) @@ -204,16 +336,97 @@ def self.collect_selectable_expansion_codes(entries, codes, inherited_system = n codes end + def self.selectable_binding_uris(meta) + binding = meta['binding'] + return [] unless binding + + primary = normalize_binding_uri(binding['uri']) + additional = binding.fetch('additional', []).filter_map do |entry| + normalize_binding_uri(entry['valueSet']) if entry['purpose'] == 'starter' + end + fallback = REQUIRED_BINDING_FALLBACKS[primary] + [primary, *additional, fallback].compact.uniq + end + + def self.normalize_binding_uri(uri) + uri&.sub(/\|[A-Za-z0-9.\-]+\z/, '') + end + + def self.valid_codes_fingerprint(valid_codes) + valid_codes&.map do |system, codes| + sorted_codes = codes.sort_by { |code| [code.class.name, code.to_s] } + [system, sorted_codes] + end&.sort_by { |system, _codes| [system.class.name, system.to_s] } + end + + def self.required_binding?(meta) + meta.dig('binding', 'strength') == 'required' + end + + def self.unselectable_binding_message(meta) + "No selectable codes for required binding " \ + "#{meta.dig('binding', 'uri')} at #{meta['path']}" + end + def self.ancestor_fhir_classes(klass,namespace) classes = klass.constants classes.concat ancestor_fhir_classes(klass.module_parent, namespace) if klass.module_parent.name != namespace && klass.module_parent != Object classes end - def self.generate_child(type, namespace, embedded=0) + def self.generate_complex_field( + type, + namespace, + embedded, + meta, + field_path, + force: false + ) + required = meta.fetch('min', 0).positive? + extend_depth = force || meta['binding'] || required + within_loop_guard = EMBEDDED_LOOP_GUARD + embedded > 0 + should_generate = embedded.positive? || + (extend_depth && within_loop_guard) + + unless should_generate + raise_required_element_generation_error!( + meta, + field_path, + embedded + ) if required + return + end + + child = generate_child( + type, + namespace, + embedded - 1, + path: field_path + ) + raise_required_element_generation_error!( + meta, + field_path, + embedded + ) if child.nil? && required + child + end + + def self.raise_required_element_generation_error!( + meta, + field_path, + embedded + ) + raise RequiredElementGenerationError, + "Unable to generate required element #{field_path} " \ + "(definition #{meta['path']}, minimum #{meta.fetch('min', 0)}) " \ + "within recursion guard #{EMBEDDED_LOOP_GUARD} " \ + "at embedded depth #{embedded}" + end + + def self.generate_child(type, namespace, embedded=0, path: nil) return if ['Meta','Extension','PrimitiveExtension'].include? type klass = "#{namespace}::#{type}".constantize - generate(klass, embedded) + generate(klass, embedded, path: path) end def self.random_oid @@ -275,6 +488,7 @@ def self.minimal_condition(system='http://snomed.info/sct', code='414915002', pa end end resource.code = minimal_codeableconcept(system, code, namespace: namespace) + resource.clinicalStatus = 'active' if resource.respond_to?(:clinicalStatus=) resource.verificationStatus = 'confirmed' fix_condition(resource) tag_metadata(resource, namespace: namespace) @@ -369,7 +583,7 @@ def self.fix_codeable_reference(resource) def self.fix_condition(resource) version = Crucible::FHIRVersion.for_class(resource) - return resource unless [:r4, :r4b].include?(version) + return resource unless [:r4, :r4b, :r5].include?(version) namespace = Crucible::FHIRVersion.namespace(version) if resource.clinicalStatus.kind_of? String @@ -387,8 +601,201 @@ def self.fix_condition(resource) resource end + def self.clear_prohibited_observation_quantity_comparators!(observation) + return observation unless observation.respond_to?(:resourceType) + return observation unless observation.resourceType == 'Observation' + + each_fhir_model(observation) do |model| + SIMPLE_QUANTITY_FIELDS.fetch(relative_fhir_class_name(model), []).each do |field| + quantity = model.public_send(field) + quantity.comparator = nil if quantity + end + end + observation + end + + def self.each_fhir_model(value, seen = {}, &block) + if value.is_a?(Array) + value.each { |entry| each_fhir_model(entry, seen, &block) } + return + end + if value.is_a?(Hash) + value.each_value { |entry| each_fhir_model(entry, seen, &block) } + return + end + return unless value.is_a?(FHIR::Model) + return if seen[value.object_id] + + seen[value.object_id] = true + yield value + value.instance_variables.each do |variable| + each_fhir_model(value.instance_variable_get(variable), seen, &block) + end + end + + def self.relative_fhir_class_name(model) + version = Crucible::FHIRVersion.for_class(model) + namespace = Crucible::FHIRVersion.namespace_name(version) + model.class.name.sub(/\A#{Regexp.escape(namespace)}::/, '') + end + + def self.apply_r5_invariants!(resource) + case resource + when FHIR::R5::UsageContext + ensure_serializable_choice!( + resource, + 'value', + 'valueCodeableConcept', + textonly_codeableconcept( + 'Generated usage context', + namespace: FHIR::R5 + ) + ) + when FHIR::R5::BiologicallyDerivedProduct::Property + ensure_serializable_choice!( + resource, + 'value', + 'valueString', + 'Generated biologically derived product property' + ) + when FHIR::R5::EvidenceVariable::Characteristic::DefinitionByTypeAndValue + ensure_serializable_choice!( + resource, + 'value', + 'valueId', + 'generated-value' + ) + when FHIR::R5::Group::Characteristic + ensure_serializable_choice!( + resource, + 'value', + 'valueBoolean', + true + ) + when FHIR::R5::MeasureReport::Group::Stratifier::Stratum::Component + ensure_serializable_choice!( + resource, + 'value', + 'valueCodeableConcept', + textonly_codeableconcept( + 'Generated measure report stratifier value', + namespace: FHIR::R5 + ) + ) + when FHIR::R5::Ingredient::Substance::Strength::ReferenceStrength + ensure_serializable_choice!( + resource, + 'strength', + 'strengthQuantity', + minimal_quantity(namespace: FHIR::R5) + ) + when FHIR::R5::MedicationKnowledge::StorageGuideline::EnvironmentalSetting + ensure_serializable_choice!( + resource, + 'value', + 'valueQuantity', + minimal_quantity(namespace: FHIR::R5) + ) + when FHIR::R5::ServiceRequest::OrderDetail::Parameter + ensure_serializable_choice!( + resource, + 'value', + 'valueString', + 'Generated service request parameter' + ) + when FHIR::R5::ElementDefinition::Example + ensure_serializable_choice!( + resource, + 'value', + 'valueString', + 'Generated element definition example' + ) + when FHIR::R5::InventoryItem::Association + if !resource.quantity || + !choice_value_serializable?(resource.quantity) + resource.quantity = FHIR::R5::Ratio.new( + numerator: minimal_quantity(namespace: FHIR::R5), + denominator: minimal_quantity(1, '1', namespace: FHIR::R5) + ) + end + when FHIR::R5::ImagingSelection::Instance::ImageRegion2D + # The official R5 XSD applies its 3D enum to the 2D element. + unless R5_IMAGING_SELECTION_2D_SCHEMA_CODES.include?( + resource.regionType + ) + resource.regionType = R5_IMAGING_SELECTION_2D_SCHEMA_CODES.first + end + when FHIR::R5::SampledData + resource.origin.comparator = nil if resource.origin + resource.data = '0' if resource.data && + !R5_SAMPLED_DATA_PATTERN.match?(resource.data) + when FHIR::R5::TestReport::Test + ensure_r5_test_report_action!(resource) + when FHIR::R5::TestScript::Test + ensure_r5_test_script_action!(resource) + end + resource + end + + def self.ensure_serializable_choice!(resource, prefix, selected_field, value) + fields = multiple_type_fields(resource.class).fetch(prefix).values + populated_fields = fields.select do |field| + choice_value_serializable?(resource.public_send(field)) + end + if populated_fields.length == 1 + selected = populated_fields.first + fields.each do |field| + resource.public_send("#{field}=", nil) unless field == selected + end + return resource + end + + fields.each { |field| resource.public_send("#{field}=", nil) } + resource.public_send("#{selected_field}=", value) + resource + end + + def self.choice_value_serializable?(value) + return false if value.nil? + return !value.to_hash.empty? if value.respond_to?(:to_hash) + return !value.empty? if value.respond_to?(:empty?) + + true + end + + def self.ensure_r5_test_report_action!(test) + return test if test.action.to_a.any? do |action| + choice_value_serializable?(action) + end + + operation = FHIR::R5::TestReport::Setup::Action::Operation.new( + result: 'pass' + ) + test.action = [ + FHIR::R5::TestReport::Test::Action.new(operation: operation) + ] + test + end + + def self.ensure_r5_test_script_action!(test) + return test if test.action.to_a.any? do |action| + choice_value_serializable?(action) + end + + operation = FHIR::R5::TestScript::Setup::Action::Operation.new( + encodeRequestUrl: true + ) + test.action = [ + FHIR::R5::TestScript::Test::Action.new(operation: operation) + ] + test + end + def self.apply_invariants!(resource) fix_codeable_reference(resource) + clear_prohibited_observation_quantity_comparators!(resource) + apply_r5_invariants!(resource) if + Crucible::FHIRVersion.for_class(resource) == :r5 case resource when FHIR::ActivityDefinition @@ -756,22 +1163,6 @@ def self.apply_invariants!(resource) end unless resource.enteralFormula.administration.nil? end resource.supplement.each { |s| s.quantity.comparator = nil unless s.quantity.nil? } - when FHIR::Observation - resource.referenceRange.each do |range| - range.low.comparator = nil unless range.low.nil? - range.high.comparator = nil unless range.high.nil? - end - resource.component.each do |component| - if !component.valueRange.nil? - component.valueRange.low.comparator = nil unless component.valueRange.low.nil? - component.valueRange.high.comparator = nil unless component.valueRange.high.nil? - end - component.referenceRange.each do |range| - range.low.comparator = nil unless range.low.nil? - range.high.comparator = nil unless range.high.nil? - end - end - when FHIR::OperationDefinition resource.parameter.each do |p| p.binding = nil @@ -1538,22 +1929,6 @@ def self.apply_invariants!(resource) end unless resource.enteralFormula.administration.nil? end resource.supplement.each { |s| s.quantity.comparator = nil unless s.quantity.nil? } - when FHIR::STU3::Observation - resource.referenceRange.each do |range| - range.low.comparator = nil unless range.low.nil? - range.high.comparator = nil unless range.high.nil? - end - resource.component.each do |component| - if !component.valueRange.nil? - component.valueRange.low.comparator = nil unless component.valueRange.low.nil? - component.valueRange.high.comparator = nil unless component.valueRange.high.nil? - end - component.referenceRange.each do |range| - range.low.comparator = nil unless range.low.nil? - range.high.comparator = nil unless range.high.nil? - end - end - when FHIR::STU3::OperationDefinition resource.parameter.each do |p| p.binding = nil diff --git a/lib/tasks/fhir_structure.rake b/lib/tasks/fhir_structure.rake index c5c9e7ce..0ceb029d 100644 --- a/lib/tasks/fhir_structure.rake +++ b/lib/tasks/fhir_structure.rake @@ -5,11 +5,21 @@ namespace :crucible do raise 'Usage: rake "crucible:generate_r4b_structure[path/to/r4b-definitions.json.zip]"' end - root = File.expand_path('../..', __dir__) Crucible::FHIRStructureGenerator.write_from_archive( - File.expand_path(args.definitions_archive), - File.join(root, 'lib', 'FHIR_structure_r4.json'), - File.join(root, 'lib', 'FHIR_structure_r4b.json') + Crucible::FHIRStructureGenerator::CONFIGURATIONS.fetch(:r4b), + File.expand_path(args.definitions_archive) + ) + end + + desc 'Generate the R5 FHIR structure index from the official definitions archive' + task :generate_r5_structure, [:definitions_archive] do |_task, args| + unless args.definitions_archive + raise 'Usage: rake "crucible:generate_r5_structure[path/to/r5-definitions.json.zip]"' + end + + Crucible::FHIRStructureGenerator.write_from_archive( + Crucible::FHIRStructureGenerator::CONFIGURATIONS.fetch(:r5), + File.expand_path(args.definitions_archive) ) end end diff --git a/lib/tasks/resource_generation.rake b/lib/tasks/resource_generation.rake new file mode 100644 index 00000000..dc6fa632 --- /dev/null +++ b/lib/tasks/resource_generation.rake @@ -0,0 +1,18 @@ +namespace :crucible do + desc 'Audit repeated generation of every concrete FHIR R5 resource' + task :audit_r5_resource_generation, + [:output_dir, :seed, :iterations, :resource, :depth, :iteration] do |_task, args| + options = {} + options[:output_dir] = args.output_dir if args.output_dir + options[:seed] = Integer(args.seed) if args.seed + options[:iterations] = Integer(args.iterations) if args.iterations + options[:resources] = [args.resource] if args.resource + options[:depths] = [Integer(args.depth)] if args.depth + options[:iteration_indices] = [Integer(args.iteration)] if args.iteration + + result = Crucible::Tests::R5ResourceGenerationAudit.new(**options).run + puts result.summary + result.failures.each { |failure| $stderr.puts failure.summary } + raise "#{result.failures.length} R5 resource generation case(s) failed" unless result.success? + end +end diff --git a/lib/tasks/tasks.rake b/lib/tasks/tasks.rake index d48c20f5..c3a9c008 100644 --- a/lib/tasks/tasks.rake +++ b/lib/tasks/tasks.rake @@ -31,12 +31,12 @@ namespace :crucible do desc 'execute all' task :execute_all, [:url, :fhir_version, :output] do |t, args| - FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) fhir_version = resolve_fhir_version(args.fhir_version) + FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'benchmark' result = {} b = Benchmark.measure { - client = FHIR::Client.new(args.url, fhir_version: fhir_version) + client = build_fhir_client(args.url, fhir_version) client.setup_security result = execute_all(args.url, client, args.output) } @@ -47,11 +47,11 @@ namespace :crucible do desc 'execute all test scripts' task :execute_all_testscripts, [:url, :fhir_version, :output] do |t, args| - FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) fhir_version = resolve_testscript_fhir_version(args.fhir_version) + FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'benchmark' b = Benchmark.measure { - client = FHIR::Client.new(args.url, fhir_version: fhir_version) + client = build_fhir_client(args.url, fhir_version) client.setup_security results = Crucible::Tests::TestScriptEngine.new(client).execute_all process_results(results, args.url, args.output) @@ -61,11 +61,11 @@ namespace :crucible do desc 'execute testscript and get testreport' task :testreport, [:url, :fhir_version, :test, :filename] do |t, args| - FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) fhir_version = resolve_testscript_fhir_version(args.fhir_version) + FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'benchmark' b = Benchmark.measure { - client = FHIR::Client.new(args.url, fhir_version: fhir_version) + client = build_fhir_client(args.url, fhir_version) client.setup_security engine = Crucible::Tests::TestScriptEngine.new(client) script = engine.find_test(args.test) @@ -86,12 +86,12 @@ namespace :crucible do desc 'execute' task :execute, [:url, :fhir_version, :test, :resource, :output] do |t, args| - FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) fhir_version = resolve_fhir_version(args.fhir_version) + FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'benchmark' result = {} b = Benchmark.measure { - client = FHIR::Client.new(args.url, fhir_version: fhir_version) + client = build_fhir_client(args.url, fhir_version) client.setup_security result = execute_test(args.url, client, args.test, args.resource, args.output) } @@ -101,10 +101,20 @@ namespace :crucible do end desc 'metadata' - task :metadata, [:test] do |t, args| + task :metadata, [:test, :fhir_version] do |t, args| + fhir_version = resolve_fhir_version(args.fhir_version) FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) + client = build_fhir_client('http://metadata.local', fhir_version) + executor = Crucible::Tests::Executor.new(client) + test = executor.find_test(args.test) + raise ArgumentError, "Unable to find test: #{args.test}" unless test + unless eligible_for_fhir_version?(test, fhir_version) + raise Crucible::FHIRVersion::UnsupportedVersionError, + "Test #{args.test} does not support fhir version #{fhir_version}" + end + require 'benchmark' - b = Benchmark.measure { puts JSON.pretty_unparse(Crucible::Tests::Executor.new(nil).extract_metadata_from_test(args.test)) } + b = Benchmark.measure { puts JSON.pretty_unparse(executor.extract_metadata_from_test(args.test)) } puts "Metadata #{args.test} completed in #{b.real} seconds." end @@ -155,6 +165,19 @@ namespace :crucible do "FHIR TestScripts require STU3, got #{version}" end + def build_fhir_client(url, fhir_version) + FHIR::Client.new(url, fhir_version: resolve_fhir_version(fhir_version)) + end + + def eligible_for_fhir_version?(test, fhir_version) + supported_versions = if test.respond_to?(:supported_versions) + test.supported_versions + else + test.fetch('supported_versions', []) + end + supported_versions.include?(resolve_fhir_version(fhir_version)) + end + def execute_test(url, client, key, resourceType=nil, output=nil) executor = Crucible::Tests::Executor.new(client) test = executor.find_test(key) @@ -162,7 +185,7 @@ namespace :crucible do puts "Unable to find test: #{key}" return end - if !test.supported_versions.include?(client.fhir_version) + unless eligible_for_fhir_version?(test, client.fhir_version) puts "Test #{key} does not support fhir version #{client.fhir_version}" return end @@ -183,7 +206,7 @@ namespace :crucible do all_results = {} executor.tests.each do |test| next if test.multiserver - next if !test.supported_versions.include?(client.fhir_version) + next unless eligible_for_fhir_version?(test, client.fhir_version) results = executor.execute(test) all_results.merge! process_results(results, url, output) end @@ -342,9 +365,9 @@ namespace :crucible do desc 'execute custom' task :execute_custom, [:test, :fhir_version, :resource_type, :output] do |t, args| + fhir_version = resolve_fhir_version(args.fhir_version) FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'benchmark' - fhir_version = resolve_fhir_version(args.fhir_version) puts "# #{args.test}" puts @@ -355,7 +378,7 @@ namespace :crucible do puts "## #{url}" puts "```" b = Benchmark.measure { - client = FHIR::Client.new(url, fhir_version: fhir_version) + client = build_fhir_client(url, fhir_version) client.setup_security execute_test(url, client, args.test, args.resource_type, args.output) } @@ -368,11 +391,11 @@ namespace :crucible do desc 'execute all custom' task :execute_all_custom, [:fhir_version, :output] do |t, args| + fhir_version = resolve_fhir_version(args.fhir_version) FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'benchmark' - fhir_version = resolve_fhir_version(args.fhir_version) - puts "# #{args.test}" + puts "# Execute All" puts seconds = 0.0 @@ -381,24 +404,24 @@ namespace :crucible do puts "## #{url}" puts "```" b = Benchmark.measure { - client = FHIR::Client.new(url, fhir_version: fhir_version) + client = build_fhir_client(url, fhir_version) client.setup_security - results = execute_all(url, client, output) + results = execute_all(url, client, args.output) } seconds += b.real puts "```" puts end - puts "Execute All Custom #{args.test} completed for #{FHIR_SERVERS.length} servers in #{seconds} seconds." + puts "Execute All Custom completed for #{FHIR_SERVERS.length} servers in #{seconds} seconds." end desc 'list all' task :list_all, [:fhir_version] do |t, args| + fhir_version = resolve_fhir_version(args.fhir_version) require 'benchmark' b = Benchmark.measure do tests = Crucible::Tests::Executor.list_all - - tests = tests.select{|k,t| t['supported_versions'].include?(resolve_fhir_version(args.fhir_version))} if !args.fhir_version.nil? + tests = tests.select { |_key, test| eligible_for_fhir_version?(test, fhir_version) } tests.each do |k, v| puts "#{k} (#{v['supported_versions'].join(',')})"; @@ -411,12 +434,13 @@ namespace :crucible do desc 'list names of test suites' task :list_suites, [:fhir_version] do |t, args| + fhir_version = resolve_fhir_version(args.fhir_version) require 'benchmark' b = Benchmark.measure do suites = Crucible::Tests::Executor.list_all suite_names = [] suites.each do |key,value| - suite_names << value['author'].split('::').last if !key.start_with?('TS') && (args.fhir_version.nil? || value['supported_versions'].include?(resolve_fhir_version(args.fhir_version))) + suite_names << value['author'].split('::').last if !key.start_with?('TS') && eligible_for_fhir_version?(value, fhir_version) end suite_names.uniq! suite_names.each {|x| puts " #{x}"} @@ -433,9 +457,9 @@ namespace :crucible do desc 'execute with requirements' task :execute_w_requirements, [:url, :fhir_version, :test, :resource, :html_summary] do |t, args| + fhir_version = resolve_fhir_version(args.fhir_version) FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) require 'ansi' - fhir_version = resolve_fhir_version(args.fhir_version) module Crucible module Tests @@ -456,7 +480,7 @@ namespace :crucible do end end - client = FHIR::Client.new(args.url, fhir_version: fhir_version) + client = build_fhir_client(args.url, fhir_version) client.setup_security client.monitor_requirements test = args.test.to_sym diff --git a/lib/tests/base_test.rb b/lib/tests/base_test.rb index dd905d06..03f3e9db 100644 --- a/lib/tests/base_test.rb +++ b/lib/tests/base_test.rb @@ -35,10 +35,9 @@ class BaseTest def initialize(client, client2=nil) @client = client - FHIR::Resource.new.client = client - FHIR::DSTU2::Resource.new.client = client - FHIR::STU3::Resource.new.client = client - FHIR::R4B::Resource.new.client = client + Crucible::FHIRVersion::KNOWN.each do |fhir_version| + Crucible::FHIRVersion.namespace(fhir_version).const_get(:Resource).client = client + end @client2 = client2 @client.monitor_requests if @client @client2.monitor_requests if @client2 diff --git a/lib/tests/r5_resource_generation_audit.rb b/lib/tests/r5_resource_generation_audit.rb new file mode 100644 index 00000000..1edf316f --- /dev/null +++ b/lib/tests/r5_resource_generation_audit.rb @@ -0,0 +1,456 @@ +require 'digest' +require 'date' +require 'fileutils' +require 'json' +require 'securerandom' + +module Crucible + module Tests + module DeterministicSecureRandom + THREAD_KEY = :crucible_resource_generation_random + + def gen_random(length) + random = Thread.current[THREAD_KEY] + return super unless random + + random.bytes(length) + end + end + + SecureRandom.singleton_class.prepend(DeterministicSecureRandom) unless + SecureRandom.singleton_class.ancestors.include?(DeterministicSecureRandom) + + module DeterministicDateTime + THREAD_KEY = :crucible_resource_generation_datetime + + def now(*arguments) + datetime = Thread.current[THREAD_KEY] + return datetime if datetime + + super + end + end + + DateTime.singleton_class.prepend(DeterministicDateTime) unless + DateTime.singleton_class.ancestors.include?(DeterministicDateTime) + + class R5ResourceGenerationAudit + ABSTRACT_RESOURCES = %w[ + Resource + DomainResource + CanonicalResource + MetadataResource + ].freeze + DEFAULT_DEPTHS = [2, 3, 4].freeze + DEFAULT_ITERATIONS = 2 + DEFAULT_SEED = 20_260_728 + DEFAULT_OUTPUT_DIR = File.join( + 'tmp', + 'errors', + 'R5ResourceGenerationAudit' + ).freeze + RANDOM_MUTEX = Mutex.new + + Case = Struct.new( + :resource_name, + :depth, + :iteration, + :seed, + keyword_init: true + ) do + def to_h + { + 'resource' => resource_name, + 'depth' => depth, + 'iteration' => iteration, + 'seed' => seed + } + end + end + + Failure = Struct.new( + :audit_case, + :exception, + :validation_errors, + :namespace_errors, + :required_element_errors, + :diagnostic_path, + :fixture_path, + keyword_init: true + ) do + def summary + details = [] + details << "#{exception.fetch('class')}: #{exception.fetch('message')}" if exception + details << "#{validation_errors.length} validation field(s)" unless validation_errors.empty? + details << "#{namespace_errors.length} namespace error(s)" unless namespace_errors.empty? + details << "#{required_element_errors.length} empty required element(s)" unless required_element_errors.empty? + "#{audit_case.resource_name} depth=#{audit_case.depth} " \ + "iteration=#{audit_case.iteration} seed=#{audit_case.seed}: " \ + "#{details.join(', ')} (#{diagnostic_path})" + end + end + + Result = Struct.new( + :audit_cases, + :failures, + :output_dir, + :base_seed, + :manifest_path, + keyword_init: true + ) do + def success? + failures.empty? + end + + def summary + "cases=#{audit_cases.length} failures=#{failures.length} " \ + "seed=#{base_seed} output=#{output_dir}" + end + end + + def initialize( + resources: nil, + depths: DEFAULT_DEPTHS, + iterations: DEFAULT_ITERATIONS, + iteration_indices: nil, + seed: DEFAULT_SEED, + output_dir: DEFAULT_OUTPUT_DIR, + generator: ResourceGenerator.method(:generate) + ) + @namespace = FHIR::R5 + @resources = Array(resources || concrete_resources).map(&:to_s).sort + @depths = Array(depths).map { |depth| Integer(depth) }.sort + @iterations = if iteration_indices + Array(iteration_indices).map { |iteration| Integer(iteration) }.sort + else + (0...Integer(iterations)).to_a + end + @seed = Integer(seed) + @output_dir = File.expand_path(output_dir) + @generator = generator + end + + attr_reader :output_dir, :seed + + def concrete_resources + FHIR::R5::RESOURCES - ABSTRACT_RESOURCES + end + + def cases + @cases ||= @resources.flat_map do |resource_name| + @depths.flat_map do |depth| + @iterations.map do |iteration| + Case.new( + resource_name: resource_name, + depth: depth, + iteration: iteration, + seed: seed_for(resource_name, depth, iteration) + ) + end + end + end + end + + def run + FileUtils.rm_rf(output_dir) + FileUtils.mkdir_p(output_dir) + failures = [] + + cases.each do |audit_case| + failure = run_case(audit_case) + failures << failure if failure + end + + manifest_path = write_manifest(failures) + Result.new( + audit_cases: cases, + failures: failures, + output_dir: output_dir, + base_seed: seed, + manifest_path: manifest_path + ) + end + + private + + def run_case(audit_case) + resource = nil + exception = nil + validation_errors = {} + namespace_errors = [] + required_element_errors = [] + + begin + with_seed(audit_case.seed) do + klass = @namespace.const_get(audit_case.resource_name, false) + resource = @generator.call(klass, audit_case.depth) + end + raise "Generator returned nil for #{audit_case.resource_name}" unless resource + + validation_errors = resource.validate + namespace_errors = collect_namespace_errors(resource) + required_element_errors = collect_required_element_errors(resource) + rescue StandardError => error + exception = exception_details(error) + end + + return if exception.nil? && + validation_errors.empty? && + namespace_errors.empty? && + required_element_errors.empty? + + write_failure( + audit_case, + resource, + exception, + validation_errors, + namespace_errors, + required_element_errors + ) + end + + def write_failure( + audit_case, + resource, + exception, + validation_errors, + namespace_errors, + required_element_errors + ) + stem = artifact_stem(audit_case) + diagnostic_path = File.join(output_dir, "#{stem}.diagnostic.json") + fixture_path, fixture_exception = write_fixture(stem, resource) + diagnostic = { + 'fhir_version' => 'r5', + 'base_seed' => seed, + 'case' => audit_case.to_h, + 'exception' => exception, + 'validation_errors' => validation_errors, + 'namespace_errors' => namespace_errors, + 'required_element_errors' => required_element_errors, + 'serialized_fixture' => fixture_path, + 'fixture_exception' => fixture_exception, + 'replay_command' => replay_command(audit_case) + } + File.write(diagnostic_path, JSON.pretty_generate(diagnostic)) + + Failure.new( + audit_case: audit_case, + exception: exception, + validation_errors: validation_errors, + namespace_errors: namespace_errors, + required_element_errors: required_element_errors, + diagnostic_path: diagnostic_path, + fixture_path: fixture_path + ) + end + + def write_fixture(stem, resource) + return [nil, nil] unless resource + + fixture_path = File.join(output_dir, "#{stem}.fixture.json") + File.write(fixture_path, resource.to_json) + [fixture_path, nil] + rescue StandardError => error + [nil, exception_details(error)] + end + + def collect_namespace_errors(resource) + errors = [] + each_model(resource) do |model, path| + next if model.class.name.start_with?("#{@namespace.name}::") + + errors << { + 'path' => path, + 'expected_namespace' => @namespace.name, + 'actual_class' => model.class.name + } + end + errors + end + + def collect_required_element_errors(resource) + errors = [] + each_model(resource) do |model, path| + choice_fields = collect_required_choice_errors(model, path, errors) + model.class::METADATA.each do |field, metadata| + next if choice_fields.include?(field) + + definitions = metadata.is_a?(Array) ? metadata : [metadata] + definitions.each do |definition| + next unless definition.fetch('min', 0).positive? + + local_name = definition['local_name'] || field + value = model.instance_variable_get("@#{local_name}") + next unless required_value_empty?(value, definition.fetch('min')) + + errors << { + 'path' => "#{path}.#{local_name}", + 'definition_path' => definition['path'], + 'minimum' => definition.fetch('min'), + 'value' => value + } + end + end + end + errors + end + + def collect_required_choice_errors(model, path, errors) + multiple_types = if model.class.const_defined?(:MULTIPLE_TYPES) + model.class.const_get(:MULTIPLE_TYPES) + else + {} + end + choice_fields = [] + + multiple_types.each do |prefix, suffixes| + fields = suffixes.map { |suffix| choice_field_name(prefix, suffix) } + choice_fields.concat(fields) + definitions = fields.flat_map do |field| + metadata = model.class::METADATA[field] + metadata.is_a?(Array) ? metadata : [metadata].compact + end + next unless definitions.any? { |definition| definition.fetch('min', 0).positive? } + + selected_fields = fields.select do |field| + choice_value_present?(model.instance_variable_get("@#{field}")) + end + selected_field = selected_fields.first + selected_value = model.instance_variable_get("@#{selected_field}") if selected_field + next if selected_field && !required_value_empty?(selected_value, 1) + + definition = definitions.find { |candidate| candidate.fetch('min', 0).positive? } + errors << { + 'path' => selected_field ? "#{path}.#{selected_field}" : "#{path}.#{prefix}[x]", + 'definition_path' => definition['path'], + 'minimum' => definition.fetch('min'), + 'value' => selected_value + } + end + + choice_fields + end + + def choice_field_name(prefix, suffix) + "#{prefix}#{suffix[0].upcase}#{suffix[1..-1]}" + end + + def choice_value_present?(value) + return false if value.nil? + return !value.empty? if value.is_a?(Array) + + true + end + + def each_model(value, path = nil, seen = {}, &block) + if value.is_a?(Array) + value.each_with_index do |entry, index| + each_model(entry, "#{path}[#{index}]", seen, &block) + end + return + end + if value.is_a?(Hash) + value.each do |key, entry| + each_model(entry, "#{path}.#{key}", seen, &block) + end + return + end + return unless value.is_a?(FHIR::Model) + return if seen[value.object_id] + + seen[value.object_id] = true + model_path = path || value.class.name + yield value, model_path + value.instance_variables.each do |variable| + field = variable.to_s.delete_prefix('@') + each_model( + value.instance_variable_get(variable), + "#{model_path}.#{field}", + seen, + &block + ) + end + end + + def required_value_empty?(value, minimum) + return true if value.nil? + return value.length < minimum if value.is_a?(Array) + return value.empty? if value.respond_to?(:empty?) + + false + end + + def exception_details(error) + { + 'class' => error.class.name, + 'message' => error.message, + 'backtrace' => Array(error.backtrace).first(20) + } + end + + def artifact_stem(audit_case) + [ + 'r5', + audit_case.resource_name, + "depth-#{audit_case.depth}", + "iteration-#{audit_case.iteration}", + "seed-#{audit_case.seed}" + ].join('-') + end + + def write_manifest(failures) + manifest_path = File.join(output_dir, 'manifest.json') + manifest = { + 'fhir_version' => 'r5', + 'base_seed' => seed, + 'case_count' => cases.length, + 'failure_count' => failures.length, + 'cases' => cases.map(&:to_h), + 'failure_diagnostics' => failures.map(&:diagnostic_path) + } + File.write(manifest_path, JSON.pretty_generate(manifest)) + manifest_path + end + + def replay_command(audit_case) + replay_output_dir = File.join(output_dir, 'replay') + arguments = [ + replay_output_dir, + seed, + @iterations.length, + audit_case.resource_name, + audit_case.depth, + audit_case.iteration + ].join(',') + "bundle exec rake \"crucible:audit_r5_resource_generation[#{arguments}]\"" + end + + def seed_for(resource_name, depth, iteration) + material = [seed, resource_name, depth, iteration].join(':') + Digest::SHA256.hexdigest(material).first(16).to_i(16) + end + + def with_seed(case_seed) + RANDOM_MUTEX.synchronize do + previous_random = Thread.current[DeterministicSecureRandom::THREAD_KEY] + previous_datetime = Thread.current[DeterministicDateTime::THREAD_KEY] + Thread.current[DeterministicSecureRandom::THREAD_KEY] = Random.new(case_seed) + Thread.current[DeterministicDateTime::THREAD_KEY] = datetime_for(case_seed) + srand(case_seed) + yield + ensure + Thread.current[DeterministicSecureRandom::THREAD_KEY] = previous_random + Thread.current[DeterministicDateTime::THREAD_KEY] = previous_datetime + srand + end + end + + def datetime_for(case_seed) + epoch = DateTime.new(2020, 1, 1, 0, 0, 0, '+00:00') + milliseconds = case_seed % (10 * 365 * 24 * 60 * 60 * 1_000) + epoch + Rational(milliseconds, 24 * 60 * 60 * 1_000) + end + end + end +end diff --git a/lib/tests/suites/base_suite.rb b/lib/tests/suites/base_suite.rb index 5cc4cb7c..b8b776fe 100644 --- a/lib/tests/suites/base_suite.rb +++ b/lib/tests/suites/base_suite.rb @@ -2,7 +2,14 @@ module Crucible module Tests class BaseSuite < BaseTest - EXCLUDED_RESOURCES = ['DomainResource', 'Resource', 'Parameters', 'OperationOutcome'] + EXCLUDED_RESOURCES = [ + 'CanonicalResource', + 'DomainResource', + 'MetadataResource', + 'OperationOutcome', + 'Parameters', + 'Resource' + ].freeze def title self.class.name.demodulize @@ -139,8 +146,7 @@ def self.test(key, desc, &block) def resource_category(resource) unless @resource_category @categories_by_resource = {} - fhir_version = Crucible::FHIRVersion.for_class(resource) - fhir_structure = Crucible::FHIRStructure.get(fhir_version) + fhir_structure = Crucible::FHIRStructure.for_resource(resource) categories = fhir_structure['children'].select {|n| n['name'] == 'RESOURCES'}.first['children'] pull_children = lambda {|n, chain| n['children'].nil? ? n['name'] : n['children'].map {|child| chain.call(child, chain)}} categories.each do |category| diff --git a/lib/tests/suites/connectathon_patch_track.rb b/lib/tests/suites/connectathon_patch_track.rb index 51d25bdf..3ad88a72 100644 --- a/lib/tests/suites/connectathon_patch_track.rb +++ b/lib/tests/suites/connectathon_patch_track.rb @@ -29,7 +29,7 @@ def setup end def teardown - @client.destroy(FHIR::MedicationRequest, @medication_order_id) unless @medication_order_id.nil? + @client.destroy(FHIR::STU3::MedicationRequest, @medication_order_id) unless @medication_order_id.nil? end ['JSON','XML'].each do |fmt| @@ -46,9 +46,9 @@ def teardown validates resource: 'MedicationRequest', methods: ['read'] } - reply = @client.read(FHIR::MedicationRequest, @medication_order_id, resource_format(fmt)) + reply = @client.read(FHIR::STU3::MedicationRequest, @medication_order_id, resource_format(fmt)) assert_response_ok(reply) - assert_resource_type(reply, FHIR::MedicationRequest) + assert_resource_type(reply, FHIR::STU3::MedicationRequest) assert_resource_content_type(reply, fmt.downcase) warning { assert(!reply.resource.meta.nil?, 'Last Updated and VersionId not present.') @@ -73,14 +73,14 @@ def teardown skip 'TODO: https://github.com/FirelyTeam/spark/issues/302' patchset = [{ op: "replace", path: "MedicationRequest/status", value: "completed" }] - reply = @client.partial_update(FHIR::MedicationRequest, @medication_order_id, patchset, {}, resource_format(fmt)) + reply = @client.partial_update(FHIR::STU3::MedicationRequest, @medication_order_id, patchset, {}, resource_format(fmt)) assert_response_ok(reply) warning { - assert_resource_type(reply, FHIR::MedicationRequest) + assert_resource_type(reply, FHIR::STU3::MedicationRequest) assert_resource_content_type(reply, fmt.downcase) } - reply = @client.read(FHIR::MedicationRequest, @medication_order_id, resource_format(fmt)) + reply = @client.read(FHIR::STU3::MedicationRequest, @medication_order_id, resource_format(fmt)) assert_response_ok(reply) assert_equal(reply.resource.status, 'completed', 'Status not updated from patch.') warning { @@ -109,11 +109,11 @@ def teardown # According to the FHIR spec, the If-Match eTag for version id should be weak. options = { 'If-Match' => "W/\"#{@previous_version_id}\"" } - reply = @client.partial_update(FHIR::MedicationRequest, @medication_order_id, patchset, options, resource_format(fmt)) + reply = @client.partial_update(FHIR::STU3::MedicationRequest, @medication_order_id, patchset, options, resource_format(fmt)) assert_response_conflict(reply) - reply = @client.read(FHIR::MedicationRequest, @medication_order_id, resource_format(fmt)) + reply = @client.read(FHIR::STU3::MedicationRequest, @medication_order_id, resource_format(fmt)) assert_response_ok(reply) assert_equal(reply.resource.status, 'completed', 'Resource should not have been patched because version id was stale.') diff --git a/lib/tests/suites/connectathon_patient_track.rb b/lib/tests/suites/connectathon_patient_track.rb index cc84bdcd..87ff556d 100644 --- a/lib/tests/suites/connectathon_patient_track.rb +++ b/lib/tests/suites/connectathon_patient_track.rb @@ -30,8 +30,8 @@ def setup end def teardown - @client.destroy(FHIR::Patient, @patient_id) if !@patient_id.nil? - @client.destroy(FHIR::Patient, @patient_us_id) if !@patient_us_id.nil? + @client.destroy(FHIR::STU3::Patient, @patient_id) if !@patient_id.nil? + @client.destroy(FHIR::STU3::Patient, @patient_us_id) if !@patient_us_id.nil? end # @@ -290,7 +290,7 @@ def teardown } skip 'Patient not registered properly in C8T1_1A.' unless @patient_id - result = @client.resource_instance_history(FHIR::Patient,@patient_id) + result = @client.resource_instance_history(FHIR::STU3::Patient,@patient_id) assert_response_ok result assert_equal 2, result.resource.total, 'The number of returned versions is not correct' warning { assert_equal 'history', result.resource.type, 'The bundle does not have the correct type: history' } @@ -355,10 +355,10 @@ def check_sort_order(entries) skip 'Patient not registered properly in C8T1_1A.' unless @patient_id - reply = @client.destroy(FHIR::Patient, @patient_id) + reply = @client.destroy(FHIR::STU3::Patient, @patient_id) assert([200, 204].include?(reply.code), 'The server should have returned a 200 or 204 upon successful deletion.') - reply = @client.read(FHIR::Patient, @patient_id) + reply = @client.read(FHIR::STU3::Patient, @patient_id) assert([404, 410].include?(reply.code), 'The server should have deleted the resource and now return 410.') warning { assert(reply.code == 404, 'Deleted resource was reported as unknown (404). If the system tracks deleted resources, it should respond with 410.')} diff --git a/lib/tests/suites/fhir_path_patch_test.rb b/lib/tests/suites/fhir_path_patch_test.rb index cf5ba165..57fb19ce 100644 --- a/lib/tests/suites/fhir_path_patch_test.rb +++ b/lib/tests/suites/fhir_path_patch_test.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('fhirpath') @category = { id: 'fhirpath', title: 'FHIRPath' } - @supported_versions = [:stu3, :r4, :r4b] + @supported_versions = [:stu3, :r4, :r4b, :r5] end def setup @@ -101,15 +101,12 @@ def teardown validates resource: 'MedicationRequest', methods: ['read'] } - skip 'TODO: Issue link (conditional patch - see http://www.hl7.org/fhir/R4/http.html#concurrency)' - assert(!@previous_version_id.nil?, "VersionId of Existing Medication Request not returned in C12PATCH_1_(#{fmt}).") patchset = patchset_resource("replace", "MedicationRequest.status", nil, "active") - # http://hl7.org/fhir/2016Sep/http.html#2.42.0.2 - # According to the FHIR spec, the If-Match eTag for version id should be weak. - additional_headers = { 'If-Match' => "\"#{@previous_version_id}\"" } + # According to the FHIR spec, the If-Match ETag for a version id is weak. + additional_headers = { 'If-Match' => "W/\"#{@previous_version_id}\"" } medication_request = get_resource(:MedicationRequest) reply = @client.fhir_patch(medication_request, @medication_order_id, patchset, {}, resource_format(fmt), additional_headers) diff --git a/lib/tests/suites/format_test.rb b/lib/tests/suites/format_test.rb index 1cf27605..5f83e5c2 100644 --- a/lib/tests/suites/format_test.rb +++ b/lib/tests/suites/format_test.rb @@ -2,9 +2,9 @@ module Crucible module Tests class FormatTest < BaseSuite - @@xml_format_params = ['xml', 'text/xml', 'application/xml', 'XML_FORMAT'] - @@json_format_params = ['json', 'application/json', 'JSON_FORMAT'] - @@alpha = ['A', 'B', 'C', 'D'] + @@xml_format_params = ['xml', 'text/xml', 'application/xml', 'application/fhir+xml', 'XML_FORMAT'] + @@json_format_params = ['json', 'application/json', 'application/fhir+json', 'JSON_FORMAT'] + @@alpha = ['A', 'B', 'C', 'D', 'E'] def id 'Format001' @@ -16,7 +16,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] if client1&.fhir_version == :dstu2 @xml_format = FHIR::Formats::ResourceFormat::RESOURCE_XML_DSTU2 @json_format = FHIR::Formats::ResourceFormat::RESOURCE_JSON_DSTU2 @@ -81,7 +81,7 @@ def teardown begin patient = request_entry(get_resource(:Patient), @id, @xml_format) assert compare_response_format(patient, @xml_format), "XML format header mismatch: requested #{@xml_format}, received #{patient.response_format}" - warning { assert compare_response(patient), 'requested XML response does not match created resource' } + assert_response_matches(patient, 'requested XML response does not match created resource') rescue => e raise AssertionException.new("CTO1 - Failed to handle XML format header response. Error: #{e.message}") end @@ -103,7 +103,7 @@ def teardown wire_format = @json_format if format == 'JSON_FORMAT' patient = request_entry(get_resource(:Patient), @id, wire_format, true) assert compare_response_format(patient, @xml_format), "XML format param mismatch: requested #{format}, received #{patient.response_format}" - warning { assert compare_response(patient), 'requested XML response does not match created resource' } + assert_response_matches(patient, 'requested XML response does not match created resource') rescue => e @client.use_format_param = false raise AssertionException.new("CTO2 - Failed to handle XML format param response. Error: #{e.message}") @@ -123,7 +123,7 @@ def teardown begin patient = request_entry(get_resource(:Patient), @id, @json_format) assert compare_response_format(patient, @json_format), "JSON format header mismatch: requested #{@json_format}, received #{patient.response_format}" - warning { assert compare_response(patient), 'requested JSON resource does not match created resource' } + assert_response_matches(patient, 'requested JSON resource does not match created resource') rescue => e raise AssertionException.new("CTO3 - Failed to handle JSON format header response. Error: #{e.message}") end @@ -145,7 +145,7 @@ def teardown wire_format = @json_format if format == 'JSON_FORMAT' patient = request_entry(get_resource(:Patient), @id, wire_format, true) assert compare_response_format(patient, @json_format), "JSON format param mismatch: requested #{wire_format}, received #{patient.response_format}" - warning { assert compare_response(patient), 'requested JSON response does not match created resource' } + assert_response_matches(patient, 'requested JSON response does not match created resource') rescue => e @client.use_format_param = false raise AssertionException.new("CTO4 - Failed to handle JSON format param response. Error: #{e.message}") @@ -168,7 +168,7 @@ def teardown assert compare_response_format(patient_xml, @xml_format), "XML format header mismatch: requested #{@xml_format}, received #{patient_xml.response_format}" assert compare_response_format(patient_json, @json_format), "JSON format header mismatch: requested #{@json_format}, received #{patient_json.response_format}" - warning { assert compare_entries(patient_xml, patient_json), 'requested XML & JSON resources do not match created resource or each other' } + assert_entries_match(patient_xml, patient_json, 'requested XML & JSON resources do not match created resource or each other') rescue => e @client.use_format_param = false raise AssertionException.new("FT01 - Failed to handle XML & JSON header param response. Error: #{e.message}") @@ -190,7 +190,7 @@ def teardown assert compare_response_format(patient_xml, @xml_format), "XML format header mismatch: requested #{@xml_format}, received #{patient_xml.response_format}" assert compare_response_format(patient_json, @json_format), "JSON format header mismatch: requested #{@json_format}, received #{patient_json.response_format}" - warning { assert compare_entries(patient_xml, patient_json), 'requested XML & JSON responses do not match created resource or each other' } + assert_entries_match(patient_xml, patient_json, 'requested XML & JSON responses do not match created resource or each other') rescue => e @client.use_format_param = false raise AssertionException.new("FT02 - Failed to handle XML & JSON format param response. Error: #{e.message}") @@ -345,6 +345,22 @@ def compare_entries(entry1, entry2) compare_response(entry1) && compare_response(entry2) && entry1.resource.equals?(entry2.resource,['id']) end + def assert_response_matches(entry, message) + if fhir_version == :r5 + assert compare_response(entry), message + else + warning { assert compare_response(entry), message } + end + end + + def assert_entries_match(entry1, entry2, message) + if fhir_version == :r5 + assert compare_entries(entry1, entry2), message + else + warning { assert compare_entries(entry1, entry2), message } + end + end + # Unify resource requests and format specification def request_entry(resource_class, id, format, use_format_param=false) @client.use_format_param = use_format_param diff --git a/lib/tests/suites/history_test.rb b/lib/tests/suites/history_test.rb index b94c3cd1..5ea66de9 100644 --- a/lib/tests/suites/history_test.rb +++ b/lib/tests/suites/history_test.rb @@ -12,7 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] @category = {id: 'core_functionality', title: 'Core Functionality'} end @@ -36,6 +36,7 @@ def setup @version << @client.reply.version @patient.destroy assert([200,204].include?(@client.reply.code), 'The server should have returned a 200 or 204 upon successful deletion.') + @deleted_version = @client.reply.version @entry_count = @version.length # add one for deletion @@ -80,9 +81,9 @@ def teardown bundle = get_resource(:Patient).resource_instance_history(@patient.id) entries = bundle.entry - assert_equal 1, entries.select{|entry| entry.request.try(:local_method) == 'DELETE' }.size, 'Wrong number of DELETE transactions in the history bundle' - assert_equal 1, entries.select{|entry| entry.request.try(:local_method) == 'PUT' }.size, 'Wrong number of PUT transactions in the history bundle' - assert_equal 1, entries.select{|entry| entry.request.try(:local_method) == 'POST' }.size, 'Wrong number of POST transactions in the history bundle' + assert_equal 1, entries.count { |entry| history_request_method(entry) == 'DELETE' }, 'Wrong number of DELETE transactions in the history bundle' + assert_equal 1, entries.count { |entry| history_request_method(entry) == 'PUT' }, 'Wrong number of PUT transactions in the history bundle' + assert_equal 1, entries.count { |entry| history_request_method(entry) == 'POST' }, 'Wrong number of POST transactions in the history bundle' end @@ -132,17 +133,16 @@ def teardown active_entries(bundle.entry).each do |entry| pulled = get_resource(:Patient).vread(entry.resource.id, entry.resource.meta.versionId) assert !pulled.nil?, "Cannot find version that was present in history" + assert_equal get_resource(:Patient), pulled.class, 'Version read was not parsed with the selected FHIR version.' end - deleted_entries(bundle.entry).each do |entry| - # FIXME: Should we parse the request URL or drop this assertion? - if entry.resource + deleted_version = @deleted_version.presence || deleted_history_version(bundle) + skip 'Server did not expose a deletion versionId.' if deleted_version.blank? - ignore_client_exception { pulled = get_resource(:Patient).vread(entry.resource.id, entry.resource.meta.versionId) } - assert_response_gone @client.reply - - end + ignore_client_exception do + get_resource(:Patient).vread(@patient.id, deleted_version) end + assert_response_gone @client.reply end test "HI04", "history for missing resource" do @@ -154,7 +154,22 @@ def teardown ignore_client_exception { get_resource(:Patient).resource_instance_history('3141592unlikely') } assert_response_not_found @client.reply - assert @client.reply.resource.nil?, 'bad history request should not return a resource' + if @client.reply.resource + assert_equal get_resource(:OperationOutcome), @client.reply.resource.class, + 'History error response was not parsed with the selected FHIR version.' + end + end + + test "HI05", "read a deleted resource" do + metadata { + links "#{REST_SPEC_LINK}#read" + requires resource: "Patient", methods: ["create", "update", "delete"] + validates resource: "Patient", methods: ["read"] + } + skip 'Patient not correctly created in setup.' unless @patient_setup + + response = @client.read(get_resource(:Patient), @patient.id) + assert_response_gone response end test "HI06", "all history for resource with since" do @@ -197,7 +212,7 @@ def teardown assert (!bundle.nil? && bundle.class == get_resource(:Bundle)), "History should be a Bundle" entry_ids_are_present(bundle.entry) - relevant_entries = bundle.entry.select{|x|x.request.try(:local_method)!='DELETE'} + relevant_entries = bundle.entry.reject { |entry| history_request_method(entry) == 'DELETE' } relevant_entries.map!(&:resource).map!(&:meta).compact rescue assert(false, 'Unable to find meta for resources returned by the bundle') relevant_entries.each_cons(2) do |left, right| if !left.lastUpdated.nil? && !right.lastUpdated.nil? @@ -296,8 +311,7 @@ def teardown def deleted_entries(entries) entries.select do |entry| - assert !entry.request.nil?, "history bundle entries do not have request elements, deleted entries cannot be distinguished" - entry.request.try(:local_method) == "DELETE" + history_request_method(entry) == 'DELETE' end end @@ -307,7 +321,7 @@ def active_entries(entries) def entry_ids_are_present(entries) - relevant_entries = entries.select{|x|x.request.try(:local_method)!='DELETE'} + relevant_entries = entries.reject { |entry| history_request_method(entry) == 'DELETE' } ids = relevant_entries.map(&:resource).map(&:id).compact rescue assert(false, 'Unable to find IDs for resources returned by the bundle') # check that we have ids and self links @@ -319,7 +333,7 @@ def url?(v) end def check_sort_order(entries) - relevant_entries = entries.select{|x|x.request.try(:local_method)!='DELETE'} + relevant_entries = entries.reject { |entry| history_request_method(entry) == 'DELETE' } relevant_entry_metas = relevant_entries.map(&:resource).map!(&:meta).compact rescue assert(false, 'Unable to find meta for resources returned by the bundle') id_version_map = {} @@ -341,6 +355,19 @@ def check_sort_order(entries) end end + def history_request_method(entry) + assert !entry.request.nil?, 'History bundle entries must identify the originating request.' + + entry.request.local_method.to_s.upcase + end + + def deleted_history_version(bundle) + deleted_entry = deleted_entries(bundle.entry).first + return if deleted_entry.nil? + + deleted_entry.request.url.to_s[/_history\/([^\/]+)\z/, 1] + end + end end end diff --git a/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb b/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb index e741c523..dc1e5cf3 100644 --- a/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb +++ b/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('indendilabs') @category = { id: 'indendilabs', title: 'Indendilabs' } - @supported_versions = [:stu3, :r4, :r4b] + @supported_versions = [:stu3, :r4, :r4b, :r5] end def setup @@ -25,7 +25,11 @@ def setup @patient_id = reply.id @consent = ResourceGenerator.generate(version_namespace.const_get(:Consent)) - @consent.patient = @patient.to_reference + if fhir_version == :r5 + @consent.subject = @patient.to_reference + else + @consent.patient = @patient.to_reference + end reply = @client.create(@consent) assert_response_ok(reply) @consent_id = reply.id @@ -58,7 +62,10 @@ def teardown reply = @client.search(version_namespace.const_get(:Consent), options) assert_response_ok(reply) assert_bundle_response(reply) - assert(1 == reply.resource.entry.size, "Consent not returned by search") + consent_entries = reply.resource.entry.select do |entry| + entry.resource.is_a?(version_namespace.const_get(:Consent)) + end + assert_equal [@consent_id], consent_entries.map { |entry| entry.resource.id }, 'The search did not return the created Consent.' end end diff --git a/lib/tests/suites/incendi_elements_search_parameter.rb b/lib/tests/suites/incendi_elements_search_parameter.rb index cf412da5..be229e47 100644 --- a/lib/tests/suites/incendi_elements_search_parameter.rb +++ b/lib/tests/suites/incendi_elements_search_parameter.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('incendilabs') @category = { id: 'incendilabs', title: 'Incendilabs' } - @supported_versions = [:stu3, :r4, :r4b] + @supported_versions = [:stu3, :r4, :r4b, :r5] end def setup @@ -80,6 +80,11 @@ def elements_search_options patient = patient_entries.first.resource assert_equal @patient_id, patient.id, 'Expected the Patient id to be retained even when _elements omits id.', reply.body + subsetted_tag = patient.meta&.tag&.find do |tag| + tag.system == 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue' && + tag.code == 'SUBSETTED' + end + assert(subsetted_tag, 'Expected the Patient meta.tag to identify the partial result as SUBSETTED.', reply.body) assert(patient.name && patient.name.any?, 'Expected Patient.name to be retained when _elements includes name.', reply.body) assert(patient.gender.nil?, 'Expected Patient.gender to be omitted when _elements only includes name,birthDate.', reply.body) assert_equal '1974-12-25', patient.birthDate, 'Expected Patient.birthDate to be retained when _elements includes birthDate.', reply.body diff --git a/lib/tests/suites/incendi_unknown_search_parameter_1160.rb b/lib/tests/suites/incendi_unknown_search_parameter_1160.rb index c8398f54..001c2c1e 100644 --- a/lib/tests/suites/incendi_unknown_search_parameter_1160.rb +++ b/lib/tests/suites/incendi_unknown_search_parameter_1160.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('incendilabs') @category = { id: 'incendilabs', title: 'Incendilabs' } - @supported_versions = [:stu3, :r4, :r4b] + @supported_versions = [:stu3, :r4, :r4b, :r5] end def setup diff --git a/lib/tests/suites/read_test.rb b/lib/tests/suites/read_test.rb index a44c6900..408d331a 100644 --- a/lib/tests/suites/read_test.rb +++ b/lib/tests/suites/read_test.rb @@ -12,23 +12,25 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] @category = {id: 'core_functionality', title: 'Core Functionality'} end def setup - # try to find a patient begin - response = @client.read_feed(get_resource(:Patient)) - @patient = response.resource.entry.first.resource + patient = get_resource(:Patient).new( + meta: { tag: [{ system: 'http://projectcrucible.org', code: 'testdata' }] }, + name: { family: 'Emerald', given: 'Caro' } + ) + response = @client.create(patient) + assert_response_ok(response) + @patient = response.resource || patient + @patient.id ||= response.id + raise 'Create response did not identify the Patient' if @patient.id.blank? + + @patient_created = true rescue - # try to create a patient - begin - @patient = get_resource(:Patient).new(meta: { tag: [{ system: 'http://projectcrucible.org', code: 'testdata'}] }, name: { family: 'Emerald', given: 'Caro' }) - @patient_created = true - rescue - @patient = nil - end + @patient = nil end end @@ -48,6 +50,8 @@ def teardown patient = get_resource(:Patient).read(@patient.id) assert_equal @patient.id, @client.reply.id, 'Server returned wrong patient.' + assert_resource_type @client.reply, get_resource(:Patient) + assert_equal get_resource(:Patient), patient.class, 'Read was not parsed with the selected FHIR version.' warning { assert_valid_resource_content_type_present(@client.reply) } warning { assert_etag_present(@client.reply) } warning { assert_last_modified_present(@client.reply) } @@ -103,8 +107,28 @@ def teardown @summary_patient = nil ignore_client_exception { @summary_patient = get_resource(:Patient).read_with_summary(@patient.id, "text") } assert(@summary_patient != nil, 'Patient resource type not returned.') - assert(@summary_patient.text, 'Requested summary narrative was not provided.', @client.reply.body) - end + assert_equal get_resource(:Patient), @summary_patient.class, 'Summary response was not parsed with the selected FHIR version.' + warning do + assert(@summary_patient.text, 'Server did not include a narrative for _summary=text; it may have returned full content instead.') + end + end + + test 'R006', 'Conditional read with ETag' do + metadata { + links "#{REST_SPEC_LINK}#read" + requires resource: "Patient", methods: ["create", "read", "delete"] + validates resource: "Patient", methods: ["read"] + } + skip 'Patient not created in setup.' if @patient.nil? + + version_id = @patient.meta.try(:versionId) || @client.reply.version + skip 'Server did not provide a Patient versionId.' if version_id.blank? + + response = @client.conditional_read_version(get_resource(:Patient), @patient.id, version_id) + assert([200, 304].include?(response.code), 'Conditional read must return full content or not-modified.') + assert_resource_type(response, get_resource(:Patient)) if response.code == 200 + assert_nil response.resource, 'A 304 response must not contain a resource.' if response.code == 304 + end end end diff --git a/lib/tests/suites/resource_test.rb b/lib/tests/suites/resource_test.rb index ef33ac98..736809db 100644 --- a/lib/tests/suites/resource_test.rb +++ b/lib/tests/suites/resource_test.rb @@ -50,7 +50,7 @@ def category def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] end # this allows results to have unique ids for resource based tests diff --git a/lib/tests/suites/search_test.rb b/lib/tests/suites/search_test.rb index 007397fb..075f00c0 100644 --- a/lib/tests/suites/search_test.rb +++ b/lib/tests/suites/search_test.rb @@ -2,6 +2,8 @@ module Crucible module Tests class SearchTest < BaseSuite + R5_SEARCH_RESULT_PARAMETERS = ['_summary'].freeze + attr_accessor :resource_class attr_accessor :conformance attr_accessor :searchParams @@ -38,7 +40,7 @@ def category def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] end # this allows results to have unique ids for resource based tests @@ -77,10 +79,33 @@ def setup metadata { define_metadata('search') } - searchParamNames = [] - searchParamNames = @searchParams.map { |item| item.name } if !@searchParams.nil? - searchParamsDiff = @resource_class::SEARCH_PARAMS-searchParamNames - assert (searchParamsDiff.size <= 0), "The server does not support the following params: #{searchParamsDiff.join(', ')}." + if fhir_version == :r5 + expected_params = r5_search_parameter_definitions + expected_by_name = expected_params.each_with_object({}) do |search_param, definitions| + definitions[search_param['code']] = search_param + end + advertised_params = @searchParams || [] + allowed_params = expected_by_name.keys + R5_SEARCH_RESULT_PARAMETERS + unknown_params = advertised_params.map(&:name) - allowed_params + + assert unknown_params.empty?, + "The server advertises search parameters not defined by R5: #{unknown_params.join(', ')}." + + advertised_params.each do |search_param| + next if R5_SEARCH_RESULT_PARAMETERS.include?(search_param.name) + + expected = expected_by_name.fetch(search_param.name) + assert_equal expected['type'], search_param.type, + "The server advertises #{search_param.name} as #{search_param.type}, " \ + "but R5 defines it as #{expected['type']}." + end + else + search_param_names = [] + search_param_names = @searchParams.map(&:name) unless @searchParams.nil? + search_params_diff = @resource_class::SEARCH_PARAMS - search_param_names + assert (search_params_diff.size <= 0), + "The server does not support the following params: #{search_params_diff.join(', ')}." + end end # @@ -220,6 +245,13 @@ def define_metadata(method) validates resource: resource_class.name.demodulize, methods: [method] end + def r5_search_parameter_definitions + resource_name = @resource_class.name.demodulize + FHIR::R5::Definitions.send(:search_params).select do |search_param| + (search_param.fetch('base', []) & [resource_name, 'Resource']).any? + end + end + end end end diff --git a/lib/tests/suites/search_test_robust.rb b/lib/tests/suites/search_test_robust.rb index 9ff5c862..7fed7b73 100644 --- a/lib/tests/suites/search_test_robust.rb +++ b/lib/tests/suites/search_test_robust.rb @@ -13,7 +13,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) @category = {id: 'core_functionality', title: 'Core Functionality'} - @supported_versions = [:stu3, :r4, :r4b] + @supported_versions = [:stu3, :r4, :r4b, :r5] end def setup diff --git a/lib/tests/suites/sprinkler_search_test.rb b/lib/tests/suites/sprinkler_search_test.rb index 28a8dce4..4c187abe 100644 --- a/lib/tests/suites/sprinkler_search_test.rb +++ b/lib/tests/suites/sprinkler_search_test.rb @@ -4,6 +4,9 @@ class SprinklerSearchTest < BaseSuite attr_accessor :use_post + INDEXING_RETRY_COUNT = 10 + INDEXING_RETRY_DELAY = 0.2 + def id 'Search001' end @@ -14,7 +17,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] @category = {id: 'core_functionality', title: 'Core Functionality'} end @@ -22,45 +25,34 @@ def setup # Create a patient with gender:missing @resources = Crucible::Generator::Resources.new(fhir_version) @patient = @resources.minimal_patient + @patient_family = "Sprinkler#{SecureRandom.urlsafe_base64(12)}" + @patient_given = "Search#{SecureRandom.urlsafe_base64(12)}" + @patient.name[0].family = fhir_version == :dstu2 ? [@patient_family] : @patient_family + @patient.name[0].given = [@patient_given] @patient.identifier = [get_resource(:Identifier).new] - @patient.identifier[0].value = SecureRandom.urlsafe_base64 + @patient_identifier = SecureRandom.urlsafe_base64 + @patient.identifier[0].value = @patient_identifier @patient.gender = nil result = @client.create(@patient) @patient_id = result.id + @patient.id = @patient_id + wait_for_index(get_resource(:Patient), @patient_id) - # Sleep to allow the server to index the new patient before we attempt to read/search for it. - # This only applies if the server uses an asynchronous indexing process. - sleep(0.2) - - # read all the patients - @read_entire_feed=true - @client.use_format_param = true - reply = @client.read_feed(get_resource(:Patient)) - @read_entire_feed=false if (!reply.nil? && reply.code!=200) - @total_count = 0 - @entries = [] - - mute_response_body 'The body of the Sprinkler Search setup responses are not stored for performance reasons.' do - while reply != nil && !reply.resource.nil? - @total_count += reply.resource.entry.size - @entries += reply.resource.entry - reply = @client.next_page(reply) - @read_entire_feed=false if (!reply.nil? && reply.code!=200) - end - end - - # create a condition matching the first patient + # Create a condition matching the uniquely identified setup patient. @condition = ResourceGenerator.generate(get_resource(:Condition),3) if fhir_version == :dstu2 - @condition.patient = @entries.first.try(:resource).try(:to_reference) + @condition.patient = @patient.to_reference else - @condition.subject = @entries.first.try(:resource).try(:to_reference) + @condition.subject = @patient.to_reference end reply = @client.create(@condition) @condition_id = reply.id + wait_for_index(get_resource(:Condition), @condition_id) - # create some observations + @observation_code = "sprinkler-#{SecureRandom.urlsafe_base64(12)}" + + # Create quantity observations with a unique code so their result sets are isolated. @obs_a = create_observation(2.0) @obs_b = create_observation(1.96) @obs_c = create_observation(2.04) @@ -73,8 +65,8 @@ def create_observation(value) observation = get_resource(:Observation).new observation.status = 'preliminary' code = get_resource(:Coding).new - code.system = 'http://loinc.org' - code.code = '2164-2' + code.system = 'http://projectcrucible.org/sprinkler' + code.code = @observation_code observation.code = get_resource(:CodeableConcept).new observation.code.coding = [ code ] observation.valueQuantity = get_resource(:Quantity).new @@ -88,9 +80,50 @@ def create_observation(value) observation.bodySite.coding = [ body ] Crucible::Generator::Resources.new(fhir_version).tag_metadata(observation) reply = @client.create(observation) + wait_for_index(get_resource(:Observation), reply.id) reply.id end + def wait_for_index(resource_class, id) + INDEXING_RETRY_COUNT.times do + reply = @client.search(resource_class, search: { parameters: { '_id' => id } }) + return if reply.code == 200 && reply.resource&.entry&.any? { |entry| entry.resource&.id == id } + + sleep(INDEXING_RETRY_DELAY) + end + + raise "Timed out waiting for #{resource_class.name.demodulize}/#{id} to be indexed." + end + + def assert_exact_result_ids(reply, expected_ids) + assert_response_ok(reply) + assert_bundle_response(reply) + + actual_ids = reply.resource.entry.filter_map { |entry| entry.resource&.id }.sort + assert_equal expected_ids.sort, actual_ids, 'The search returned an unexpected set of resource ids.' + assert_equal expected_ids.length, reply.resource.total, 'The server did not report the expected number of results.' + end + + def assert_condition_search_result(reply) + assert_exact_result_ids(reply, [@condition_id]) + end + + def assert_exact_paginated_result_ids(reply, expected_ids) + actual_ids = [] + total = nil + + while reply + assert_response_ok(reply) + assert_bundle_response(reply) + total ||= reply.resource.total + actual_ids.concat(reply.resource.entry.filter_map { |entry| entry.resource&.id }) + reply = @client.next_page(reply) + end + + assert_equal expected_ids.sort, actual_ids.sort, 'The search returned an unexpected set of resource ids.' + assert_equal expected_ids.length, total, 'The server did not report the expected number of results.' + end + def teardown @client.use_format_param = false @client.destroy(get_resource(:Patient), @patient_id) if @patient_id @@ -153,33 +186,7 @@ def teardown validates resource: "Patient", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - search_string = '' - if fhir_version == :dstu2 - search_string = @patient.name[0].family.first[0..2] - else - search_string = @patient.name[0].family[0..2] - end - search_regex = Regexp.new(search_string, Regexp::IGNORECASE) - # how many patients in the bundle have matching names? - expected = 0 - @entries.each do |entry| - patient = entry.resource - isMatch = false - if !patient.nil? && !patient.name.nil? - patient.name.each do |name| - if !name.family.nil? - familyName = name.family - familyName = familyName[0] if familyName.kind_of?(Array) - unless (familyName =~ search_regex).nil? - isMatch = true - end - end - end - end - expected += 1 if isMatch - end + search_string = @patient_family[0..2] options = { :search => { @@ -191,9 +198,7 @@ def teardown } } reply = @client.search(get_resource(:Patient), options) - assert_response_ok(reply) - assert_bundle_response(reply) - assert_equal expected, reply.resource.total, 'The server did not report the expected number of results.' + assert_exact_result_ids(reply, [@patient_id]) end test "SE04#{action[0]}", 'Search patient resource on given name' do @@ -204,28 +209,7 @@ def teardown validates resource: "Patient", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - search_string = @patient.name[0].given[0] - search_regex = Regexp.new(search_string, Regexp::IGNORECASE) - # how many patients in the bundle have matching names? - expected = 0 - @entries.each do |entry| - patient = entry.resource - isMatch = false - if !patient.nil? && !patient.name.nil? - patient.name.each do |name| - if !name.given.nil? - name.given.each do |given| - if !(given =~ search_regex).nil? - isMatch = true - end - end - end - end - end - expected += 1 if isMatch - end + search_string = @patient_given options = { :search => { @@ -237,9 +221,7 @@ def teardown } } reply = @client.search(get_resource(:Patient), options) - assert_response_ok(reply) - assert_bundle_response(reply) - assert_equal expected, reply.resource.total, 'The server did not report the expected number of results.' + assert_exact_result_ids(reply, [@patient_id]) end test "SE05.0#{action[0]}", 'Search condition by patient reference url (partial)' do @@ -250,29 +232,17 @@ def teardown validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - # pick some search parameters... we previously created - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, :compartment => nil, :parameters => { - 'patient' => @entries.first.resource.to_reference.reference + 'patient' => @patient.to_reference.reference } } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.0F#{action[0]}", 'Search condition by patient reference url (full)' do @@ -283,19 +253,15 @@ def teardown validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - # pick some search parameters... we previously created options = { - :id => @entries[0].resource.id, - :resource => @entries[0].resource.class + :id => @patient.id, + :resource => @patient.class } temp = @client.use_format_param @client.use_format_param = false patient_url = @client.full_resource_url(options) @client.use_format_param = temp - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -306,15 +272,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.1#{action[0]}", 'Search condition by patient reference id' do @@ -325,12 +283,8 @@ def teardown validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - # pick some search parameters... we previously created - patient_id = @entries[0].resource.id + patient_id = @patient.id - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -341,15 +295,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.2#{action[0]}", 'Search condition by patient:Patient reference url' do @@ -360,12 +306,9 @@ def teardown validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - # pick some search parameters... we previously created options = { - :id => @entries[0].resource.id, - :resource => @entries[0].resource.class + :id => @patient.id, + :resource => @patient.class } temp = @client.use_format_param @client.use_format_param = false @@ -373,7 +316,6 @@ def teardown patient_url = patient_url[1..-1] if patient_url[0]=='/' @client.use_format_param = temp - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -384,15 +326,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.3#{action[0]}", 'Search condition by patient:Patient reference id' do @@ -403,13 +337,8 @@ def teardown validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - # pick some search parameters... we previously created - patient = @entries[0].resource - patient_id = @entries[0].resource.id + patient_id = @patient.id - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -420,15 +349,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.4#{action[0]}", 'Search condition by patient:_id reference' do @@ -438,11 +359,8 @@ def teardown links "#{BASE_SPEC_LINK}/condition.html#search" validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - # pick some search parameters... we previously created - patient_id = @entries[0].resource.id + patient_id = @patient.id - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -453,15 +371,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.5#{action[0]}", 'Search condition by patient.name reference' do @@ -471,11 +381,8 @@ def teardown links "#{BASE_SPEC_LINK}/condition.html#search" validates resource: "Condition", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - # pick some search parameters... we previously created - patient_name = @patient.name[0].family + patient_name = @patient_family - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -486,15 +393,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE05.6#{action[0]}", 'Search condition by patient.identifier reference' do @@ -505,10 +404,8 @@ def teardown validates resource: "Condition", methods: ["search"] } assert @patient_id, 'Could not create a patient in setup.' - # pick some search parameters... we previously created - patient_identifier = @patient.identifier[0].value + patient_identifier = @patient_identifier - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -519,15 +416,7 @@ def teardown } } reply = @client.search(get_resource(:Condition), options) - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - if fhir_version == :dstu2 - assert((e.resource.patient.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - else - assert((e.resource.subject.reference == @entries.first.resource.to_reference.reference),"The search returned a Condition that doesn't match the Patient.") - end - end + assert_condition_search_result(reply) end test "SE06#{action[0]}", 'Search condition and _include' do @@ -539,7 +428,6 @@ def teardown } assert @condition_id, 'Could not create Condition in setup.' - # next, we're going execute a series of searches for conditions referencing the patient options = { :search => { :flag => flag, @@ -553,12 +441,10 @@ def teardown reply = @client.search(get_resource(:Condition), options) assert_response_ok(reply) assert_bundle_response(reply) - assert reply.resource.total > 0, 'The server should have Conditions that _include=Condition:patient.' - has_patient = false - reply.resource.entry.each do |entry| - has_patient = true if (entry.resource && entry.resource.class == get_resource(:Patient)) - end - assert(has_patient,'The server did not include the Patient referenced in the Condition.', reply.body) + assert_equal 1, reply.resource.total, 'The server did not report the expected number of primary results.' + assert_equal [@condition_id, @patient_id].sort, + reply.resource.entry.filter_map { |entry| entry.resource&.id }.sort, + 'The server did not return the expected primary and included resources.' end test "SE07#{action[0]}", 'Search patient and _revinclude' do @@ -610,26 +496,13 @@ def teardown :flag => flag, :compartment => nil, :parameters => { - 'value-quantity' => '2.0||mmol' + 'code' => "http://projectcrucible.org/sprinkler|#{@observation_code}", + 'value-quantity' => '2.0|http://unitsofmeasure.org|mmol' } } } reply = @client.search(get_resource(:Observation), options) - has_obs_a = has_obs_b = has_obs_c = has_obs_d = false - while reply != nil - assert_response_ok(reply) - assert_bundle_response(reply) - has_obs_a = true if reply.resource.get_by_id(@obs_a) - has_obs_b = true if reply.resource.get_by_id(@obs_b) - has_obs_c = true if reply.resource.get_by_id(@obs_c) - has_obs_d = true if reply.resource.get_by_id(@obs_d) - reply = @client.next_page(reply) - end - - assert has_obs_a, 'Search on quantity value 2.0 should return 2.0' - assert has_obs_b, 'Search on quantity value 2.0 should return 1.96' - assert has_obs_c, 'Search on quantity value 2.0 should return 2.04' - assert !has_obs_d, 'Search on quantity value 2.0 should not return 1.80' + assert_exact_paginated_result_ids(reply, [@obs_a, @obs_b, @obs_c]) end test "SE22#{action[0]}", 'Search for quantity (in observation) - operators' do @@ -647,27 +520,13 @@ def teardown :flag => flag, :compartment => nil, :parameters => { - 'value-quantity' => 'gt5||mmol' + 'code' => "http://projectcrucible.org/sprinkler|#{@observation_code}", + 'value-quantity' => 'gt5|http://unitsofmeasure.org|mmol' } } } reply = @client.search(get_resource(:Observation), options) - has_obs_e = has_obs_f = false - while reply != nil - assert_response_ok(reply) - assert_bundle_response(reply) - reply.resource.entry.each do |e| - value = e.resource.value.try(:value) - assert(value, "Search did not return a value.") - assert((value > 5), "Search should not return values less than or equal to 5.") - end - has_obs_e = true if reply.resource.get_by_id(@obs_e) - has_obs_f = true if reply.resource.get_by_id(@obs_f) - reply = @client.next_page(reply) - end - - assert has_obs_e, 'Search greater than quantity should return greater value.' - assert has_obs_f, 'Search greater than quantity should return greater value.' + assert_exact_paginated_result_ids(reply, [@obs_e, @obs_f]) end test "SE23#{action[0]}", 'Search with quantifier :missing, on Patient.gender' do @@ -678,28 +537,18 @@ def teardown validates resource: "Patient", methods: ["search"] } - assert @read_entire_feed, 'Could not find a patient to search on in setup.' - - # how many patients in the bundle have no gender? - expected = 0 - @entries.each do |entry| - patient = entry.resource - expected += 1 if !patient.nil? && patient.gender.nil? - end - options = { :search => { :flag => flag, :compartment => nil, :parameters => { - 'gender:missing' => true + 'gender:missing' => true, + 'identifier' => @patient_identifier } } } reply = @client.search(get_resource(:Patient), options) - assert_response_ok(reply) - assert_bundle_response(reply) - assert_equal expected, reply.resource.total, 'The server did not report the expected number of results.' + assert_exact_result_ids(reply, [@patient_id]) end test "SE24#{action[0]}", 'Search with non-existing parameter' do diff --git a/lib/tests/suites/suite_engine.rb b/lib/tests/suites/suite_engine.rb index 72927c02..a054fefd 100644 --- a/lib/tests/suites/suite_engine.rb +++ b/lib/tests/suites/suite_engine.rb @@ -87,21 +87,23 @@ def self.generate_metadata(fhir_version) puts "---" puts "BUILDING METADATA" puts "---" - SuiteEngine.new.tests.each do |test| - test_file = Crucible::Tests.const_get(test).new(nil) + SuiteEngine.new.tests.each do |test_file| + next unless test_file.supported_versions.include?(version) + + test_name = test_file.class.name.demodulize if test_file.respond_to? 'resource_class=' Crucible::Tests::BaseSuite.fhir_resources(version).each do |klass| test_file.resource_class = klass puts "---" - puts "BUILDING METADATA - #{test}#{klass}" + puts "BUILDING METADATA - #{test_name}#{klass.name.demodulize}" puts "---" - metadata["#{test}#{klass}"] = test_file.collect_metadata(true) + metadata["#{test_name}#{klass.name.demodulize}"] = test_file.collect_metadata(true) end else puts "---" - puts "BUILDING METADATA - #{test}" + puts "BUILDING METADATA - #{test_name}" puts "---" - metadata[test] = test_file.collect_metadata(true) + metadata[test_name] = test_file.collect_metadata(true) end end puts "---" diff --git a/lib/tests/suites/transaction_test.rb b/lib/tests/suites/transaction_test.rb index 61c98886..aa6ca570 100644 --- a/lib/tests/suites/transaction_test.rb +++ b/lib/tests/suites/transaction_test.rb @@ -12,7 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4, :r4b] + @supported_versions = [:dstu2, :stu3, :r4, :r4b, :r5] @category = {id: 'core_functionality', title: 'Core Functionality'} end @@ -92,7 +92,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) # set the IDs to whatever the server created @@ -150,7 +150,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) # set the IDs to whatever the server created @@ -198,7 +198,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) # set the IDs to whatever the server created @@ -279,7 +279,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) count = (reply.resource.entry.first.resource.total rescue 0) @@ -324,7 +324,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) # get the new IDs @@ -388,7 +388,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) end @@ -421,7 +421,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_transaction_bundle_response(reply) assert_bundle_transactions_okay(reply) end @@ -453,7 +453,7 @@ def teardown @client.add_batch_request('POST',nil,@batch_obs).fullUrl = "urn:uuid:#{SecureRandom.uuid}" reply = @client.end_batch - assert_bundle_response(reply) + assert_batch_bundle_response(reply) assert_equal(2, reply.resource.entry.length, "Expected 2 Bundle entries but found #{reply.resource.entry.length}.", reply.body) patientCode = reply.resource.entry[0].try(:response).try(:status).try(:split).try(:first).try(:to_i) @@ -503,7 +503,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_batch_bundle_response(reply) # set the IDs to whatever the server created @batch_obs_2.id = FHIR::ResourceAddress.pull_out_id('Observation',reply.resource.entry[0].try(:response).try(:location)) @@ -544,7 +544,7 @@ def teardown assert( ((200..299).include?(reply.code)), "Unexpected status code: #{reply.code}" ) warning{ assert_response_ok(reply) } - assert_bundle_response(reply) + assert_batch_bundle_response(reply) assert_bundle_transactions_okay(reply) end @@ -578,7 +578,17 @@ def teardown reply = @client.end_transaction assert( ((200..299).include?(reply.code)), "Transaction with matching IfMatch should succeed, got: #{reply.code}" ) + assert_transaction_bundle_response(reply) + end + + def assert_transaction_bundle_response(reply) + assert_bundle_response(reply) + assert_equal 'transaction-response', reply.resource.type + end + + def assert_batch_bundle_response(reply) assert_bundle_response(reply) + assert_equal 'batch-response', reply.resource.type end # Transaction PUT with non-matching IfMatch should fail diff --git a/test/fixtures/fhir_structure_generator/r4b-definitions.zip b/test/fixtures/fhir_structure_generator/r4b-definitions.zip new file mode 100644 index 00000000..b57b5688 Binary files /dev/null and b/test/fixtures/fhir_structure_generator/r4b-definitions.zip differ diff --git a/test/fixtures/fhir_structure_generator/r5-definitions.zip b/test/fixtures/fhir_structure_generator/r5-definitions.zip new file mode 100644 index 00000000..6387214c Binary files /dev/null and b/test/fixtures/fhir_structure_generator/r5-definitions.zip differ diff --git a/test/unit/fhir_structure_generator_test.rb b/test/unit/fhir_structure_generator_test.rb index 06354b3c..538a14dc 100644 --- a/test/unit/fhir_structure_generator_test.rb +++ b/test/unit/fhir_structure_generator_test.rb @@ -1,33 +1,222 @@ require_relative '../test_helper' +require 'fileutils' +require 'tmpdir' class FHIRStructureGeneratorTest < Test::Unit::TestCase + ROOT = File.expand_path('../..', __dir__).freeze + FIXTURE_ROOT = File.join(ROOT, 'test', 'fixtures', 'fhir_structure_generator').freeze + R4B_FIXTURE = File.join(FIXTURE_ROOT, 'r4b-definitions.zip').freeze + R5_FIXTURE = File.join(FIXTURE_ROOT, 'r5-definitions.zip').freeze + R4B_FIXTURE_SHA256 = '06e07c0fca9d1b566868ce0deda6c883d40d3bc49f69e59bc6f44bb2edc54c58'.freeze + R5_FIXTURE_SHA256 = 'eabbde6f4c1e83c54ed7f958a2b7a48acfe21d7aa1f79d66262006bd8637dbc4'.freeze + + def setup + @tmpdir = Dir.mktmpdir('fhir-structure-generator') + @template_path = File.join(@tmpdir, 'template.json') + File.write(@template_path, JSON.pretty_generate(template)) + end + + def teardown + FileUtils.remove_entry(@tmpdir) + end + + def test_r4b_configuration_is_complete_and_immutable + configuration = Crucible::FHIRStructureGenerator::CONFIGURATIONS.fetch(:r4b) + + assert_equal :r4b, configuration.version + assert_equal 'R4B', configuration.label + assert_equal 'https://www.hl7.org/fhir/R4B/definitions.json.zip', configuration.source_url + assert_equal 'a2793a06853c2d4540db8a72fc1c6d972528b01d113c2bb70ae2d80dc062e963', + configuration.sha256 + assert_equal 'definitions.json/profiles-resources.json', configuration.archive_entry + assert_equal File.join(ROOT, 'lib', 'FHIR_structure_r4.json'), configuration.template_path + assert_equal File.join(ROOT, 'lib', 'FHIR_structure_r4b.json'), configuration.output_path + assert_predicate configuration, :frozen? + assert_predicate configuration.category_overrides, :frozen? + assert_predicate Crucible::FHIRStructureGenerator::CONFIGURATIONS, :frozen? + assert_raise(FrozenError) do + configuration.category_overrides['Citation'] = 'Specialized.Other' + end + end + + def test_r5_configuration_pins_the_official_root_archive_entry + configuration = Crucible::FHIRStructureGenerator::CONFIGURATIONS.fetch(:r5) + + assert_equal :r5, configuration.version + assert_equal 'R5', configuration.label + assert_equal 'https://hl7.org/fhir/R5/definitions.json.zip', configuration.source_url + assert_equal 'df0d7259b4a8741d59f4971d96dd486423ecbd414c7060e9dc006ae3c3209c0c', + configuration.sha256 + assert_equal 'profiles-resources.json', configuration.archive_entry + assert_empty configuration.category_overrides + assert_equal File.join(ROOT, 'lib', 'FHIR_structure_r4.json'), configuration.template_path + assert_equal File.join(ROOT, 'lib', 'FHIR_structure_r5.json'), configuration.output_path + end + + def test_reads_the_exact_nested_r4b_archive_entry + structure = Crucible::FHIRStructureGenerator.from_archive(r4b_fixture_configuration, R4B_FIXTURE) + + assert_equal ['citation', 'research definition'], generated_resource_names(structure) + end + + def test_reads_the_exact_zip_root_r5_archive_entry + structure = Crucible::FHIRStructureGenerator.from_archive(r5_fixture_configuration, R5_FIXTURE) + + assert_equal ['account'], generated_resource_names(structure) + end + + def test_rejects_an_incorrect_checksum_with_the_selected_version + configuration = r5_fixture_configuration(sha256: '0' * 64) + + error = assert_raise(RuntimeError) do + Crucible::FHIRStructureGenerator.from_archive(configuration, R5_FIXTURE) + end + + assert_match(/Unexpected R5 definitions checksum/, error.message) + assert_match(/expected #{'0' * 64}, got #{R5_FIXTURE_SHA256}/, error.message) + end + + def test_rejects_a_missing_exact_entry_with_the_selected_version + configuration = r4b_fixture_configuration(archive_entry: 'profiles/missing-resources.json') + + error = assert_raise(RuntimeError) do + Crucible::FHIRStructureGenerator.from_archive(configuration, R4B_FIXTURE) + end + + assert_match(/R4B archive entry profiles\/missing-resources.json/, error.message) + end + + def test_rejects_a_malformed_category_with_the_selected_version + malformed = definitions + malformed['entry'].first['resource']['extension'].first['valueString'] = 'Specialized' + + error = assert_raise(RuntimeError) do + Crucible::FHIRStructureGenerator.generate(r5_fixture_configuration, malformed, template) + end + + assert_match(/Invalid R5 resource category for Citation: Specialized/, error.message) + end + + def test_rejects_a_missing_resource_category_with_the_selected_version + uncategorized = definitions + uncategorized['entry'].first['resource']['extension'] = [] + + error = assert_raise(RuntimeError) do + Crucible::FHIRStructureGenerator.generate(r5_fixture_configuration, uncategorized, template) + end + + assert_match(/No R5 resource category for Citation/, error.message) + end + + def test_generation_is_deterministic + configuration = r4b_fixture_configuration + + Crucible::FHIRStructureGenerator.write_from_archive(configuration, R4B_FIXTURE) + first_generation = File.binread(configuration.output_path) + Crucible::FHIRStructureGenerator.write_from_archive(configuration, R4B_FIXTURE) + + assert_equal first_generation, File.binread(configuration.output_path) + end + + def test_official_r4b_archive_regenerates_the_checked_in_artifact + archive_path = ENV.fetch( + 'R4B_DEFINITIONS_ARCHIVE', + File.join(ROOT, 'r4b-definitions.json.zip') + ) + omit("Set R4B_DEFINITIONS_ARCHIVE to the pinned R4B definitions archive") unless File.exist?(archive_path) + + configuration = duplicate_configuration( + Crucible::FHIRStructureGenerator::CONFIGURATIONS.fetch(:r4b), + output_path: File.join(@tmpdir, 'FHIR_structure_r4b.json') + ) + Crucible::FHIRStructureGenerator.write_from_archive(configuration, archive_path) + + assert_equal File.binread(File.join(ROOT, 'lib', 'FHIR_structure_r4b.json')), + File.binread(configuration.output_path) + end + def test_generates_resource_categories_from_structure_definitions - structure = Crucible::FHIRStructureGenerator.generate(definitions, template) + structure = Crucible::FHIRStructureGenerator.generate( + r4b_fixture_configuration, + definitions, + template + ) resources = structure.fetch('children').find { |child| child['name'] == 'RESOURCES' } evidence = resources.fetch('children').first.fetch('children').first - assert_equal 'Evidence-Based Medicine', evidence['name'] + assert_equal 'Evidence-Based & Medicine', evidence['name'] assert_equal ['citation', 'research definition'], evidence.fetch('children').map { |child| child['name'] } end def test_does_not_modify_the_template original = JSON.generate(template) - Crucible::FHIRStructureGenerator.generate(definitions, template) + Crucible::FHIRStructureGenerator.generate(r4b_fixture_configuration, definitions, template) assert_equal original, JSON.generate(template) end - def test_rejects_uncategorized_resources_without_an_explicit_override - uncategorized = definitions - uncategorized['entry'].first['resource']['extension'] = [] + private - assert_raise(RuntimeError) do - Crucible::FHIRStructureGenerator.generate(uncategorized, template) - end + def r4b_fixture_configuration(**overrides) + configuration( + version: :r4b, + label: 'R4B', + sha256: R4B_FIXTURE_SHA256, + archive_entry: 'definitions.json/profiles-resources.json', + category_overrides: { + 'ResearchDefinition' => 'Specialized.Evidence-Based & Medicine' + }, + **overrides + ) end - private + def r5_fixture_configuration(**overrides) + configuration( + version: :r5, + label: 'R5', + sha256: R5_FIXTURE_SHA256, + archive_entry: 'profiles-resources.json', + category_overrides: {}, + **overrides + ) + end + + def configuration(version:, label:, sha256:, archive_entry:, category_overrides:, **overrides) + attributes = { + version: version, + label: label, + source_url: "https://example.test/fhir/#{label}/definitions.json.zip", + sha256: sha256, + archive_entry: archive_entry, + category_overrides: category_overrides, + template_path: @template_path, + output_path: File.join(@tmpdir, "FHIR_structure_#{version}.json") + }.merge(overrides) + Crucible::FHIRStructureGenerator::Configuration.new(**attributes) + end + + def duplicate_configuration(configuration, output_path:) + Crucible::FHIRStructureGenerator::Configuration.new( + version: configuration.version, + label: configuration.label, + source_url: configuration.source_url, + sha256: configuration.sha256, + archive_entry: configuration.archive_entry, + category_overrides: configuration.category_overrides, + template_path: configuration.template_path, + output_path: output_path + ) + end + + def generated_resource_names(structure) + resources = structure.fetch('children').find { |child| child['name'] == 'RESOURCES' } + resources.fetch('children').flat_map do |section| + section.fetch('children').flat_map do |category| + category.fetch('children').map { |resource| resource.fetch('name') } + end + end + end def template { @@ -39,7 +228,7 @@ def template { 'name' => 'Specialized', 'children' => [ - { 'name' => 'Evidence-Based Medicine', 'children' => [{ 'name' => 'old resource' }] } + { 'name' => 'Evidence-Based & Medicine', 'children' => [{ 'name' => 'old resource' }] } ] } ] @@ -60,7 +249,7 @@ def definitions 'extension' => [ { 'url' => Crucible::FHIRStructureGenerator::CATEGORY_URL, - 'valueString' => 'Specialized.Evidence-Based Medicine' + 'valueString' => 'Specialized.Evidence-Based & Medicine' } ] } diff --git a/test/unit/fhir_structure_test.rb b/test/unit/fhir_structure_test.rb index 4b0a86cc..809c58ae 100644 --- a/test/unit/fhir_structure_test.rb +++ b/test/unit/fhir_structure_test.rb @@ -1,6 +1,9 @@ require_relative '../test_helper' class FHIRStructureTest < Test::Unit::TestCase + COMMITTED_STRUCTURE_VERSIONS = [:dstu2, :stu3, :r4, :r4b, :r5].freeze + ABSTRACT_RESOURCES = %w[Resource DomainResource].freeze + R5_ABSTRACT_RESOURCES = %w[Resource DomainResource CanonicalResource MetadataResource].freeze def test_fhir_starburst_root structure = Crucible::FHIRStructure.get(:r4) @@ -12,6 +15,11 @@ def test_fhir_starburst_root_r4b assert_equal 'FHIR', structure['name'] end + def test_fhir_starburst_root_r5 + structure = Crucible::FHIRStructure.get(:r5) + assert_equal 'FHIR', structure['name'] + end + def test_fhir_starburst_stu3 structure = Crucible::FHIRStructure.get(:stu3) assert_equal 'FHIR', structure['name'] @@ -23,7 +31,7 @@ def test_fhir_starburst_root_dstu2 end def test_no_duplicate_names_in_starburst - Crucible::FHIRVersion::KNOWN.each do |version| + COMMITTED_STRUCTURE_VERSIONS.each do |version| structure = Crucible::FHIRStructure.get(version) names = all_names(structure) @@ -32,16 +40,18 @@ def test_no_duplicate_names_in_starburst end def fhir_resources(fhir_version) - Crucible::FHIRVersion.namespace(fhir_version).const_get(:RESOURCES) + resources = Crucible::FHIRVersion.namespace(fhir_version).const_get(:RESOURCES) + abstract_resources = fhir_version == :r5 ? R5_ABSTRACT_RESOURCES : ABSTRACT_RESOURCES + resources.reject { |resource| abstract_resources.include?(resource) } end def test_no_missing_resources_in_starburst - Crucible::FHIRVersion::KNOWN.each do |version| + COMMITTED_STRUCTURE_VERSIONS.each do |version| structure = Crucible::FHIRStructure.get(version) resource_subset = structure['children'].select{|c| c['name'] == 'RESOURCES'}.first structure_resources = all_names(resource_subset, true).map{|e| e.downcase.delete(' ')} - model_resources = fhir_resources(version).map(&:downcase).reject{|m| m == 'resource' || m == "domainresource"} + model_resources = fhir_resources(version).map(&:downcase) missing_resources = model_resources - structure_resources extra_resources = structure_resources - model_resources @@ -60,7 +70,7 @@ def test_no_unknown_requires_in_tests names = [] - Crucible::FHIRVersion::KNOWN.each do |version| + COMMITTED_STRUCTURE_VERSIONS.each do |version| structure = Crucible::FHIRStructure.get(version) names.concat(all_names(structure).map{|e| e.downcase.delete(' ')}) end diff --git a/test/unit/fhir_version_test.rb b/test/unit/fhir_version_test.rb index 661737be..0e32bcb1 100644 --- a/test/unit/fhir_version_test.rb +++ b/test/unit/fhir_version_test.rb @@ -2,16 +2,16 @@ class FHIRVersionTest < Test::Unit::TestCase def test_omitted_version_is_rejected - assert_raise(ArgumentError) do - Crucible::FHIRVersion.resolve - end - - [nil, '', ' '].each do |version| + [-> { Crucible::FHIRVersion.resolve }, + -> { Crucible::FHIRVersion.resolve(nil) }, + -> { Crucible::FHIRVersion.resolve('') }, + -> { Crucible::FHIRVersion.resolve(' ') }].each do |resolve| error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do - Crucible::FHIRVersion.resolve(version) + resolve.call end assert_match(/FHIR version is required/, error.message) + assert_match(/dstu2, stu3, r4, r4b, r5/, error.message) end end @@ -20,10 +20,12 @@ def test_known_versions_are_resolved_explicitly assert_equal :stu3, Crucible::FHIRVersion.resolve('STU3') assert_equal :r4, Crucible::FHIRVersion.resolve(:r4) assert_equal :r4b, Crucible::FHIRVersion.resolve('R4B') + assert_equal :r5, Crucible::FHIRVersion.resolve(:r5) + assert_equal :r5, Crucible::FHIRVersion.resolve('R5') end def test_known_versions_are_listed_in_one_registry - assert_equal [:dstu2, :stu3, :r4, :r4b], Crucible::FHIRVersion::KNOWN + assert_equal [:dstu2, :stu3, :r4, :r4b, :r5], Crucible::FHIRVersion::KNOWN end def test_known_versions_resolve_to_explicit_model_namespaces @@ -31,6 +33,7 @@ def test_known_versions_resolve_to_explicit_model_namespaces assert_same FHIR::STU3, Crucible::FHIRVersion.namespace(:stu3) assert_same FHIR, Crucible::FHIRVersion.namespace(:r4) assert_same FHIR::R4B, Crucible::FHIRVersion.namespace(:r4b) + assert_same FHIR::R5, Crucible::FHIRVersion.namespace(:r5) end def test_model_classes_resolve_to_their_owning_version @@ -39,15 +42,17 @@ def test_model_classes_resolve_to_their_owning_version assert_equal :r4, Crucible::FHIRVersion.for_class(FHIR::Patient) assert_equal :r4b, Crucible::FHIRVersion.for_class(FHIR::R4B::Patient) assert_equal :r4b, Crucible::FHIRVersion.for_class(FHIR::R4B::Patient.new) + assert_equal :r5, Crucible::FHIRVersion.for_class(FHIR::R5::Patient) + assert_equal :r5, Crucible::FHIRVersion.for_class(FHIR::R5::Patient.new) end def test_unknown_version_fails_instead_of_falling_back_to_r4 error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do - Crucible::FHIRVersion.resolve('r5') + Crucible::FHIRVersion.resolve('r6') end - assert_match(/Unsupported FHIR version 'r5'/, error.message) - assert_match(/dstu2, stu3, r4, r4b/, error.message) + assert_match(/Unsupported FHIR version 'r6'/, error.message) + assert_match(/dstu2, stu3, r4, r4b, r5/, error.message) end def test_unknown_fhir_4_version_fails_instead_of_falling_back_to_r4 diff --git a/test/unit/fixture_selection_test.rb b/test/unit/fixture_selection_test.rb index eefa2a90..79bfbb13 100644 --- a/test/unit/fixture_selection_test.rb +++ b/test/unit/fixture_selection_test.rb @@ -25,6 +25,108 @@ def test_falls_back_to_the_base_fixture end end + def test_selects_r5_json_override + with_fixture_helper('R5') do |resources, directory| + write_patient(directory, 'patient.json', 'base') + write_patient(directory, 'patient.r5.json', 'r5-json') + + patient = resources.load_fixture('patient', :json) + + assert_instance_of FHIR::R5::Patient, patient + assert_equal 'r5-json', patient.id + end + end + + def test_selects_r5_xml_override + with_fixture_helper(:r5) do |resources, directory| + write_patient_xml(directory, 'patient.xml', 'base') + write_patient_xml(directory, 'patient.r5.xml', 'r5-xml') + + patient = resources.load_fixture('patient', :xml) + + assert_instance_of FHIR::R5::Patient, patient + assert_equal 'r5-xml', patient.id + end + end + + def test_r5_base_fallback_is_parsed_through_r5 + with_fixture_helper(:r5) do |resources, directory| + write_patient(directory, 'patient.json', 'base') + + patient = resources.load_fixture('patient', :json) + + assert_instance_of FHIR::R5::Patient, patient + assert_equal 'base', patient.id + end + end + + def test_r4b_override_is_not_selected_for_r5 + with_fixture_helper(:r5) do |resources, directory| + write_patient(directory, 'patient.json', 'base') + write_patient(directory, 'patient.r4b.json', 'r4b') + + patient = resources.load_fixture('patient', :json) + + assert_instance_of FHIR::R5::Patient, patient + assert_equal 'base', patient.id + end + end + + def test_invalid_r5_json_fixture_reports_validation_errors + with_fixture_helper(:r5) do |resources, directory| + File.write( + File.join(directory, 'patient.r5.json'), + JSON.generate(resourceType: 'Patient', gender: 'invalid') + ) + + error = assert_raise(Crucible::Generator::Resources::InvalidFixtureError) do + resources.load_fixture('patient', :json) + end + + assert_match(/Invalid R5 JSON fixture/, error.message) + assert_match(/Patient\.gender: invalid codes/, error.message) + end + end + + def test_malformed_r5_json_fixture_reports_parsing_errors + with_fixture_helper(:r5) do |resources, directory| + File.write(File.join(directory, 'patient.r5.json'), '{') + + error = assert_raise(Crucible::Generator::Resources::InvalidFixtureError) do + resources.load_fixture('patient', :json) + end + + assert_match(/Invalid R5 JSON fixture/, error.message) + assert_match(/line 1 column 2/, error.message) + end + end + + def test_invalid_r5_xml_fixture_reports_schema_errors + with_fixture_helper(:r5) do |resources, directory| + File.write( + File.join(directory, 'patient.r5.xml'), + '' + ) + + error = assert_raise(Crucible::Generator::Resources::InvalidFixtureError) do + resources.load_fixture('patient', :xml) + end + + assert_match(/Invalid R5 XML fixture/, error.message) + assert_match(/birthDate/, error.message) + assert_match(/not-date/, error.message) + end + end + + def test_unknown_fixture_version_is_rejected + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + Crucible::Generator::Resources.new(:r6) + end + + assert_match(/Unsupported FHIR version 'r6'/, error.message) + assert_match(/dstu2, stu3, r4, r4b, r5/, error.message) + end + private def with_fixture_helper(version) @@ -41,4 +143,11 @@ def write_patient(directory, filename, id) JSON.generate(resourceType: 'Patient', id: id) ) end + + def write_patient_xml(directory, filename, id) + File.write( + File.join(directory, filename), + %() + ) + end end diff --git a/test/unit/fixtures_test.rb b/test/unit/fixtures_test.rb index 54eb7045..fa2a8e23 100644 --- a/test/unit/fixtures_test.rb +++ b/test/unit/fixtures_test.rb @@ -11,12 +11,20 @@ class FixturesTest < Test::Unit::TestCase json_fixtures = File.join('fixtures','**','*.json') raise 'No Fixture Files Found' if Dir[fixtures].empty? && Dir[json_fixtures].empty? + def self.fixture_version(file) + directory_version = file.match(/fixtures\/([^\/]+)/)[1].to_sym + filename = File.basename(file, File.extname(file)) + filename_version = filename.split('.').last.to_sym + + Crucible::FHIRVersion::KNOWN.include?(filename_version) ? filename_version : directory_version + end + # Define test methods to validate example JSON Dir.glob(fixtures).each do | file | basename = File.basename(file,'.xml') next if basename.start_with?('ccda') - version = file.match(/fixtures\/([^\/]+)/)[1] + version = fixture_version(file) xml = File.open(file, 'r:bom|UTF-8', &:read) define_method("test_fixture_validation_#{basename}_#{version}") do @@ -27,26 +35,32 @@ class FixturesTest < Test::Unit::TestCase basename = File.basename(file,'.json') json = File.open(file, 'r:bom|UTF-8', &:read) - version = file.match(/fixtures\/([^\/]+)/)[1] + version = fixture_version(file) define_method("test_json_fixture_validation_#{basename}_#{version}") do run_json_validate(basename, json, version.to_sym) end end def xml_namespace(fhir_version) - namespace = FHIR::Xml - if !fhir_version.nil? && FHIR.constants.include?(fhir_version.upcase) - namespace = FHIR.const_get(fhir_version.upcase)::Xml - end - namespace + Crucible::FHIRVersion.namespace(fhir_version).const_get(:Xml) end def json_namespace(fhir_version) - namespace = FHIR::Json - if !fhir_version.nil? && FHIR.constants.include?(fhir_version.upcase) - namespace = FHIR.const_get(fhir_version.upcase)::Json + Crucible::FHIRVersion.namespace(fhir_version).const_get(:Json) + end + + def test_r5_fixture_validation_uses_r5_format_namespaces + assert_same FHIR::R5::Xml, xml_namespace(:r5) + assert_same FHIR::R5::Json, json_namespace(:r5) + end + + def test_unknown_fixture_validation_version_is_rejected + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + json_namespace(:r6) end - namespace + + assert_match(/Unsupported FHIR version 'r6'/, error.message) + assert_match(/dstu2, stu3, r4, r4b, r5/, error.message) end def run_validate(fixture, xml, version) diff --git a/test/unit/format_suite_test.rb b/test/unit/format_suite_test.rb index 230e1e96..2112888a 100644 --- a/test/unit/format_suite_test.rb +++ b/test/unit/format_suite_test.rb @@ -7,7 +7,8 @@ class FormatSuiteTest < Test::Unit::TestCase FHIR_VERSIONS = { stu3: '3.0.2', r4: '4.0.1', - r4b: '4.3.0' + r4b: '4.3.0', + r5: '5.0.0' }.freeze FHIR_VERSIONS.each do |version, specification_version| @@ -22,6 +23,7 @@ def execute_format_suite(version, specification_version) @namespace = Crucible::FHIRVersion.namespace(version) @client = FHIR::Client.new(BASE_URL, fhir_version: version) @created_patient = nil + @create_request_headers = [] stub_capability_statement(specification_version) stub_create stub_reads @@ -30,10 +32,11 @@ def execute_format_suite(version, specification_version) suite = Crucible::Tests::FormatTest.new(@client) tests = suite.execute.fetch('Format001') - assert_equal 22, tests.length + assert_equal 26, tests.length assert_true tests.all? { |test| test['status'] == 'pass' }, failure_summary(tests) assert_instance_of @namespace.const_get(:Patient), @created_patient assert_include suite.supported_versions, version + assert_r5_format_behavior if version == :r5 end def stub_capability_statement(specification_version) @@ -54,6 +57,7 @@ def stub_capability_statement(specification_version) def stub_create stub_request(:post, "#{BASE_URL}/Patient").to_return do |request| + @create_request_headers << request.headers @created_patient = @namespace.from_contents(request.body) @created_patient.id = PATIENT_ID @created_patient.meta ||= @namespace.const_get(:Meta).new @@ -71,6 +75,33 @@ def stub_create end end + def assert_r5_format_behavior + assert_equal FHIR::Formats::ResourceFormat::RESOURCE_JSON, @create_request_headers.first['Accept'] + assert_equal "#{FHIR::Formats::ResourceFormat::RESOURCE_JSON};charset=utf-8", + @create_request_headers.first['Content-Type'] + + [ + FHIR::Formats::ResourceFormat::RESOURCE_JSON, + FHIR::Formats::ResourceFormat::RESOURCE_XML + ].each do |format| + patient = @namespace.const_get(:Patient).new(name: [{ family: 'Format' }]) + reply = @client.create(patient, {}, format) + + assert_equal 201, reply.code + assert_instance_of @namespace.const_get(:Patient), reply.resource + assert_equal "#{format};charset=utf-8", @create_request_headers.last['Content-Type'] + end + + json_reply = @client.read(@namespace.const_get(:Patient), PATIENT_ID, + FHIR::Formats::ResourceFormat::RESOURCE_JSON) + xml_reply = @client.read(@namespace.const_get(:Patient), PATIENT_ID, + FHIR::Formats::ResourceFormat::RESOURCE_XML) + + assert_instance_of FHIR::R5::Patient, json_reply.resource + assert_instance_of FHIR::R5::Patient, xml_reply.resource + assert_true json_reply.resource.equals?(xml_reply.resource, ['id']) + end + def stub_reads stub_request(:get, %r{\A#{Regexp.escape(BASE_URL)}/Patient(?:/#{PATIENT_ID})?(?:\?.*)?\z}).to_return do |request| requested_format = request_format(request) diff --git a/test/unit/observation_generation_test.rb b/test/unit/observation_generation_test.rb new file mode 100644 index 00000000..76d19060 --- /dev/null +++ b/test/unit/observation_generation_test.rb @@ -0,0 +1,170 @@ +require_relative '../test_helper' + +class ObservationGenerationTest < Test::Unit::TestCase + VERSIONS = { + r4: FHIR, + r4b: FHIR::R4B + }.freeze + SHARED_SIMPLE_QUANTITY_VERSIONS = VERSIONS.merge(r5: FHIR::R5).freeze + DEPTHS = [2, 3, 4].freeze + ITERATIONS = 10 + + def test_repeated_r4_and_r4b_observations_are_valid + VERSIONS.each do |version, namespace| + DEPTHS.each do |depth| + ITERATIONS.times do |iteration| + observation = Crucible::Tests::ResourceGenerator.generate(namespace::Observation, depth) + errors = observation.validate + + assert_empty errors, failure_context(version, depth, iteration, errors) + assert_prohibited_comparators_cleared(observation, version, depth, iteration) + end + end + end + end + + def test_observation_simple_quantities_have_no_comparator_across_namespaces + SHARED_SIMPLE_QUANTITY_VERSIONS.each do |version, namespace| + observation = observation_with_prohibited_comparators(namespace) + + Crucible::Tests::ResourceGenerator.apply_invariants!(observation) + + prohibited_quantities(observation).each do |path, quantity| + assert_nil quantity.comparator, "#{version} retained a comparator at #{path}" + end + end + end + + def test_r4_and_r4b_observation_quantities_that_allow_comparators_are_unchanged + VERSIONS.each do |version, namespace| + observation = observation_with_allowed_comparators(namespace) + expected = allowed_quantities(observation).map { |path, quantity| [path, quantity.comparator] } + + Crucible::Tests::ResourceGenerator.apply_invariants!(observation) + + actual = allowed_quantities(observation).map { |path, quantity| [path, quantity.comparator] } + assert_equal expected, actual, "#{version} cleared an allowed Quantity comparator" + end + end + + private + + def observation_with_prohibited_comparators(namespace) + observation = namespace::Observation.new + observation.valueRange = range(namespace) + observation.valueSampledData = sampled_data(namespace) + observation.referenceRange = [reference_range(namespace)] + observation.component = [namespace::Observation::Component.new( + valueRange: range(namespace), + valueSampledData: sampled_data(namespace), + referenceRange: [reference_range(namespace)] + )] + observation + end + + def observation_with_allowed_comparators(namespace) + observation = namespace::Observation.new + observation.valueQuantity = quantity(namespace, '<') + observation.valueRatio = ratio(namespace, '>', '<=') + observation.component = [namespace::Observation::Component.new( + valueQuantity: quantity(namespace, '>='), + valueRatio: ratio(namespace, '<=', '>') + )] + observation + end + + def reference_range(namespace) + namespace::Observation::ReferenceRange.new( + low: quantity(namespace), + high: quantity(namespace), + age: range(namespace) + ) + end + + def range(namespace) + namespace::Range.new( + low: quantity(namespace), + high: quantity(namespace) + ) + end + + def sampled_data(namespace) + namespace::SampledData.new(origin: quantity(namespace)) + end + + def ratio(namespace, numerator_comparator, denominator_comparator) + namespace::Ratio.new( + numerator: quantity(namespace, numerator_comparator), + denominator: quantity(namespace, denominator_comparator) + ) + end + + def quantity(namespace, comparator = '<') + namespace::Quantity.new(value: 1, comparator: comparator) + end + + def assert_prohibited_comparators_cleared(observation, version, depth, iteration) + prohibited_quantities(observation).each do |path, quantity| + assert_nil quantity.comparator, + "#{version} depth #{depth} iteration #{iteration} retained a comparator at #{path}" + end + end + + def prohibited_quantities(observation) + quantities = [] + add_range_quantities(quantities, 'valueRange', observation.valueRange) + add_sampled_data_quantity(quantities, 'valueSampledData', observation.valueSampledData) + add_reference_ranges(quantities, 'referenceRange', observation.referenceRange) + + observation.component.to_a.each_with_index do |component, index| + prefix = "component[#{index}]" + add_range_quantities(quantities, "#{prefix}.valueRange", component.valueRange) + add_sampled_data_quantity(quantities, "#{prefix}.valueSampledData", component.valueSampledData) + add_reference_ranges(quantities, "#{prefix}.referenceRange", component.referenceRange) + end + + quantities + end + + def allowed_quantities(observation) + quantities = [ + ['valueQuantity', observation.valueQuantity], + ['valueRatio.numerator', observation.valueRatio&.numerator], + ['valueRatio.denominator', observation.valueRatio&.denominator] + ] + observation.component.to_a.each_with_index do |component, index| + quantities.concat( + [ + ["component[#{index}].valueQuantity", component.valueQuantity], + ["component[#{index}].valueRatio.numerator", component.valueRatio&.numerator], + ["component[#{index}].valueRatio.denominator", component.valueRatio&.denominator] + ] + ) + end + quantities.reject { |_path, quantity| quantity.nil? } + end + + def add_reference_ranges(quantities, prefix, ranges) + ranges.to_a.each_with_index do |reference_range, index| + range_prefix = "#{prefix}[#{index}]" + quantities << ["#{range_prefix}.low", reference_range.low] if reference_range.low + quantities << ["#{range_prefix}.high", reference_range.high] if reference_range.high + add_range_quantities(quantities, "#{range_prefix}.age", reference_range.age) + end + end + + def add_range_quantities(quantities, prefix, range) + return unless range + + quantities << ["#{prefix}.low", range.low] if range.low + quantities << ["#{prefix}.high", range.high] if range.high + end + + def add_sampled_data_quantity(quantities, prefix, sampled_data) + quantities << ["#{prefix}.origin", sampled_data.origin] if sampled_data&.origin + end + + def failure_context(version, depth, iteration, errors) + "#{version} depth #{depth} iteration #{iteration}: #{JSON.generate(errors)}" + end +end diff --git a/test/unit/r4b_routing_test.rb b/test/unit/r4b_routing_test.rb index c288b409..8f390f68 100644 --- a/test/unit/r4b_routing_test.rb +++ b/test/unit/r4b_routing_test.rb @@ -64,6 +64,8 @@ def test_minimal_condition_uses_r4b_status_datatype condition = Crucible::Tests::ResourceGenerator.minimal_condition(namespace: FHIR::R4B) assert_instance_of FHIR::R4B::Condition, condition + assert_instance_of FHIR::R4B::CodeableConcept, condition.clinicalStatus + assert_equal 'active', condition.clinicalStatus.coding.first.code assert_instance_of FHIR::R4B::CodeableConcept, condition.verificationStatus assert_instance_of FHIR::R4B::Coding, condition.verificationStatus.coding.first assert_equal 'confirmed', condition.verificationStatus.coding.first.code diff --git a/test/unit/r5_fhirpath_patch_suite_test.rb b/test/unit/r5_fhirpath_patch_suite_test.rb new file mode 100644 index 00000000..a45279f7 --- /dev/null +++ b/test/unit/r5_fhirpath_patch_suite_test.rb @@ -0,0 +1,196 @@ +require_relative '../test_helper' +require 'webmock/test_unit' + +class R5FhirPathPatchSuiteTest < Test::Unit::TestCase + BASE_URL = 'http://fhirpath-patch-suite.test/fhir'.freeze + MEDICATION_REQUEST_ID = 'r5-medication-request'.freeze + + def setup + @client = FHIR::Client.new(BASE_URL, fhir_version: :r5) + @patch_requests = [] + @medication_request = r5_medication_request + stub_create + stub_read + stub_patch + stub_delete + end + + def test_r5_patch_suite_uses_r5_parameters_and_rejects_stale_versions + suite = Crucible::Tests::FhirPathPatchTest.new(@client) + results = suite.execute.fetch('FhirPathPatchTest') + + assert_equal 6, results.length + assert_equal 6, results.count { |result| result['status'] == 'pass' }, failure_summary(results) + assert_equal 0, results.count { |result| result['status'] == 'skip' }, failure_summary(results) + assert_empty suite.warnings + assert_include suite.supported_versions, :r5 + assert_equal [ + FHIR::Formats::ResourceFormat::RESOURCE_JSON, + FHIR::Formats::ResourceFormat::RESOURCE_XML + ], @patch_requests.map { |request| request[:content_type] } + + @patch_requests.each do |request| + assert_instance_of FHIR::R5::Parameters, request[:parameters] + assert_r5_model_graph(request[:parameters]) + operation = request[:parameters].parameter.first + assert_equal 'operation', operation.name + assert_equal %w[type path value], operation.part.map(&:name) + assert_equal 'replace', operation.part[0].valueCode + assert_equal 'MedicationRequest.status', operation.part[1].valueString + assert_equal 'completed', operation.part[2].valueString + end + end + + def test_r5_choice_patch_uses_unsuffixed_fhirpath_name_with_a_typed_value + suite = Crucible::Tests::FhirPathPatchTest.new(@client) + patchset = suite.patchset_resource('add', 'Observation', 'value', 'patched value') + operation = patchset.parameter.first + + assert_instance_of FHIR::R5::Parameters, patchset + assert_equal %w[type path value name], operation.part.map(&:name) + assert_equal 'Observation', operation.part[1].valueString + assert_equal 'patched value', operation.part[2].valueString + assert_equal 'value', operation.part[3].valueString + assert_round_trips_in_r5_json_and_xml(patchset) + end + + def test_stale_version_patch_returns_an_r5_operation_outcome_without_updating_the_resource + patchset = Crucible::Tests::FhirPathPatchTest.new(@client).patchset_resource( + 'replace', 'MedicationRequest.status', nil, 'active' + ) + stale_response = FHIR::R5::OperationOutcome.new( + issue: [{ severity: 'error', code: 'conflict' }] + ) + stub_request(:patch, "#{BASE_URL}/MedicationRequest/#{MEDICATION_REQUEST_ID}").with( + headers: { 'If-Match' => 'W/"1"' } + ).to_return( + status: 412, + body: stale_response.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + ) + + reply = @client.fhir_patch( + FHIR::R5::MedicationRequest, + MEDICATION_REQUEST_ID, + patchset, + {}, + FHIR::Formats::ResourceFormat::RESOURCE_JSON, + 'If-Match' => 'W/"1"' + ) + + assert_equal 412, reply.code + assert_instance_of FHIR::R5::OperationOutcome, reply.resource + assert_equal 'active', @medication_request.status + end + + private + + def r5_medication_request + Crucible::Generator::Resources.new(:r5).medicationorder_simple + end + + def stub_create + stub_request(:post, "#{BASE_URL}/MedicationRequest").to_return do |request| + created = FHIR::R5.from_contents(request.body) + assert_instance_of FHIR::R5::MedicationRequest, created + assert_r5_model_graph(created) + @medication_request = created + @medication_request.id = MEDICATION_REQUEST_ID + @medication_request.meta = FHIR::R5::Meta.new( + versionId: '1', + lastUpdated: '2026-07-29T12:00:00Z' + ) + + response(@medication_request, request).tap do |reply| + reply[:headers]['Location'] = "#{BASE_URL}/MedicationRequest/#{MEDICATION_REQUEST_ID}/_history/1" + end + end + end + + def stub_read + stub_request(:get, "#{BASE_URL}/MedicationRequest/#{MEDICATION_REQUEST_ID}").to_return do |request| + response(@medication_request, request) + end + end + + def stub_patch + stub_request(:patch, "#{BASE_URL}/MedicationRequest/#{MEDICATION_REQUEST_ID}").to_return do |request| + if request.headers['If-Match'] + next stale_patch_response + end + + parameters = parse_parameters(request) + @patch_requests << { content_type: request.headers['Content-Type'].split(';').first, parameters: parameters } + @medication_request.status = parameters.parameter.first.part[2].valueString + @medication_request.meta.versionId = (@medication_request.meta.versionId.to_i + 1).to_s + @medication_request.meta.lastUpdated = '2026-07-29T12:01:00Z' + + response(@medication_request, request) + end + end + + def stub_delete + stub_request(:delete, "#{BASE_URL}/MedicationRequest/#{MEDICATION_REQUEST_ID}").to_return(status: 204) + end + + def parse_parameters(request) + if request.headers['Content-Type'].include?('xml') + FHIR::R5::Xml.from_xml(request.body) + else + FHIR::R5::Json.from_json(request.body) + end + end + + def response(resource, request) + xml = request.headers['Accept'].include?('xml') + { + status: 200, + body: xml ? resource.to_xml : resource.to_json, + headers: { + 'Content-Type' => xml ? FHIR::Formats::ResourceFormat::RESOURCE_XML : + FHIR::Formats::ResourceFormat::RESOURCE_JSON + } + } + end + + def stale_patch_response + outcome = FHIR::R5::OperationOutcome.new(issue: [{ severity: 'error', code: 'conflict' }]) + { + status: 409, + body: outcome.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + } + end + + def assert_round_trips_in_r5_json_and_xml(resource) + json = FHIR::R5::Json.from_json(resource.to_json) + xml = FHIR::R5::Xml.from_xml(resource.to_xml) + + assert_instance_of FHIR::R5::Parameters, json + assert_instance_of FHIR::R5::Parameters, xml + assert_r5_model_graph(json) + assert_r5_model_graph(xml) + end + + def assert_r5_model_graph(value, seen = {}) + return if value.nil? || seen[value.object_id] + + seen[value.object_id] = true + if value.is_a?(FHIR::Model) + assert_match(/\AFHIR::R5(?:::\w+)*\z/, value.class.name) + value.instance_variables.each do |variable| + assert_r5_model_graph(value.instance_variable_get(variable), seen) + end + elsif value.is_a?(Array) + value.each { |entry| assert_r5_model_graph(entry, seen) } + elsif value.is_a?(Hash) + value.each_value { |entry| assert_r5_model_graph(entry, seen) } + end + end + + def failure_summary(results) + results.reject { |result| %w[pass skip].include?(result['status']) }.map do |result| + "#{result[:test_method]}: #{result['status']} #{result['message']}" + end.join("\n") + end +end diff --git a/test/unit/r5_incendi_search_regressions_test.rb b/test/unit/r5_incendi_search_regressions_test.rb new file mode 100644 index 00000000..eb03b9ce --- /dev/null +++ b/test/unit/r5_incendi_search_regressions_test.rb @@ -0,0 +1,140 @@ +require_relative '../test_helper' + +class R5IncendiSearchRegressionsTest < Test::Unit::TestCase + SUBSETTED_SYSTEM = 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue'.freeze + + def setup + @client = FHIR::Client.new('http://r5-incendi-search-regressions.test/fhir', fhir_version: :r5) + end + + def test_consent_uses_r5_subject_and_patient_search_parameter + suite = Crucible::Tests::ConsentSearchByPatientReferenceTest.new(@client) + consent = Crucible::Tests::ResourceGenerator.generate(FHIR::R5::Consent) + consent.subject = FHIR::R5::Reference.new(reference: 'Patient/r5-consent-patient') + definition = find_search_parameter('Consent', 'patient') + + assert_include suite.supported_versions, :r5 + assert_instance_of FHIR::R5::Consent, consent + assert_instance_of FHIR::R5::Reference, consent.subject + assert_equal 'reference', definition['type'] + assert_include definition['target'], 'Patient' + assert_match(/Consent\.subject/, definition['expression']) + end + + def test_consent_setup_builds_the_r5_subject_reference_from_the_create_response_id + client = SetupRecorder.new + suite = Crucible::Tests::ConsentSearchByPatientReferenceTest.new(client) + + suite.setup + + consent = client.created.last + assert_instance_of FHIR::R5::Consent, consent + assert_instance_of FHIR::R5::Reference, consent.subject + assert_equal 'Patient/patient-from-create-response', consent.subject.reference + end + + def test_elements_search_uses_exact_r5_request_and_subsetted_response_contract + suite = Crucible::Tests::ElementsSearchParameterTest.new(@client) + suite.instance_variable_set(:@patient_id, 'r5-elements-patient') + parameters = suite.elements_search_options.fetch(:search).fetch(:parameters) + patient = FHIR::R5::Patient.new( + id: 'r5-elements-patient', + meta: { tag: [{ system: SUBSETTED_SYSTEM, code: 'SUBSETTED' }] }, + name: [{ family: 'Elements' }], + birthDate: '1974-12-25' + ) + + assert_include suite.supported_versions, :r5 + assert_equal({ '_id' => 'r5-elements-patient', '_elements' => 'name,birthDate' }, parameters) + assert_instance_of FHIR::R5::Patient, patient + assert_equal 'SUBSETTED', patient.meta.tag.first.code + assert_nil patient.gender + end + + def test_unknown_parameter_remains_unknown_in_r5_and_uses_r5_outcome_entries + suite = Crucible::Tests::UnknownSearchParameterTest.new(@client) + definitions = FHIR::R5::Definitions.send(:search_params) + response = FHIR::R5::Bundle.new( + type: 'searchset', + entry: [{ + resource: FHIR::R5::OperationOutcome.new(issue: [{ severity: 'warning', code: 'invalid' }]), + search: { mode: 'outcome' } + }] + ) + + assert_include suite.supported_versions, :r5 + assert_nil definitions.find { |definition| definition['code'] == 'basedOn' && definition.fetch('base', []).include?('QuestionnaireResponse') } + assert_not_nil definitions.find { |definition| definition['code'] == 'based-on' && definition.fetch('base', []).include?('QuestionnaireResponse') } + assert_instance_of FHIR::R5::Bundle, response + assert_instance_of FHIR::R5::OperationOutcome, response.entry.first.resource + assert_equal 'outcome', response.entry.first.search.mode + assert_equal 'warning', response.entry.first.resource.issue.first.severity + end + + def test_incendi_teardowns_target_r5_resource_classes + client = DestroyRecorder.new + consent_suite = Crucible::Tests::ConsentSearchByPatientReferenceTest.new(client) + elements_suite = Crucible::Tests::ElementsSearchParameterTest.new(client) + unknown_suite = Crucible::Tests::UnknownSearchParameterTest.new(client) + consent_suite.instance_variable_set(:@patient_id, 'patient') + consent_suite.instance_variable_set(:@consent_id, 'consent') + elements_suite.instance_variable_set(:@patient_id, 'elements') + unknown_suite.instance_variable_set(:@questionnaire_response_id, 'questionnaire-response') + + consent_suite.teardown + elements_suite.teardown + unknown_suite.teardown + + assert_equal [ + [FHIR::R5::Patient, 'patient'], + [FHIR::R5::Consent, 'consent'], + [FHIR::R5::Patient, 'elements'], + [FHIR::R5::QuestionnaireResponse, 'questionnaire-response'] + ], client.destroyed + end + + private + + def find_search_parameter(resource, code) + FHIR::R5::Definitions.send(:search_params).find do |definition| + definition['code'] == code && definition.fetch('base', []).include?(resource) + end.tap { |definition| assert_not_nil definition, "Expected R5 #{resource} search parameter #{code}." } + end + + class DestroyRecorder + attr_reader :destroyed + + def initialize + @destroyed = [] + end + + def fhir_version + :r5 + end + + def monitor_requests + end + + def destroy(resource_class, id) + @destroyed << [resource_class, id] + end + end + + class SetupRecorder < DestroyRecorder + Reply = Struct.new(:code, :id, :body) + + attr_reader :created + + def initialize + super + @created = [] + end + + def create(resource) + @created << resource + id = @created.length == 1 ? 'patient-from-create-response' : 'consent-from-create-response' + resource.id = id + Reply.new(201, id, '') + end + end +end diff --git a/test/unit/r5_primitive_and_choice_generation_test.rb b/test/unit/r5_primitive_and_choice_generation_test.rb new file mode 100644 index 00000000..3edf1d6c --- /dev/null +++ b/test/unit/r5_primitive_and_choice_generation_test.rb @@ -0,0 +1,153 @@ +require_relative '../test_helper' + +class R5PrimitiveAndChoiceGenerationTest < Test::Unit::TestCase + INTEGER64_VALUES = [ + -9_223_372_036_854_775_808, + -1, + 0, + 1, + 9_223_372_036_854_775_807 + ].freeze + + def test_integer64_boundaries_are_generated_as_integers + generator = Crucible::Tests::ResourceGenerator + offsets = INTEGER64_VALUES.map do |value| + value - generator::INTEGER64_MIN + end + + generated = offsets.map do |offset| + random = Struct.new(:offset) do + def random_number(limit) + raise "offset #{offset} is outside 0...#{limit}" unless + offset >= 0 && offset < limit + + offset + end + end.new(offset) + generator.random_integer64(random: random) + end + + assert_equal INTEGER64_VALUES, generated + assert_true generated.all? { |value| value.is_a?(Integer) } + assert_true generated.all? do |value| + FHIR::R5.primitive?(datatype: 'integer64', value: value) + end + end + + def test_integer64_json_and_xml_round_trips_preserve_each_boundary + INTEGER64_VALUES.each do |value| + resource = integer64_parameters(value) + + assert_empty resource.validate + + json = resource.to_json + json_value = JSON.parse(json).dig('parameter', 0, 'valueInteger64') + assert_instance_of String, json_value + assert_equal value.to_s, json_value + assert_match(/"valueInteger64"\s*:\s*"#{value}"(?:\s*[,}])/, json) + + xml_value = FHIR::R5.from_contents(resource.to_xml) + .parameter + .first + .valueInteger64 + assert_instance_of Integer, xml_value + assert_equal value, xml_value + end + end + + def test_r5_choice_candidates_follow_the_owning_element_metadata + generator = Crucible::Tests::ResourceGenerator + r4b_content = generator.selectable_multiple_type_fields( + FHIR::R4B::Communication::Payload, + 'FHIR::R4B' + ).fetch('content') + r5_content = generator.selectable_multiple_type_fields( + FHIR::R5::Communication::Payload, + 'FHIR::R5' + ).fetch('content') + r5_input = generator.selectable_multiple_type_fields( + FHIR::R5::Task::Input, + 'FHIR::R5' + ).fetch('value') + + assert_equal %w[Attachment Reference string], r4b_content.keys.sort + assert_equal %w[Attachment CodeableConcept Reference], r5_content.keys.sort + assert_not_include r5_content.keys, 'string' + assert_include r5_input.keys, 'integer64' + assert_equal 'valueInteger64', r5_input.fetch('integer64') + end + + def test_generated_r5_choices_populate_exactly_one_allowed_property + assertions = [ + [ + FHIR::R5::Communication::Payload, + 'content', + 'CodeableConcept' + ], + [ + FHIR::R5::Task::Input, + 'value', + 'integer64' + ] + ] + + assertions.each do |klass, prefix, selected_type| + resource = klass.new + selector = lambda do |types| + types.include?(selected_type) ? selected_type : types.first + end + Crucible::Tests::ResourceGenerator.set_fields!( + resource, + 'FHIR::R5', + 2, + choice_selector: selector + ) + fields = Crucible::Tests::ResourceGenerator + .multiple_type_fields(klass) + .fetch(prefix) + .values + populated = fields.select { |field| !resource.public_send(field).nil? } + + assert_equal ["#{prefix}#{selected_type[0].upcase}#{selected_type[1..]}"], + populated + assert_empty resource.validate + end + end + + def test_quantity_choice_exclusion_remains_dstu2_only + generator = Crucible::Tests::ResourceGenerator + versions = { + 'FHIR' => FHIR::Observation, + 'FHIR::R4B' => FHIR::R4B::Observation, + 'FHIR::R5' => FHIR::R5::Observation, + 'FHIR::STU3' => FHIR::STU3::Observation + } + + versions.each do |namespace, klass| + choices = generator.selectable_multiple_type_fields( + klass, + namespace + ).fetch('value') + assert_include choices.keys, 'Quantity', namespace + end + + dstu2_choices = generator.selectable_multiple_type_fields( + FHIR::DSTU2::Observation, + 'FHIR::DSTU2' + ).fetch('value') + assert_not_include dstu2_choices.keys, 'Quantity' + end + + private + + def integer64_parameters(value) + FHIR::R5::Parameters.new( + parameter: [ + FHIR::R5::Parameters::Parameter.new( + name: 'integer64', + valueInteger64: value + ) + ] + ) + end +end diff --git a/test/unit/r5_read_history_suite_test.rb b/test/unit/r5_read_history_suite_test.rb new file mode 100644 index 00000000..0e19f4c8 --- /dev/null +++ b/test/unit/r5_read_history_suite_test.rb @@ -0,0 +1,68 @@ +require_relative '../test_helper' + +class R5ReadHistorySuiteTest < Test::Unit::TestCase + Reply = Struct.new(:code, :resource, :body, keyword_init: true) + + def setup + @client = FHIR::Client.new('http://r5.example', fhir_version: :r5) + @read_suite = Crucible::Tests::ReadTest.new(@client) + @history_suite = Crucible::Tests::HistoryTest.new(@client) + end + + def test_read_and_history_suites_explicitly_advertise_r5 + expected = [:dstu2, :stu3, :r4, :r4b, :r5] + + assert_equal expected, @read_suite.supported_versions + assert_equal expected, @history_suite.supported_versions + end + + def test_mocked_r5_read_vread_and_conditional_responses_parse_in_the_r5_namespace + patient_json = FHIR::R5::Patient.new(id: 'patient-1').to_json + + read = @read_suite.resource_from_contents(patient_json) + vread = @history_suite.resource_from_contents(patient_json) + conditional_full = @read_suite.resource_from_contents(patient_json) + conditional_not_modified = Reply.new(code: 304, resource: nil, body: '') + + assert_instance_of FHIR::R5::Patient, read + assert_instance_of FHIR::R5::Patient, vread + assert_instance_of FHIR::R5::Patient, conditional_full + assert_nil conditional_not_modified.resource + end + + def test_mocked_r5_delete_and_history_responses_parse_in_the_r5_namespace + deleted = FHIR::R5::OperationOutcome.new( + issue: [{ severity: 'information', code: 'deleted' }] + ) + history_json = <<~JSON + { + "resourceType": "Bundle", + "type": "history", + "entry": [ + { + "resource": { "resourceType": "Patient", "id": "patient-1" }, + "request": { "method": "POST", "url": "Patient" } + }, + { + "request": { "method": "DELETE", "url": "Patient/patient-1/_history/3" } + } + ] + } + JSON + + delete_response = @history_suite.resource_from_contents(deleted.to_json) + history_response = @history_suite.resource_from_contents(history_json) + + assert_instance_of FHIR::R5::OperationOutcome, delete_response + assert_instance_of FHIR::R5::Bundle, history_response + assert_equal 1, @history_suite.deleted_entries(history_response.entry).length + assert_equal 1, @history_suite.active_entries(history_response.entry).length + assert_equal '3', @history_suite.deleted_history_version(history_response) + end + + def test_r5_response_status_expectations_distinguish_not_found_and_gone + @read_suite.assert_response_ok(Reply.new(code: 200, resource: FHIR::R5::Patient.new)) + @read_suite.assert_response_not_found(Reply.new(code: 404, resource: nil)) + @history_suite.assert_response_gone(Reply.new(code: 410, resource: nil)) + end +end diff --git a/test/unit/r5_recursive_generation_test.rb b/test/unit/r5_recursive_generation_test.rb new file mode 100644 index 00000000..8a899908 --- /dev/null +++ b/test/unit/r5_recursive_generation_test.rb @@ -0,0 +1,162 @@ +require_relative '../test_helper' + +class R5RecursiveGenerationTest < Test::Unit::TestCase + AUDITED_DEPTHS = Crucible::Tests::R5ResourceGenerationAudit::DEFAULT_DEPTHS + + def setup + @required_node_class = Class.new(FHIR::R5::Model) + @required_node_class.include(FHIR::R5::Hashable) + FHIR::R5.const_set(:Task6ERequiredNode, @required_node_class) + @required_node_class.const_set( + :METADATA, + { + 'child' => { + 'path' => 'Task6ERequiredNode.child', + 'type' => 'Task6ERequiredNode', + 'min' => 1, + 'max' => 1 + } + }.freeze + ) + @required_node_class.class_eval { attr_accessor :child } + end + + def teardown + FHIR::R5.send(:remove_const, :Task6ERequiredNode) if + FHIR::R5.const_defined?(:Task6ERequiredNode, false) + end + + def test_direct_and_indirect_r5_recursion_terminates_at_every_audited_depth + AUDITED_DEPTHS.each do |depth| + questionnaire = generator.generate(FHIR::R5::Questionnaire, depth) + characteristics = generator.generate( + FHIR::R5::EvidenceVariable::Characteristic, + depth + ) + identifier = generator.generate(FHIR::R5::Identifier, depth) + + items = questionnaire_items(questionnaire.item) + recursive_characteristics = combination_characteristics(characteristics) + + assert_not_empty items, "Questionnaire depth #{depth}" + assert_operator items.length, :<=, depth + 1 + assert_operator recursive_characteristics.length, :<=, depth + 1 + assert_empty questionnaire.validate, "Questionnaire depth #{depth}" + assert_empty characteristics.validate, + "EvidenceVariable::Characteristic depth #{depth}" + assert_empty identifier.validate, "Identifier depth #{depth}" + end + end + + def test_required_recursive_descendants_and_references_are_populated + AUDITED_DEPTHS.each do |depth| + characteristic = generator.generate( + FHIR::R5::EvidenceVariable::Characteristic, + depth + ) + schedule = generator.generate(FHIR::R5::Schedule, depth) + + combinations = combination_characteristics(characteristic).filter_map( + &:definitionByCombination + ) + + assert_not_empty combinations, "Characteristic depth #{depth}" + assert_true combinations.all? do |combination| + combination.characteristic.to_a.length >= 1 + end + assert_not_empty schedule.actor, "Schedule depth #{depth}" + assert_true schedule.actor.all? do |reference| + reference.is_a?(FHIR::R5::Reference) + end + assert_empty characteristic.validate, "Characteristic depth #{depth}" + assert_empty schedule.validate, "Schedule depth #{depth}" + end + end + + def test_empty_r5_codeable_reference_gets_exactly_one_r5_value + codeable_reference = generator.generate(FHIR::R5::CodeableReference) + populated = [ + codeable_reference.concept, + codeable_reference.reference + ].compact + + assert_equal 1, populated.length + assert_instance_of FHIR::R5::CodeableConcept, + codeable_reference.concept + assert_nil codeable_reference.reference + assert_r5_graph(codeable_reference) + end + + def test_populated_r5_codeable_reference_is_preserved + existing_reference = FHIR::R5::Reference.new( + display: 'Existing R5 reference' + ) + codeable_reference = FHIR::R5::CodeableReference.new( + reference: existing_reference + ) + + generator.apply_invariants!(codeable_reference) + + assert_nil codeable_reference.concept + assert_same existing_reference, codeable_reference.reference + assert_r5_graph(codeable_reference) + end + + def test_nested_generated_codeable_references_stay_in_r5 + AUDITED_DEPTHS.each do |depth| + supply_request = generator.generate(FHIR::R5::SupplyRequest, depth) + codeable_references = collect_models(supply_request).select do |model| + model.is_a?(FHIR::R5::CodeableReference) + end + + assert_not_empty codeable_references, "SupplyRequest depth #{depth}" + codeable_references.each { |reference| assert_r5_graph(reference) } + assert_empty supply_request.validate, "SupplyRequest depth #{depth}" + end + end + + def test_required_cycle_fails_with_the_complete_element_path + error = assert_raise(generator::RequiredElementGenerationError) do + generator.generate(@required_node_class) + end + expected_path = 'FHIR::R5::Task6ERequiredNode' + + ('.child' * (generator::EMBEDDED_LOOP_GUARD + 1)) + + assert_include error.message, expected_path + assert_include error.message, 'Task6ERequiredNode.child' + assert_include error.message, 'minimum 1' + end + + private + + def generator + Crucible::Tests::ResourceGenerator + end + + def questionnaire_items(items) + items.to_a.flat_map do |item| + [item] + questionnaire_items(item.item) + end + end + + def combination_characteristics(root) + values = [root] + root.definitionByCombination&.characteristic.to_a.each do |child| + values.concat(combination_characteristics(child)) + end + values + end + + def collect_models(root) + models = [] + generator.each_fhir_model(root) { |model| models << model } + models + end + + def assert_r5_graph(root) + collect_models(root).each do |model| + assert_true model.class.name.start_with?('FHIR::R5::'), + model.class.name + end + end +end diff --git a/test/unit/r5_resource_generation_audit_test.rb b/test/unit/r5_resource_generation_audit_test.rb new file mode 100644 index 00000000..95f848e7 --- /dev/null +++ b/test/unit/r5_resource_generation_audit_test.rb @@ -0,0 +1,182 @@ +require_relative '../test_helper' +require 'tmpdir' + +class R5ResourceGenerationAuditTest < Test::Unit::TestCase + ABSTRACT_RESOURCES = Crucible::Tests::R5ResourceGenerationAudit::ABSTRACT_RESOURCES + + def setup + @tmpdir = Dir.mktmpdir('r5-resource-generation-audit') + end + + def teardown + FileUtils.rm_rf(@tmpdir) + end + + def test_cases_enumerate_every_concrete_resource_once_per_depth_and_iteration + audit = build_audit(depths: [2, 3], iterations: 2) + concrete_resources = (FHIR::R5::RESOURCES - ABSTRACT_RESOURCES).sort + expected = concrete_resources.product([2, 3], [0, 1]) + actual = audit.cases.map do |audit_case| + [audit_case.resource_name, audit_case.depth, audit_case.iteration] + end + + assert_equal 158, concrete_resources.length + assert_equal expected, actual + assert_equal actual.length, actual.uniq.length + assert_empty actual.map(&:first) & ABSTRACT_RESOURCES + end + + def test_exceptions_and_validation_failures_write_actionable_diagnostics + generator = lambda do |klass, _depth| + raise ArgumentError, 'controlled generator failure' if klass == FHIR::R5::Patient + + FHIR::R5::Observation.new( + status: 'not-a-real-status', + code: FHIR::R5::CodeableConcept.new + ) + end + result = build_audit( + resources: %w[Patient Observation], + depths: [2], + iterations: 1, + generator: generator + ).run + + assert_false result.success? + assert_equal 2, result.failures.length + + patient_diagnostic = diagnostic_for('Patient') + assert_equal 'ArgumentError', patient_diagnostic.fetch('exception').fetch('class') + assert_equal 'controlled generator failure', patient_diagnostic.fetch('exception').fetch('message') + assert_nil patient_diagnostic.fetch('serialized_fixture') + + observation_diagnostic = diagnostic_for('Observation') + assert_not_empty observation_diagnostic.fetch('validation_errors') + assert_nil observation_diagnostic.fetch('exception') + assert_true File.exist?(observation_diagnostic.fetch('serialized_fixture')) + assert_match( + /crucible:audit_r5_resource_generation/, + observation_diagnostic.fetch('replay_command') + ) + end + + def test_namespace_contamination_and_empty_required_elements_fail_the_audit + generator = lambda do |_klass, _depth| + FHIR::R5::Bundle.new( + type: '', + entry: [ + FHIR::R5::Bundle::Entry.new(resource: FHIR::Patient.new) + ] + ) + end + result = build_audit( + resources: ['Bundle'], + depths: [2], + iterations: 1, + generator: generator + ).run + failure = result.failures.fetch(0) + + assert_false result.success? + assert_true failure.namespace_errors.any? do |error| + error.fetch('actual_class') == 'FHIR::Patient' + end + assert_true failure.required_element_errors.any? do |error| + error.fetch('path').end_with?('.type') + end + end + + def test_successful_cases_leave_no_error_artifacts + result = build_audit( + resources: ['Patient'], + depths: [2], + iterations: 2, + generator: ->(_klass, _depth) { FHIR::R5::Patient.new } + ).run + + assert_true result.success? + assert_equal 2, result.audit_cases.length + assert_empty result.failures + assert_equal ['manifest.json'], Dir.children(@tmpdir) + assert_empty Dir[File.join(@tmpdir, '*.diagnostic.json')] + assert_empty Dir[File.join(@tmpdir, '*.fixture.json')] + manifest = JSON.parse(File.read(result.manifest_path)) + assert_equal 2, manifest.fetch('case_count') + assert_equal 0, manifest.fetch('failure_count') + end + + def test_required_choice_is_checked_once_as_a_group + generator = lambda do |_klass, _depth| + FHIR::R5::Task.new( + status: 'requested', + intent: 'order', + input: [ + FHIR::R5::Task::Input.new( + type: FHIR::R5::CodeableConcept.new + ) + ] + ) + end + result = build_audit( + resources: ['Task'], + depths: [2], + iterations: 1, + generator: generator + ).run + value_errors = result.failures.fetch(0).required_element_errors.select do |error| + error.fetch('definition_path') == 'Input.value[x]' + end + + assert_equal 1, value_errors.length + assert_match(/value\[x\]\z/, value_errors.fetch(0).fetch('path')) + end + + def test_case_seed_replays_random_and_datetime_values + generated_values = [] + generator = lambda do |_klass, _depth| + generated_values << [ + SecureRandom.base64, + SecureRandom.uuid, + SecureRandom.random_number(10_000), + %w[a b c].sample, + rand(10_000), + DateTime.now.strftime('%Y-%m-%dT%H:%M:%S.%L%:z') + ] + FHIR::R5::Patient.new + end + + first = build_audit( + resources: ['Patient'], + depths: [2], + iterations: 1, + seed: 1234, + generator: generator + ) + first.run + second = build_audit( + resources: ['Patient'], + depths: [2], + iterations: 1, + seed: 1234, + generator: generator + ) + second.run + + assert_equal first.cases.map(&:seed), second.cases.map(&:seed) + assert_equal generated_values.fetch(0), generated_values.fetch(1) + end + + private + + def build_audit(**options) + Crucible::Tests::R5ResourceGenerationAudit.new( + output_dir: @tmpdir, + **options + ) + end + + def diagnostic_for(resource_name) + path = Dir[File.join(@tmpdir, "r5-#{resource_name}-*.diagnostic.json")].fetch(0) + JSON.parse(File.read(path)) + end +end diff --git a/test/unit/r5_resource_invariants_test.rb b/test/unit/r5_resource_invariants_test.rb new file mode 100644 index 00000000..0b4557e7 --- /dev/null +++ b/test/unit/r5_resource_invariants_test.rb @@ -0,0 +1,461 @@ +require_relative '../test_helper' + +class R5ResourceInvariantsTest < Test::Unit::TestCase + REMOVED_R4B_RESOURCES = [ + :DeviceUseStatement, + :RequestGroup + ].freeze + + def test_ingredient_reference_strength_has_a_serializable_required_choice + reference_strength = + FHIR::R5::Ingredient::Substance::Strength::ReferenceStrength.new( + substance: codeable_reference('Reference substance'), + strengthRatio: FHIR::R5::Ratio.new + ) + + generator.apply_invariants!(reference_strength) + + assert_nil reference_strength.strengthRatio + assert_nil reference_strength.strengthRatioRange + assert_instance_of FHIR::R5::Quantity, + reference_strength.strengthQuantity + assert_not_nil reference_strength.strengthQuantity.value + + resource = FHIR::R5::Ingredient.new( + status: 'draft', + role: concept('Active ingredient'), + substance: FHIR::R5::Ingredient::Substance.new( + code: codeable_reference('Ingredient'), + strength: [ + FHIR::R5::Ingredient::Substance::Strength.new( + referenceStrength: [reference_strength] + ) + ] + ) + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_medication_knowledge_environment_has_a_serializable_required_choice + environment = + FHIR::R5::MedicationKnowledge::StorageGuideline::EnvironmentalSetting.new( + type: concept('Temperature'), + valueRange: FHIR::R5::Range.new + ) + + generator.apply_invariants!(environment) + + assert_nil environment.valueRange + assert_nil environment.valueCodeableConcept + assert_instance_of FHIR::R5::Quantity, environment.valueQuantity + assert_not_nil environment.valueQuantity.value + + resource = FHIR::R5::MedicationKnowledge.new( + storageGuideline: [ + FHIR::R5::MedicationKnowledge::StorageGuideline.new( + environmentalSetting: [environment] + ) + ] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_service_request_parameter_has_a_serializable_required_choice + parameter = FHIR::R5::ServiceRequest::OrderDetail::Parameter.new( + code: concept('Device setting'), + valueRatio: FHIR::R5::Ratio.new + ) + + generator.apply_invariants!(parameter) + + assert_nil parameter.valueQuantity + assert_nil parameter.valueRatio + assert_nil parameter.valueRange + assert_nil parameter.valueCodeableConcept + assert_nil parameter.valuePeriod + assert_not_empty parameter.valueString + + resource = FHIR::R5::ServiceRequest.new( + status: 'active', + intent: 'order', + subject: FHIR::R5::Reference.new(display: 'Patient'), + orderDetail: [ + FHIR::R5::ServiceRequest::OrderDetail.new(parameter: [parameter]) + ] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_element_definition_example_has_a_serializable_required_choice + example = FHIR::R5::ElementDefinition::Example.new( + label: 'Example', + valueQuantity: FHIR::R5::Quantity.new + ) + + generator.apply_invariants!(example) + + populated_choices = generator + .multiple_type_fields(example.class) + .fetch('value') + .values + .select { |field| !example.public_send(field).nil? } + assert_equal ['valueString'], populated_choices + assert_not_empty example.valueString + + resource = FHIR::R5::StructureDefinition.new( + url: 'http://example.test/StructureDefinition/example', + name: 'Example', + status: 'draft', + kind: 'resource', + abstract: false, + type: 'Patient', + differential: FHIR::R5::StructureDefinition::Differential.new( + element: [ + FHIR::R5::ElementDefinition.new( + path: 'Patient', + example: [example] + ) + ] + ) + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_sampled_data_uses_the_r5_numeric_sample_grammar + sampled_data = FHIR::R5::SampledData.new( + origin: FHIR::R5::Quantity.new(value: 0), + intervalUnit: 's', + dimensions: 1, + data: 'not numeric sampled data' + ) + + generator.apply_invariants!(sampled_data) + + assert_equal '0', sampled_data.data + + resource = FHIR::R5::Observation.new( + status: 'final', + code: concept('Sampled observation'), + valueSampledData: sampled_data + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_usage_context_has_a_serializable_required_choice + usage_context = FHIR::R5::UsageContext.new( + code: FHIR::R5::Coding.new( + system: 'http://terminology.hl7.org/CodeSystem/usage-context-type', + code: 'workflow' + ), + valueRange: FHIR::R5::Range.new + ) + + generator.apply_invariants!(usage_context) + + assert_required_choice( + usage_context, + 'value', + 'valueCodeableConcept' + ) + resource = FHIR::R5::ActorDefinition.new( + status: 'draft', + type: 'system', + useContext: [usage_context] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_populated_r5_required_choice_is_preserved + existing_value = concept('Existing usage context') + usage_context = FHIR::R5::UsageContext.new( + code: FHIR::R5::Coding.new(code: 'workflow'), + valueCodeableConcept: existing_value + ) + + generator.apply_invariants!(usage_context) + + assert_same existing_value, usage_context.valueCodeableConcept + assert_required_choice( + usage_context, + 'value', + 'valueCodeableConcept' + ) + end + + def test_biologically_derived_product_property_has_a_serializable_choice + property = FHIR::R5::BiologicallyDerivedProduct::Property.new( + type: concept('Collection property'), + valueRatio: FHIR::R5::Ratio.new + ) + + generator.apply_invariants!(property) + + assert_required_choice(property, 'value', 'valueString') + resource = FHIR::R5::BiologicallyDerivedProduct.new( + property: [property] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_evidence_variable_definition_has_a_serializable_required_choice + definition = + FHIR::R5::EvidenceVariable::Characteristic::DefinitionByTypeAndValue.new( + type: concept('Definition type'), + valueReference: FHIR::R5::Reference.new + ) + + generator.apply_invariants!(definition) + + assert_required_choice(definition, 'value', 'valueId') + resource = FHIR::R5::EvidenceVariable.new( + status: 'draft', + characteristic: [ + FHIR::R5::EvidenceVariable::Characteristic.new( + definitionByTypeAndValue: definition + ) + ] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_group_characteristic_has_a_serializable_required_choice + characteristic = FHIR::R5::Group::Characteristic.new( + code: concept('Group characteristic'), + valueReference: FHIR::R5::Reference.new, + exclude: false + ) + + generator.apply_invariants!(characteristic) + + assert_required_choice(characteristic, 'value', 'valueBoolean') + assert_equal true, characteristic.valueBoolean + resource = FHIR::R5::Group.new( + type: 'person', + membership: 'definitional', + characteristic: [characteristic] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_measure_report_stratifier_component_has_a_serializable_required_choice + component = + FHIR::R5::MeasureReport::Group::Stratifier::Stratum::Component.new( + code: concept('Measure stratifier'), + valueRange: FHIR::R5::Range.new + ) + + generator.apply_invariants!(component) + + assert_required_choice(component, 'value', 'valueCodeableConcept') + assert_not_empty component.valueCodeableConcept.text + + resource = FHIR::R5::MeasureReport.new( + status: 'complete', + type: 'individual', + measure: 'http://example.test/Measure/example', + period: FHIR::R5::Period.new( + start: '2026-01-01T00:00:00Z', + end: '2026-01-01T00:00:00Z' + ), + group: [ + FHIR::R5::MeasureReport::Group.new( + stratifier: [ + FHIR::R5::MeasureReport::Group::Stratifier.new( + stratum: [ + FHIR::R5::MeasureReport::Group::Stratifier::Stratum.new( + component: [component] + ) + ] + ) + ] + ) + ] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_inventory_item_association_has_a_serializable_required_ratio + association = FHIR::R5::InventoryItem::Association.new( + associationType: concept('Package'), + relatedItem: FHIR::R5::Reference.new(display: 'Inventory item'), + quantity: FHIR::R5::Ratio.new + ) + + generator.apply_invariants!(association) + + assert_instance_of FHIR::R5::Quantity, association.quantity.numerator + assert_instance_of FHIR::R5::Quantity, association.quantity.denominator + assert_not_nil association.quantity.numerator.value + assert_not_nil association.quantity.denominator.value + resource = FHIR::R5::InventoryItem.new( + status: 'active', + code: [concept('Inventory item')], + association: [association] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_r5_imaging_selection_2d_codes_are_compatible_with_the_official_xsd + region = FHIR::R5::ImagingSelection::Instance::ImageRegion2D.new( + regionType: 'circle', + coordinate: [1.0, 2.0] + ) + + generator.apply_invariants!(region) + + assert_equal 'point', region.regionType + resource = FHIR::R5::ImagingSelection.new( + status: 'available', + code: concept('Imaging selection'), + instance: [ + FHIR::R5::ImagingSelection::Instance.new( + uid: 'image-1', + imageRegion2D: [region] + ) + ] + ) + assert_r5_json_and_xml_valid(resource) + + compatible_region = + FHIR::R5::ImagingSelection::Instance::ImageRegion2D.new( + regionType: 'polyline', + coordinate: [1.0, 2.0] + ) + generator.apply_invariants!(compatible_region) + assert_equal 'polyline', compatible_region.regionType + end + + def test_test_report_test_has_a_serializable_required_action + test = FHIR::R5::TestReport::Test.new( + action: [FHIR::R5::TestReport::Test::Action.new] + ) + + generator.apply_invariants!(test) + + assert_equal 1, test.action.length + assert_instance_of FHIR::R5::TestReport::Setup::Action::Operation, + test.action.first.operation + assert_equal 'pass', test.action.first.operation.result + resource = FHIR::R5::TestReport.new( + status: 'completed', + testScript: 'http://example.test/TestScript/example', + result: 'pass', + test: [test] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_test_script_test_has_a_serializable_required_action + test = FHIR::R5::TestScript::Test.new( + action: [FHIR::R5::TestScript::Test::Action.new] + ) + + generator.apply_invariants!(test) + + assert_equal 1, test.action.length + assert_instance_of FHIR::R5::TestScript::Setup::Action::Operation, + test.action.first.operation + assert_equal true, test.action.first.operation.encodeRequestUrl + resource = FHIR::R5::TestScript.new( + name: 'GeneratedTestScript', + status: 'draft', + test: [test] + ) + assert_r5_json_and_xml_valid(resource) + end + + def test_r5_replacements_do_not_resolve_removed_r4b_constants + install_removed_resource_sentinels + + resources = [ + FHIR::R5::RequestOrchestration.new(status: 'active', intent: 'order'), + FHIR::R5::DeviceUsage.new( + status: 'active', + patient: FHIR::R5::Reference.new(display: 'Patient'), + device: codeable_reference('Device') + ) + ] + resources.each do |resource| + generator.apply_invariants!(resource) + assert_r5_json_and_xml_valid(resource) + end + ensure + remove_removed_resource_sentinels + end + + def test_representative_r5_only_resource_stays_in_the_r5_namespace + resource = generator.generate(FHIR::R5::ActorDefinition, 3) + + assert_r5_graph(resource) + assert_instance_of FHIR::R5::ActorDefinition, + FHIR::R5::Json.from_json(resource.to_json) + end + + private + + def generator + Crucible::Tests::ResourceGenerator + end + + def concept(text) + FHIR::R5::CodeableConcept.new(text: text) + end + + def codeable_reference(text) + FHIR::R5::CodeableReference.new(concept: concept(text)) + end + + def assert_r5_json_and_xml_valid(resource) + assert_empty resource.validate, resource.class.name + + json_resource = FHIR::R5::Json.from_json(resource.to_json) + assert_instance_of resource.class, json_resource + assert_empty json_resource.validate, "#{resource.class.name} JSON" + assert_r5_graph(json_resource) + + xml = resource.to_xml + assert_empty FHIR::R5::Xml.validate(xml).map(&:message), + "#{resource.class.name} XML schema" + xml_resource = FHIR::R5::Xml.from_xml(xml) + assert_instance_of resource.class, xml_resource + assert_empty xml_resource.validate, "#{resource.class.name} XML" + assert_r5_graph(xml_resource) + end + + def assert_required_choice(resource, prefix, expected_field) + populated_choices = generator + .multiple_type_fields(resource.class) + .fetch(prefix) + .values + .select do |field| + !resource.public_send(field).nil? + end + assert_equal [expected_field], populated_choices + value = resource.public_send(expected_field) + assert_false(value.respond_to?(:empty?) && value.empty?) + end + + def assert_r5_graph(root) + generator.each_fhir_model(root) do |model| + assert_true model.class.name.start_with?('FHIR::R5::'), + model.class.name + end + end + + def install_removed_resource_sentinels + REMOVED_R4B_RESOURCES.each do |name| + sentinel = Class.new + sentinel.define_singleton_method(:===) do |_resource| + raise "R5 dispatch resolved removed R4B resource #{name}" + end + FHIR::R5.const_set(name, sentinel) + end + end + + def remove_removed_resource_sentinels + REMOVED_R4B_RESOURCES.each do |name| + FHIR::R5.send(:remove_const, name) if + FHIR::R5.const_defined?(name, false) + end + end +end diff --git a/test/unit/r5_resource_suite_test.rb b/test/unit/r5_resource_suite_test.rb new file mode 100644 index 00000000..841469ef --- /dev/null +++ b/test/unit/r5_resource_suite_test.rb @@ -0,0 +1,90 @@ +require_relative '../test_helper' + +class R5ResourceSuiteTest < Test::Unit::TestCase + R5_ONLY_RESOURCES = %w[ + ActorDefinition + ArtifactAssessment + GenomicStudy + Permission + Requirements + TestPlan + Transport + ].freeze + REMOVED_R4B_RESOURCES = %w[ + CatalogEntry + DeviceUseStatement + DocumentManifest + Media + RequestGroup + ResearchDefinition + ResearchElementDefinition + ].freeze + + def setup + @client = FHIR::Client.new('http://r5.example', fhir_version: :r5) + @suite = Crucible::Tests::ResourceTest.new(@client) + end + + def test_resource_test_expands_to_every_crud_testable_r5_structure_resource + expected = structure_resource_names.map(&:downcase) - + Crucible::Tests::BaseSuite::EXCLUDED_RESOURCES.map(&:downcase) + actual = @suite.fhir_resources.map { |resource| resource.name.demodulize.downcase }.sort + + assert_equal expected.sort, actual + assert_equal 156, actual.length + assert_true @suite.fhir_resources.all? { |resource| resource.name.start_with?('FHIR::R5::') } + end + + def test_r5_only_resources_are_listed_and_removed_r4b_resources_are_not + listed = Crucible::Tests::SuiteEngine.list_all + r5_resource_tests = listed.select do |name, metadata| + name.start_with?('ResourceTest') && metadata.fetch('supported_versions').include?(:r5) + end.keys + + R5_ONLY_RESOURCES.each do |resource| + assert_include r5_resource_tests, "ResourceTest#{resource}" + end + REMOVED_R4B_RESOURCES.each do |resource| + assert_not_include r5_resource_tests, "ResourceTest#{resource}" + end + end + + def test_resource_test_generation_and_parsing_remain_in_the_r5_namespace + @suite.fhir_resources.each do |resource_class| + generated = Crucible::Tests::ResourceGenerator.generate(resource_class, 2) + parsed = @suite.resource_from_contents(generated.to_json) + + assert_r5_graph(generated, resource_class.name) + assert_r5_graph(parsed, "parsed #{resource_class.name}") + end + end + + private + + def structure_resource_names + structure = Crucible::FHIRStructure.get(:r5) + resource_root = structure.fetch('children').find { |child| child['name'] == 'RESOURCES' } + + resource_root.fetch('children').flat_map do |section| + section.fetch('children').flat_map do |category| + category.fetch('children').map { |resource| resource.fetch('name').delete(' ') } + end + end + end + + def assert_r5_graph(value, description, seen = {}) + return if value.nil? + return value.each { |entry| assert_r5_graph(entry, description, seen) } if value.is_a?(Array) + return value.each_value { |entry| assert_r5_graph(entry, description, seen) } if value.is_a?(Hash) + return unless value.is_a?(FHIR::Model) + return if seen[value.object_id] + + seen[value.object_id] = true + assert_true value.class.name.start_with?('FHIR::R5::'), "#{description} includes #{value.class}" + + value.class::METADATA.each do |field, metadata| + local_name = metadata['local_name'] || field + assert_r5_graph(value.instance_variable_get("@#{local_name}"), description, seen) + end + end +end diff --git a/test/unit/r5_routing_test.rb b/test/unit/r5_routing_test.rb new file mode 100644 index 00000000..a33a6a14 --- /dev/null +++ b/test/unit/r5_routing_test.rb @@ -0,0 +1,136 @@ +require_relative '../test_helper' + +class R5RoutingTest < Test::Unit::TestCase + def setup + @client = FHIR::Client.new('http://r5', fhir_version: :r5) + @suite = Crucible::Tests::BaseSuite.new(@client) + end + + def test_base_test_and_base_suite_use_r5 + assert_equal :r5, @client.fhir_version + assert_same FHIR::R5, @suite.version_namespace + assert_same @client, FHIR::R5::Resource.new.client + end + + def test_r5_resource_lookup_and_validation_are_explicit + assert_same FHIR::R5::Patient, Crucible::Tests::BaseSuite.get_resource(:r5, :Patient) + assert_true Crucible::Tests::BaseSuite.valid_resource?(:r5, 'Patient') + assert_false Crucible::Tests::BaseSuite.valid_resource?(:r5, 'ResearchDefinition') + + patient = @suite.resource_from_contents(FHIR::R5::Patient.new(id: 'r5').to_json) + + assert_instance_of FHIR::R5::Patient, patient + assert_empty patient.validate + end + + def test_r5_resource_enumeration_contains_only_concrete_r5_classes + resources = Crucible::Tests::BaseSuite.fhir_resources(:r5) + + assert_not_empty resources + assert_true resources.all? { |resource| resource.name.start_with?('FHIR::R5::') } + assert_not_include resources, FHIR::R5::CanonicalResource + assert_not_include resources, FHIR::R5::MetadataResource + end + + def test_r5_response_parsing_uses_r5_classes + patient = @suite.resource_from_contents(FHIR::R5::Patient.new(id: 'r5').to_json) + bundle = @suite.resource_from_contents( + FHIR::R5::Bundle.new(type: 'collection', entry: [{ resource: patient }]).to_json + ) + capability_statement = @suite.resource_from_contents( + FHIR::R5::CapabilityStatement.new( + status: 'active', + date: '2026-07-28', + kind: 'instance', + fhirVersion: '5.0.0', + format: ['json'] + ).to_json + ) + operation_outcome = @suite.parse_operation_outcome( + FHIR::R5::OperationOutcome.new( + issue: [{ severity: 'error', code: 'invalid' }] + ).to_json + ) + + assert_instance_of FHIR::R5::Patient, patient + assert_instance_of FHIR::R5::Bundle, bundle + assert_instance_of FHIR::R5::Patient, bundle.entry.first.resource + assert_instance_of FHIR::R5::CapabilityStatement, capability_statement + assert_instance_of FHIR::R5::OperationOutcome, operation_outcome + end + + def test_generated_r5_patient_graph_has_no_r4_or_r4b_models + patient = Crucible::Tests::ResourceGenerator.generate(FHIR::R5::Patient, 2) + + model_classes = assert_r5_model_graph(patient) + + assert_include model_classes, FHIR::R5::HumanName + assert_include model_classes, FHIR::R5::Meta + end + + def test_generated_r5_bundle_graph_has_no_r4_or_r4b_models + bundle = Crucible::Tests::ResourceGenerator.generate(FHIR::R5::Bundle, 2) + + model_classes = assert_r5_model_graph(bundle) + + assert_include model_classes, FHIR::R5::Bundle::Entry + assert_include model_classes, FHIR::R5::Meta + end + + def test_condition_status_normalization_preserves_r5_types + condition = FHIR::R5::Condition.new + condition.clinicalStatus = 'active' + condition.verificationStatus = 'confirmed' + + Crucible::Tests::ResourceGenerator.fix_condition(condition) + + assert_instance_of FHIR::R5::CodeableConcept, condition.clinicalStatus + assert_instance_of FHIR::R5::CodeableConcept, condition.verificationStatus + assert_instance_of FHIR::R5::Coding, condition.clinicalStatus.coding.first + assert_instance_of FHIR::R5::Coding, condition.verificationStatus.coding.first + end + + def test_minimal_condition_includes_r5_clinical_and_verification_statuses + condition = Crucible::Tests::ResourceGenerator.minimal_condition(namespace: FHIR::R5) + + assert_instance_of FHIR::R5::CodeableConcept, condition.clinicalStatus + assert_equal 'active', condition.clinicalStatus.coding.first.code + assert_instance_of FHIR::R5::CodeableConcept, condition.verificationStatus + assert_equal 'confirmed', condition.verificationStatus.coding.first.code + end + + def test_r5_resource_ownership_selects_the_r5_structure + r5_structure = Crucible::FHIRStructure.for_resource(FHIR::R5::ActorDefinition) + r4b_structure = Crucible::FHIRStructure.for_resource(FHIR::R4B::Citation) + + assert_equal Crucible::FHIRStructure.get(:r5), r5_structure + assert_equal r5_structure, Crucible::FHIRStructure.get('R5') + assert_equal Crucible::FHIRStructure.get(:r4b), r4b_structure + assert_equal 'Specialized', @suite.resource_category(FHIR::R5::ActorDefinition) + assert_equal 'Specialized', @suite.resource_category(FHIR::R4B::Citation) + end + + private + + def assert_r5_model_graph(resource) + model_classes = collect_model_classes(resource) + + assert_not_empty model_classes + assert_true model_classes.all? { |klass| klass.name.start_with?('FHIR::R5::') }, + "Non-R5 classes found: #{model_classes.reject { |klass| klass.name.start_with?('FHIR::R5::') }.uniq}" + model_classes + end + + def collect_model_classes(value, seen = {}) + return [] if value.nil? + return value.flat_map { |item| collect_model_classes(item, seen) } if value.is_a?(Array) + return value.values.flat_map { |item| collect_model_classes(item, seen) } if value.is_a?(Hash) + return [] unless value.is_a?(FHIR::Model) + return [] if seen[value.object_id] + + seen[value.object_id] = true + [value.class] + value.instance_variables.flat_map do |variable| + collect_model_classes(value.instance_variable_get(variable), seen) + end + end +end diff --git a/test/unit/r5_search_suite_test.rb b/test/unit/r5_search_suite_test.rb new file mode 100644 index 00000000..5561d72a --- /dev/null +++ b/test/unit/r5_search_suite_test.rb @@ -0,0 +1,137 @@ +require_relative '../test_helper' +require 'webmock/test_unit' + +class R5SearchSuiteTest < Test::Unit::TestCase + BASE_URL = 'http://r5-search-suite.test/fhir'.freeze + + def setup + @client = FHIR::Client.new(BASE_URL, fhir_version: :r5) + end + + def test_search_test_uses_r5_definitions_for_advertised_parameter_names_and_types + stub_capability_statement(search_param_type: 'string') + stub_search_responses + + suite = Crucible::Tests::SearchTest.new(@client) + results = suite.execute(FHIR::R5::Observation).fetch('SearchTest_Observation') + + assert_equal 7, results.length + assert_true results.all? { |result| result['status'] == 'pass' }, failure_summary(results) + assert_include suite.supported_versions, :r5 + assert_equal 'string', suite.r5_search_parameter_definitions.find { |definition| + definition['code'] == 'value-markdown' + }['type'] + assert_not_include FHIR::R4B::Definitions.search_parameters('Observation'), 'value-markdown' + end + + def test_search_test_rejects_an_advertised_r5_parameter_with_the_wrong_type + stub_capability_statement(search_param_type: 'token') + stub_search_responses + + suite = Crucible::Tests::SearchTest.new(@client) + result = suite.execute(FHIR::R5::Observation).fetch('SearchTest_Observation').first + + assert_equal 'fail', result['status'] + assert_match(/R5 defines it as string/, result['message']) + end + + def test_search_test_accepts_r5_resource_parameters_and_the_summary_control_parameter + stub_capability_statement(search_param_type: 'string', additional_search_params: [ + { name: '_id', type: 'token' }, + { name: '_lastUpdated', type: 'date' }, + { name: '_tag', type: 'token' }, + { name: '_profile', type: 'reference' }, + { name: '_security', type: 'token' }, + { name: '_summary', type: 'string' } + ]) + stub_search_responses + + suite = Crucible::Tests::SearchTest.new(@client) + result = suite.execute(FHIR::R5::Observation).fetch('SearchTest_Observation').first + + assert_equal 'pass', result['status'] + end + + def test_r5_search_parameter_definitions_cover_representative_types + definitions = FHIR::R5::Definitions.send(:search_params) + + assert_search_parameter_type definitions, 'Patient', 'name', 'string' + assert_search_parameter_type definitions, 'Patient', 'identifier', 'token' + assert_search_parameter_type definitions, 'Observation', 'subject', 'reference' + assert_search_parameter_type definitions, 'Patient', 'birthdate', 'date' + assert_search_parameter_type definitions, 'RiskAssessment', 'probability', 'number' + assert_search_parameter_type definitions, 'Observation', 'value-quantity', 'quantity' + end + + def test_robust_search_setup_and_cleanup_stay_in_the_r5_namespace + patient = FHIR::R5::Patient.new(id: 'r5-search-patient') + stub_request(:post, "#{BASE_URL}/Patient").to_return( + status: 201, + body: patient.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + ) + stub_request(:delete, "#{BASE_URL}/Patient/r5-search-patient").to_return(status: 204) + + suite = Crucible::Tests::RobustSearchTest.new(@client) + results = suite.execute.fetch('Search002') + + assert_equal 1, results.length + assert_equal 'skip', results.first['status'] + assert_match(/spark\/issues\/310/, results.first['message']) + assert_instance_of FHIR::R5::Patient, suite.instance_variable_get(:@patient) + assert_include suite.supported_versions, :r5 + end + + private + + def stub_capability_statement(search_param_type:, additional_search_params: []) + capability_statement = FHIR::R5::CapabilityStatement.new( + status: 'active', + date: '2026-07-29', + kind: 'instance', + fhirVersion: '5.0.0', + format: ['json'], + rest: [ + { + mode: 'server', + resource: [ + { + type: 'Observation', + searchParam: [{ name: 'value-markdown', type: search_param_type }] + additional_search_params + } + ] + } + ] + ) + + stub_request(:get, "#{BASE_URL}/metadata").to_return( + status: 200, + body: capability_statement.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + ) + end + + def stub_search_responses + bundle = FHIR::R5::Bundle.new(type: 'searchset', total: 0, entry: []) + stub_request(:any, %r{\A#{Regexp.escape(BASE_URL)}/Observation(?:/_search)?(?:\?.*)?\z}).to_return( + status: 200, + body: bundle.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + ) + end + + def failure_summary(results) + results.reject { |result| result['status'] == 'pass' }.map do |result| + "#{result['id']}: #{result['status']} #{result['message']}" + end.join("\n") + end + + def assert_search_parameter_type(definitions, resource, code, expected_type) + definition = definitions.find do |candidate| + candidate['code'] == code && candidate.fetch('base', []).include?(resource) + end + + assert_not_nil definition, "Expected R5 #{resource} search parameter #{code}." + assert_equal expected_type, definition['type'] + end +end diff --git a/test/unit/r5_sprinkler_search_suite_test.rb b/test/unit/r5_sprinkler_search_suite_test.rb new file mode 100644 index 00000000..b96ce353 --- /dev/null +++ b/test/unit/r5_sprinkler_search_suite_test.rb @@ -0,0 +1,53 @@ +require_relative '../test_helper' + +class R5SprinklerSearchSuiteTest < Test::Unit::TestCase + def setup + @client = FHIR::Client.new('http://r5-sprinkler-search-suite.test/fhir', fhir_version: :r5) + @suite = Crucible::Tests::SprinklerSearchTest.new(@client) + end + + def test_r5_resource_specific_and_generic_search_parameters_match_the_sprinkler_contract + definitions = FHIR::R5::Definitions.send(:search_params) + + assert_search_parameter definitions, 'Patient', 'family', 'string' + assert_search_parameter definitions, 'Patient', 'given', 'string' + assert_search_parameter definitions, 'Patient', 'gender', 'token' + assert_search_parameter definitions, 'Patient', 'identifier', 'token' + assert_search_parameter definitions, 'Condition', 'patient', 'reference', ['Patient'] + assert_search_parameter definitions, 'Observation', 'code', 'token' + assert_search_parameter definitions, 'Observation', 'value-quantity', 'quantity' + assert_search_parameter definitions, 'Resource', '_id', 'token' + end + + def test_r5_setup_patient_uses_the_r5_namespace + patient = Crucible::Generator::Resources.new(:r5).minimal_patient + + assert_instance_of FHIR::R5::Patient, patient + assert_include @suite.supported_versions, :r5 + end + + def test_exact_result_assertion_is_independent_of_bundle_order + first = FHIR::R5::Patient.new(id: 'first') + second = FHIR::R5::Patient.new(id: 'second') + bundle = FHIR::R5::Bundle.new( + type: 'searchset', + total: 2, + entry: [{ resource: second }, { resource: first }] + ) + reply = Struct.new(:code, :resource, :body).new(200, bundle, bundle.to_json) + + @suite.assert_exact_result_ids(reply, %w[first second]) + end + + private + + def assert_search_parameter(definitions, resource, code, type, targets = nil) + definition = definitions.find do |candidate| + candidate['code'] == code && candidate.fetch('base', []).include?(resource) + end + + assert_not_nil definition, "Expected R5 #{resource} search parameter #{code}." + assert_equal type, definition['type'] + assert_equal targets, definition['target'] if targets + end +end diff --git a/test/unit/r5_structure_test.rb b/test/unit/r5_structure_test.rb new file mode 100644 index 00000000..86984288 --- /dev/null +++ b/test/unit/r5_structure_test.rb @@ -0,0 +1,165 @@ +require_relative '../test_helper' +require 'rake' +require 'tmpdir' + +load File.expand_path('../../lib/tasks/fhir_structure.rake', __dir__) + +class R5StructureTest < Test::Unit::TestCase + ROOT = File.expand_path('../..', __dir__).freeze + ABSTRACT_RESOURCES = %w[ + Resource + DomainResource + CanonicalResource + MetadataResource + ].freeze + R5_ONLY_RESOURCES = %w[ + ActorDefinition + ArtifactAssessment + GenomicStudy + Permission + Requirements + TestPlan + Transport + ].freeze + REMOVED_R4B_RESOURCES = %w[ + CatalogEntry + DeviceUseStatement + DocumentManifest + Media + RequestGroup + ResearchDefinition + ResearchElementDefinition + ].freeze + EXPECTED_CATEGORIES = [ + 'Foundation.Conformance', + 'Foundation.Terminology', + 'Foundation.Security', + 'Foundation.Documents', + 'Foundation.Other', + 'Base.Individuals', + 'Base.Entities', + 'Base.Workflow', + 'Base.Management', + 'Clinical.Summary', + 'Clinical.Diagnostics', + 'Clinical.Medications', + 'Clinical.Care Provision', + 'Clinical.Request & Response', + 'Financial.Support', + 'Financial.Billing', + 'Financial.Payment', + 'Financial.General', + 'Specialized.Public Health & Research', + 'Specialized.Definitional Artifacts', + 'Specialized.Evidence-Based Medicine', + 'Specialized.Quality Reporting & Testing', + 'Specialized.Medication Definition' + ].freeze + + def setup + @structure = Crucible::FHIRStructure.get(:r5) + @resource_root = @structure.fetch('children').find { |child| child['name'] == 'RESOURCES' } + end + + def test_index_contains_exactly_the_concrete_r5_model_resources + expected = (FHIR::R5::RESOURCES - ABSTRACT_RESOURCES).map { |resource| normalize(resource) } + + assert_equal expected.sort, resource_names.map { |resource| normalize(resource) }.sort + end + + def test_index_contains_158_resources_once_each + assert_equal 158, resource_names.length + assert_equal resource_names.length, resource_names.uniq.length + end + + def test_index_contains_representative_r5_only_resources + normalized_resources = resource_names.map { |resource| normalize(resource) } + + R5_ONLY_RESOURCES.each do |resource| + assert_include normalized_resources, normalize(resource) + end + end + + def test_index_excludes_resources_removed_after_r4b + normalized_resources = resource_names.map { |resource| normalize(resource) } + + REMOVED_R4B_RESOURCES.each do |resource| + assert_not_include normalized_resources, normalize(resource) + end + end + + def test_index_contains_only_valid_decoded_categories + assert_equal EXPECTED_CATEGORIES.sort, category_paths.sort + assert_true categories.all? { |category| category.fetch('children').any? } + assert_no_match(/&[a-zA-Z]+;/, JSON.generate(@structure)) + assert_include category_paths, 'Clinical.Request & Response' + assert_include category_paths, 'Specialized.Public Health & Research' + assert_include category_paths, 'Specialized.Quality Reporting & Testing' + end + + def test_r5_generation_task_is_registered_and_requires_an_archive + assert_true Rake::Task.task_defined?('crucible:generate_r5_structure') + + task = Rake::Task['crucible:generate_r5_structure'] + task.reenable + error = assert_raise(RuntimeError) { task.invoke } + + assert_match(/crucible:generate_r5_structure/, error.message) + end + + def test_pinned_archive_repeatedly_regenerates_the_checked_in_artifact + archive_path = ENV.fetch( + 'R5_DEFINITIONS_ARCHIVE', + File.join(ROOT, 'tmp', 'task-5c', 'r5-definitions.json.zip') + ) + omit('Set R5_DEFINITIONS_ARCHIVE to the pinned R5 definitions archive') unless File.exist?(archive_path) + + Dir.mktmpdir('fhir-r5-structure') do |tmpdir| + configuration = configuration_with_output(File.join(tmpdir, 'FHIR_structure_r5.json')) + Crucible::FHIRStructureGenerator.write_from_archive(configuration, archive_path) + first_generation = File.binread(configuration.output_path) + Crucible::FHIRStructureGenerator.write_from_archive(configuration, archive_path) + + assert_equal File.binread(File.join(ROOT, 'lib', 'FHIR_structure_r5.json')), first_generation + assert_equal first_generation, File.binread(configuration.output_path) + end + end + + private + + def resource_names + categories.flat_map do |category| + category.fetch('children').map { |resource| resource.fetch('name') } + end + end + + def categories + @resource_root.fetch('children').flat_map { |section| section.fetch('children') } + end + + def category_paths + @resource_root.fetch('children').flat_map do |section| + section.fetch('children').map do |category| + "#{section.fetch('name')}.#{category.fetch('name')}" + end + end + end + + def normalize(resource) + resource.downcase.delete(' ') + end + + def configuration_with_output(output_path) + configuration = Crucible::FHIRStructureGenerator::CONFIGURATIONS.fetch(:r5) + Crucible::FHIRStructureGenerator::Configuration.new( + version: configuration.version, + label: configuration.label, + source_url: configuration.source_url, + sha256: configuration.sha256, + archive_entry: configuration.archive_entry, + category_overrides: configuration.category_overrides, + template_path: configuration.template_path, + output_path: output_path + ) + end +end diff --git a/test/unit/r5_terminology_generation_test.rb b/test/unit/r5_terminology_generation_test.rb new file mode 100644 index 00000000..a9db9f1c --- /dev/null +++ b/test/unit/r5_terminology_generation_test.rb @@ -0,0 +1,174 @@ +require_relative '../test_helper' + +class R5TerminologyGenerationTest < Test::Unit::TestCase + ITEM_TYPE_URI = 'http://hl7.org/fhir/ValueSet/item-type'.freeze + LANGUAGE_URI = 'http://hl7.org/fhir/ValueSet/all-languages'.freeze + BCP47_URI = 'http://tools.ietf.org/html/bcp47'.freeze + + def setup + clear_generator_code_caches + end + + def test_r5_required_bindings_generate_locally_selectable_codes + item_codes = generator.selectable_valid_codes( + FHIR::R5::Questionnaire::Item::METADATA.fetch('type'), + 'FHIR::R5' + ) + language_codes = generator.selectable_valid_codes( + FHIR::R5::Patient::Communication::METADATA.fetch('language'), + 'FHIR::R5' + ) + + assert_not_include item_codes.fetch('http://hl7.org/fhir/item-type'), + 'question' + assert_include item_codes.fetch('http://hl7.org/fhir/item-type'), 'string' + assert_include language_codes.fetch('urn:ietf:bcp:47'), 'en' + + [ + FHIR::R5::Binary, + FHIR::R5::Patient::Communication, + FHIR::R5::SampledData + ].each do |klass| + resource = generator.generate(klass, 2) + assert_empty resource.validate, "#{klass}: #{resource.validate}" + end + end + + def test_nested_abstract_and_inactive_expansion_entries_are_not_selectable + entries = [ + { + 'system' => 'http://example.test/codes', + 'code' => 'selectable', + 'contains' => [ + { 'code' => 'nested-selectable' }, + { 'code' => 'nested-abstract', 'abstract' => true }, + { 'code' => 'nested-inactive', 'inactive' => true } + ] + }, + { + 'system' => 'http://example.test/codes', + 'code' => 'abstract', + 'abstract' => true + }, + { + 'system' => 'http://example.test/codes', + 'code' => 'inactive', + 'inactive' => true + } + ] + + codes = generator.collect_selectable_expansion_codes(entries, {}) + + assert_equal( + { + 'http://example.test/codes' => %w[ + selectable + nested-selectable + ] + }, + codes + ) + end + + def test_versioned_and_unversioned_canonicals_resolve_the_same_expansion + metadata = FHIR::R5::Questionnaire::Item::METADATA.fetch('type') + versioned = metadata.deep_dup + versioned.fetch('binding')['uri'] = "#{ITEM_TYPE_URI}|5.0.0" + + assert_equal( + generator.selectable_valid_codes(metadata, 'FHIR::R5'), + generator.selectable_valid_codes(versioned, 'FHIR::R5') + ) + end + + def test_cache_results_are_isolated_by_namespace_canonical_and_field_subset + r4b_codes = generator.selectable_valid_codes( + required_binding(ITEM_TYPE_URI), + 'FHIR::R4B' + ) + r5_codes = generator.selectable_valid_codes( + required_binding(ITEM_TYPE_URI), + 'FHIR::R5' + ) + string_only = generator.selectable_valid_codes( + required_binding( + "#{ITEM_TYPE_URI}|5.0.0", + 'http://hl7.org/fhir/item-type' => ['string'] + ), + 'FHIR::R5' + ) + + assert_include r4b_codes.fetch('http://hl7.org/fhir/item-type'), 'choice' + assert_not_include r4b_codes.fetch('http://hl7.org/fhir/item-type'), 'coding' + assert_include r5_codes.fetch('http://hl7.org/fhir/item-type'), 'coding' + assert_not_include r5_codes.fetch('http://hl7.org/fhir/item-type'), 'choice' + assert_equal( + { 'http://hl7.org/fhir/item-type' => ['string'] }, + string_only + ) + end + + def test_legacy_binding_fallbacks_and_mixed_system_keys_remain_supported + dstu2_languages = generator.selectable_valid_codes( + required_binding(BCP47_URI), + 'FHIR::DSTU2' + ) + mixed_valid_codes = { + nil => [], + 'http://example.test/codes' => ['selectable'] + } + mixed_systems = generator.selectable_valid_codes( + required_binding( + 'http://example.test/ValueSet/mixed-systems', + mixed_valid_codes + ), + 'FHIR::STU3' + ) + + assert_equal( + { 'urn:ietf:bcp:47' => ['en-US'] }, + dstu2_languages + ) + assert_equal mixed_valid_codes, mixed_systems + end + + def test_optional_external_required_binding_without_codes_is_omitted + resource = generator.generate( + FHIR::R5::MolecularSequence::Relative, + 2 + ) + + assert_not_nil resource.startingSequence + assert_nil resource.startingSequence.chromosome + end + + private + + def generator + Crucible::Tests::ResourceGenerator + end + + def clear_generator_code_caches + %i[ + @selectable_expansion_codes_cache + @selectable_valid_codes_cache + ].each do |name| + generator.remove_instance_variable(name) if + generator.instance_variable_defined?(name) + end + end + + def required_binding(uri, valid_codes = nil) + metadata = { + 'type' => 'code', + 'min' => 1, + 'max' => 1, + 'binding' => { + 'strength' => 'required', + 'uri' => uri + } + } + metadata['valid_codes'] = valid_codes if valid_codes + metadata + end +end diff --git a/test/unit/r5_transaction_suite_test.rb b/test/unit/r5_transaction_suite_test.rb new file mode 100644 index 00000000..2effc237 --- /dev/null +++ b/test/unit/r5_transaction_suite_test.rb @@ -0,0 +1,153 @@ +require_relative '../test_helper' +require 'webmock/test_unit' + +class R5TransactionSuiteTest < Test::Unit::TestCase + BASE_URL = 'http://transaction-suite.test/fhir'.freeze + + def setup + @client = FHIR::Client.new(BASE_URL, fhir_version: :r5) + @submitted_bundles = [] + end + + def test_transaction_preserves_r5_request_semantics_and_parses_response_bundle + patient_reference = "urn:uuid:#{SecureRandom.uuid}" + patient = Crucible::Tests::ResourceGenerator.minimal_patient('transaction-r5', 'Transaction', namespace: FHIR::R5) + observation = Crucible::Tests::ResourceGenerator.minimal_observation( + 'http://loinc.org', '8302-2', 170, 'cm', nil, namespace: FHIR::R5 + ) + observation.subject = FHIR::R5::Reference.new(reference: patient_reference) + condition = Crucible::Tests::ResourceGenerator.minimal_condition( + 'http://snomed.info/sct', '414915002', nil, namespace: FHIR::R5, patient_ref: patient_reference + ) + stub_bundle_response(transaction_response_bundle) + + @client.begin_transaction + @client.add_transaction_request('POST', nil, patient).fullUrl = patient_reference + @client.add_transaction_request('POST', nil, observation).fullUrl = "urn:uuid:#{SecureRandom.uuid}" + @client.add_transaction_request('POST', nil, patient, 'identifier=http://projectcrucible.org|transaction-r5') + @client.add_transaction_request('PUT', 'Condition?subject=Patient/patient-r5&code=http://snomed.info/sct|414915002', condition) + @client.add_transaction_request('DELETE', 'Observation/observation-r5') + @client.add_transaction_request('GET', 'Observation?subject=Patient/patient-r5&code=http://loinc.org|8302-2') + reply = @client.end_transaction + + submitted = @submitted_bundles.fetch(0) + assert_equal 'transaction', submitted.type + assert_equal 6, submitted.entry.length + assert_equal patient_reference, submitted.entry[0].fullUrl + assert_equal patient_reference, submitted.entry[1].resource.subject.reference + assert_equal 'POST', submitted.entry[2].request.local_method + assert_equal 'identifier=http://projectcrucible.org|transaction-r5', submitted.entry[2].request.ifNoneExist + assert_equal 'PUT', submitted.entry[3].request.local_method + assert_equal 'Condition?subject=Patient/patient-r5&code=http://snomed.info/sct|414915002', submitted.entry[3].request.url + assert_equal 'DELETE', submitted.entry[4].request.local_method + assert_equal 'GET', submitted.entry[5].request.local_method + assert_equal 'Observation?subject=Patient/patient-r5&code=http://loinc.org|8302-2', submitted.entry[5].request.url + + assert_equal 200, reply.code + assert_instance_of FHIR::R5::Bundle, reply.resource + assert_equal 'transaction-response', reply.resource.type + assert_instance_of FHIR::R5::Patient, reply.resource.entry.first.resource + assert_instance_of FHIR::R5::Bundle, reply.resource.entry.last.resource + assert_r5_model_graph(reply.resource) + end + + def test_batch_preserves_r5_response_bundle_type_and_independent_results + patient = Crucible::Tests::ResourceGenerator.minimal_patient('batch-r5', 'Batch', namespace: FHIR::R5) + stub_bundle_response(batch_response_bundle) + + @client.begin_batch + @client.add_batch_request('POST', nil, patient).fullUrl = "urn:uuid:#{SecureRandom.uuid}" + @client.add_batch_request('GET', 'Patient?identifier=http://projectcrucible.org|batch-r5') + reply = @client.end_batch + + submitted = @submitted_bundles.fetch(0) + assert_equal 'batch', submitted.type + assert_equal %w[POST GET], submitted.entry.map { |entry| entry.request.local_method } + assert_equal 200, reply.code + assert_instance_of FHIR::R5::Bundle, reply.resource + assert_equal 'batch-response', reply.resource.type + assert_equal '201 Created', reply.resource.entry.first.response.status + assert_equal '400 Bad Request', reply.resource.entry.last.response.status + assert_instance_of FHIR::R5::OperationOutcome, reply.resource.entry.last.resource + assert_r5_model_graph(reply.resource) + end + + def test_failed_transaction_parses_an_r5_operation_outcome + stub_request(:post, system_endpoint).to_return( + status: 400, + body: FHIR::R5::OperationOutcome.new(issue: [{ severity: 'error', code: 'processing' }]).to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + ) + + @client.begin_transaction + @client.add_transaction_request('POST', nil, FHIR::R5::Patient.new) + reply = @client.end_transaction + + assert_equal 400, reply.code + assert_instance_of FHIR::R5::OperationOutcome, reply.resource + assert_equal 'processing', reply.resource.issue.first.code + end + + private + + def stub_bundle_response(bundle) + stub_request(:post, system_endpoint).to_return do |request| + @submitted_bundles << FHIR::R5.from_contents(request.body) + { + status: 200, + body: bundle.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + } + end + end + + def transaction_response_bundle + FHIR::R5::Bundle.new( + type: 'transaction-response', + entry: [ + { response: { status: '201 Created', location: 'Patient/patient-r5/_history/1' }, resource: FHIR::R5::Patient.new(id: 'patient-r5') }, + { response: { status: '201 Created', location: 'Observation/observation-r5/_history/1' }, resource: FHIR::R5::Observation.new(id: 'observation-r5') }, + { response: { status: '200 OK', location: 'Patient/patient-r5/_history/1' }, resource: FHIR::R5::Patient.new(id: 'patient-r5') }, + { response: { status: '200 OK', location: 'Condition/condition-r5/_history/1' }, resource: FHIR::R5::Condition.new(id: 'condition-r5') }, + { response: { status: '204 No Content' } }, + { + response: { status: '200 OK' }, + resource: FHIR::R5::Bundle.new(type: 'searchset', total: 0, entry: []) + } + ] + ) + end + + def batch_response_bundle + FHIR::R5::Bundle.new( + type: 'batch-response', + entry: [ + { response: { status: '201 Created', location: 'Patient/batch-r5/_history/1' }, resource: FHIR::R5::Patient.new(id: 'batch-r5') }, + { + response: { status: '400 Bad Request' }, + resource: FHIR::R5::OperationOutcome.new(issue: [{ severity: 'error', code: 'invalid' }]) + } + ] + ) + end + + def system_endpoint + %r{\A#{Regexp.escape(BASE_URL)}/?\z} + end + + def assert_r5_model_graph(value, seen = {}) + return if value.nil? || seen[value.object_id] + + seen[value.object_id] = true + if value.is_a?(FHIR::Model) + assert_match(/\AFHIR::R5(?:::\w+)*\z/, value.class.name) + value.instance_variables.each do |variable| + assert_r5_model_graph(value.instance_variable_get(variable), seen) + end + elsif value.is_a?(Array) + value.each { |entry| assert_r5_model_graph(entry, seen) } + elsif value.is_a?(Hash) + value.each_value { |entry| assert_r5_model_graph(entry, seen) } + end + end +end diff --git a/test/unit/supported_versions_test.rb b/test/unit/supported_versions_test.rb index 7288def1..041dbc03 100644 --- a/test/unit/supported_versions_test.rb +++ b/test/unit/supported_versions_test.rb @@ -1,6 +1,21 @@ require_relative '../test_helper' class SupportedVersionsTest < Test::Unit::TestCase + R4B_CAPABLE_SUITE_CLASSES = %w[ + ConsentSearchByPatientReferenceTest + ElementsSearchParameterTest + FhirPathPatchTest + FormatTest + HistoryTest + ReadTest + ResourceTest + RobustSearchTest + SearchTest + SprinklerSearchTest + TransactionAndBatchTest + UnknownSearchParameterTest + ].freeze + def test_base_suite_does_not_grant_implicit_version_support assert_empty Crucible::Tests::BaseSuite.new(nil).supported_versions end @@ -11,11 +26,13 @@ def test_every_executable_suite_declares_supported_versions assert_true suites.all? { |suite| suite.supported_versions.any? } end - def test_resource_suites_preserve_their_existing_version_support + def test_resource_and_search_suites_advertise_their_audited_versions expected = [:dstu2, :stu3, :r4, :r4b] - assert_equal expected, Crucible::Tests::ResourceTest.new(nil).supported_versions - assert_equal expected, Crucible::Tests::SearchTest.new(nil).supported_versions + assert_equal expected + [:r5], Crucible::Tests::ResourceTest.new(nil).supported_versions + assert_equal expected + [:r5], Crucible::Tests::SearchTest.new(nil).supported_versions + assert_equal expected + [:r5], Crucible::Tests::SprinklerSearchTest.new(nil).supported_versions + assert_equal expected.drop(1) + [:r5], Crucible::Tests::RobustSearchTest.new(nil).supported_versions end def test_every_r4_suite_advertises_r4b @@ -25,4 +42,51 @@ def test_every_r4_suite_advertises_r4b assert_equal r4_suites.map(&:class).sort_by(&:name), r4b_suites.map(&:class).sort_by(&:name) end + + def test_r5_compatibility_inventory_matches_the_complete_r4b_suite_set + r4b_suite_classes = Crucible::Tests::SuiteEngine.new.tests + .select { |suite| suite.supported_versions.include?(:r4b) } + .map { |suite| suite.class.name.demodulize } + .sort + + assert_equal R4B_CAPABLE_SUITE_CLASSES, r4b_suite_classes + end + + def test_only_audited_suites_are_enabled_for_r5 + suites = Crucible::Tests::SuiteEngine.new.tests + r5_suite_classes = suites.select { |suite| suite.supported_versions.include?(:r5) } + .map { |suite| suite.class.name.demodulize } + .sort + + assert_equal %w[ConsentSearchByPatientReferenceTest ElementsSearchParameterTest FhirPathPatchTest FormatTest HistoryTest ReadTest ResourceTest RobustSearchTest SearchTest SprinklerSearchTest TransactionAndBatchTest UnknownSearchParameterTest], r5_suite_classes + end + + def test_r5_listing_and_execution_eligibility_match_the_audited_suites + suites = Crucible::Tests::SuiteEngine.new.tests + r5_executable_suites = suites.select { |suite| suite.supported_versions.include?(:r5) } + .map(&:title) + .sort + r5_listed_tests = Crucible::Tests::SuiteEngine.list_all.select do |_name, metadata| + metadata.fetch('supported_versions', []).include?(:r5) + end.keys + r5_listed_suite_classes = r5_listed_tests.map do |name| + if name.start_with?('ResourceTest') + 'ResourceTest' + elsif name.start_with?('SearchTest') + 'SearchTest' + else + name + end + end.uniq.sort + + assert_equal %w[ConsentSearchByPatientReferenceTest ElementsSearchParameterTest FhirPathPatchTest FormatTest HistoryTest ReadTest ResourceTest RobustSearchTest SearchTest SprinklerSearchTest TransactionAndBatchTest UnknownSearchParameterTest], r5_executable_suites + assert_equal r5_executable_suites, r5_listed_suite_classes + end + + def test_testscripts_remain_explicitly_stu3_only + testscripts = Crucible::Tests::TestScriptEngine.new.tests + + assert_not_empty testscripts + assert_true testscripts.all? { |testscript| testscript.supported_versions == [:stu3] } + end end diff --git a/test/unit/task_routing_test.rb b/test/unit/task_routing_test.rb new file mode 100644 index 00000000..39520be8 --- /dev/null +++ b/test/unit/task_routing_test.rb @@ -0,0 +1,214 @@ +require_relative '../test_helper' +require 'rake' +require 'stringio' +require 'tmpdir' + +unless Rake::Task.task_defined?('crucible:execute') + load File.expand_path('../../lib/tasks/tasks.rake', __dir__) +end + +class TaskRoutingTest < Test::Unit::TestCase + R5_SUITE_TITLES = %w[ + ConsentSearchByPatientReferenceTest + ElementsSearchParameterTest + FhirPathPatchTest + FormatTest + HistoryTest + ReadTest + ResourceTest + RobustSearchTest + SearchTest + SprinklerSearchTest + TransactionAndBatchTest + UnknownSearchParameterTest + ].freeze + + TASK_ARGUMENTS = { + 'crucible:execute' => [:url, :fhir_version, :test, :resource, :output], + 'crucible:execute_all' => [:url, :fhir_version, :output], + 'crucible:execute_custom' => [:test, :fhir_version, :resource_type, :output], + 'crucible:execute_all_custom' => [:fhir_version, :output], + 'crucible:list_all' => [:fhir_version], + 'crucible:list_suites' => [:fhir_version], + 'crucible:metadata' => [:test, :fhir_version], + 'crucible:execute_w_requirements' => [:url, :fhir_version, :test, :resource, :html_summary] + }.freeze + + def test_r5_resolves_and_constructs_an_r5_client + assert_equal :r5, resolve_fhir_version('R5') + + client = build_fhir_client('http://r5.example', 'r5') + + assert_instance_of FHIR::Client, client + assert_equal :r5, client.fhir_version + end + + def test_versioned_tasks_expose_explicit_fhir_version_arguments + TASK_ARGUMENTS.each do |task_name, arguments| + assert_equal arguments, Rake::Task[task_name].arg_names + end + end + + def test_r5_task_clients_construct_and_audited_suites_are_eligible + client = build_fhir_client('http://r5.example', 'r5') + consent_test = Crucible::Tests::Executor.new(client).find_test('ConsentSearchByPatientReferenceTest') + elements_test = Crucible::Tests::Executor.new(client).find_test('ElementsSearchParameterTest') + patch_test = Crucible::Tests::Executor.new(client).find_test('FhirPathPatchTest') + resource_test = Crucible::Tests::Executor.new(client).find_test('ResourceTest') + robust_search_test = Crucible::Tests::Executor.new(client).find_test('RobustSearchTest') + search_test = Crucible::Tests::Executor.new(client).find_test('SearchTest') + sprinkler_search_test = Crucible::Tests::Executor.new(client).find_test('SprinklerSearchTest') + transaction_test = Crucible::Tests::Executor.new(client).find_test('TransactionAndBatchTest') + unknown_search_parameter_test = Crucible::Tests::Executor.new(client).find_test('UnknownSearchParameterTest') + + assert_equal :r5, client.fhir_version + assert_true eligible_for_fhir_version?(consent_test, :r5) + assert_true eligible_for_fhir_version?(elements_test, :r5) + assert_true eligible_for_fhir_version?(patch_test, :r5) + assert_true eligible_for_fhir_version?(resource_test, :r5) + assert_true eligible_for_fhir_version?(robust_search_test, :r5) + assert_true eligible_for_fhir_version?(search_test, :r5) + assert_true eligible_for_fhir_version?(sprinkler_search_test, :r5) + assert_true eligible_for_fhir_version?(transaction_test, :r5) + assert_true eligible_for_fhir_version?(unknown_search_parameter_test, :r5) + end + + def test_r5_custom_execution_rejects_a_non_r5_suite + execute_output = capture_stdout do + invoke_task('crucible:execute_custom', 'ConnectathonPatientTrackTest', 'r5') + end + + assert_match(/does not support fhir version r5/, execute_output) + assert_match(/Execute Custom ConnectathonPatientTrackTest completed/, execute_output) + end + + def test_unknown_and_omitted_task_versions_fail_before_client_construction + omitted = nil + unknown = nil + + Dir.mktmpdir do |directory| + Dir.chdir(directory) do + omitted = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + invoke_task('crucible:execute', 'http://r5.example') + end + unknown = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + invoke_task('crucible:execute', 'http://r5.example', 'r6', 'ResourceTest') + end + end + end + + omitted_listing = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + invoke_task('crucible:list_all') + end + + assert_match(/FHIR version is required/, omitted.message) + assert_match(/Unsupported FHIR version 'r6'/, unknown.message) + assert_match(/FHIR version is required/, omitted_listing.message) + end + + def test_r5_eligibility_is_limited_to_audited_suites + suites = Crucible::Tests::SuiteEngine.new.tests + listed_tests = Crucible::Tests::Executor.list_all + + executable_suites = suites.select { |suite| eligible_for_fhir_version?(suite, :r5) } + .map(&:title) + .sort + listed_suites = listed_tests.select do |_name, test| + eligible_for_fhir_version?(test, :r5) + end.keys.map do |name| + if name.start_with?('ResourceTest') + 'ResourceTest' + elsif name.start_with?('SearchTest') + 'SearchTest' + else + name + end + end.uniq.sort + + assert_equal %w[ConsentSearchByPatientReferenceTest ElementsSearchParameterTest FhirPathPatchTest FormatTest HistoryTest ReadTest ResourceTest RobustSearchTest SearchTest SprinklerSearchTest TransactionAndBatchTest UnknownSearchParameterTest], executable_suites + assert_equal executable_suites, listed_suites + end + + def test_r5_listing_includes_audited_suites_and_excludes_non_r5_suites + listing_output = capture_stdout do + invoke_task('crucible:list_all', 'r5') + end + suite_listing_output = capture_stdout do + invoke_task('crucible:list_suites', 'r5') + end + assert_match(/ResourceTest/, listing_output) + assert_match(/ResourceTest/, suite_listing_output) + assert_match(/FormatTest/, listing_output) + assert_match(/FormatTest/, suite_listing_output) + assert_match(/FhirPathPatchTest/, listing_output) + assert_match(/FhirPathPatchTest/, suite_listing_output) + assert_match(/TransactionAndBatchTest/, listing_output) + assert_match(/TransactionAndBatchTest/, suite_listing_output) + assert_match(/RobustSearchTest/, listing_output) + assert_match(/RobustSearchTest/, suite_listing_output) + assert_match(/SearchTest/, listing_output) + assert_match(/SearchTest/, suite_listing_output) + assert_match(/SprinklerSearchTest/, listing_output) + assert_match(/SprinklerSearchTest/, suite_listing_output) + assert_match(/ConsentSearchByPatientReferenceTest/, listing_output) + assert_match(/ConsentSearchByPatientReferenceTest/, suite_listing_output) + assert_match(/ElementsSearchParameterTest/, listing_output) + assert_match(/ElementsSearchParameterTest/, suite_listing_output) + assert_match(/UnknownSearchParameterTest/, listing_output) + assert_match(/UnknownSearchParameterTest/, suite_listing_output) + assert_no_match(/ConnectathonPatientTrackTest/, listing_output) + assert_no_match(/ConnectathonPatientTrackTest/, suite_listing_output) + end + + def test_r5_metadata_task_rejects_a_non_r5_suite + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + invoke_task('crucible:metadata', 'ConnectathonPatientTrackTest', 'r5') + end + + assert_match(/Test ConnectathonPatientTrackTest does not support fhir version r5/, error.message) + end + + def test_r5_metadata_task_accepts_every_audited_suite + R5_SUITE_TITLES.each do |suite_title| + assert_nothing_raised("#{suite_title} metadata should support R5") do + capture_stdout do + invoke_task('crucible:metadata', suite_title, 'r5') + end + end + end + end + + def test_testscript_tasks_remain_stu3_only + error = nil + + Dir.mktmpdir do |directory| + Dir.chdir(directory) do + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + invoke_task('crucible:execute_all_testscripts', 'http://r5.example', 'r5') + end + end + end + + assert_equal 'FHIR TestScripts require STU3, got r5', error.message + end + + private + + def invoke_task(name, *arguments) + task = Rake::Task[name] + task.reenable + task.invoke(*arguments) + ensure + task&.reenable + end + + def capture_stdout + original_stdout = $stdout + output = StringIO.new + $stdout = output + yield + output.string + ensure + $stdout = original_stdout + end +end diff --git a/test/unit/testscript_version_test.rb b/test/unit/testscript_version_test.rb new file mode 100644 index 00000000..c7be8fda --- /dev/null +++ b/test/unit/testscript_version_test.rb @@ -0,0 +1,20 @@ +require_relative '../test_helper' +require 'rake' + +unless Rake::Task.task_defined?('crucible:execute') + load File.expand_path('../../lib/tasks/tasks.rake', __dir__) +end + +class TestScriptVersionTest < Test::Unit::TestCase + def test_stu3_testscript_execution_remains_supported + assert_equal :stu3, resolve_testscript_fhir_version(:stu3) + end + + def test_r5_testscript_execution_is_rejected + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + resolve_testscript_fhir_version(:r5) + end + + assert_equal 'FHIR TestScripts require STU3, got r5', error.message + end +end