diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 44838b1a6..d1ed6ca8c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,8 +80,8 @@ flowchart LR | `temporal_expressions.py` | Pure Korean relative-time resolver for Global Ask (ADR 0150) | | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | | `ontology.py` | Loads the governed Turtle source tree (`lineageweave-kg.ttl` plus generated fragments), the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types, source taxonomies, and published O*NET linkages (ADR 0004, ADR 0252, ADR 0255, ADR 0256) | -| `backend/app/occupation_rating_ingestion.py` | Projects authenticated, bounded occupation-rating evidence and the persisted selectable-source catalog (ADR 0258, ADR 0260) | -| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source and reads exact occupation evidence in the existing Dashboard while preserving absence, uncertainty, and warning semantics (ADR 0259, ADR 0260) | +| `backend/app/occupation_rating_ingestion.py` | Projects authenticated, bounded occupation-rating evidence, the persisted selectable-source catalog, and occupations that have observations in a selected source (ADR 0258, ADR 0260, ADR 0261) | +| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source and published occupation title, then reads exact occupation evidence in the existing Dashboard while preserving absence, uncertainty, and warning semantics (ADR 0259, ADR 0260, ADR 0261) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET | | `period_report.py` | Fit GRM/GPCM on persisted IRT rows, FIPC-select, EAP-score a period (ADR 0003 slice 3; Bock & Mislevy, 1982) | diff --git a/CHANGELOG.d/2.21.0-occupation-catalog.md b/CHANGELOG.d/2.21.0-occupation-catalog.md new file mode 100644 index 000000000..80525974a --- /dev/null +++ b/CHANGELOG.d/2.21.0-occupation-catalog.md @@ -0,0 +1,6 @@ +### Added + +- Occupation evidence now selects a published occupation title from occupations + that actually have observations in the chosen imported source, with fail-closed + empty/unavailable catalog states and the retained title on the opened profile + (ADR 0261). diff --git a/CHANGELOG.md b/CHANGELOG.md index f99d034da..1ac40d85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows ### Added +- Occupation evidence now selects a published occupation title from occupations + that actually have observations in the chosen imported source, with fail-closed + empty/unavailable catalog states and the retained title on the opened profile + (ADR 0261). - Occupation evidence source selection now comes from an authenticated catalog of actually imported rating artifacts, with release, publisher, license, digest, URL, and row-count provenance and fail-closed loading/empty/error diff --git a/backend/app/main.py b/backend/app/main.py index ee7d32133..63abca230 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -99,6 +99,7 @@ ) from backend.app.operations_dashboard import fetch_operations_dashboard from backend.app.occupation_rating_ingestion import ( + fetch_occupation_rating_occupations, fetch_occupation_rating_sources, fetch_occupation_ratings, ) @@ -2315,6 +2316,28 @@ async def read_occupation_rating_sources( return await fetch_occupation_rating_sources(conn) +@app.get( + "/api/occupation-rating-sources/{data_release_code}/{source_table_code}/occupations" +) +async def read_occupation_rating_occupations( + data_release_code: str = Path( + ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" + ), + source_table_code: str = Path( + ..., min_length=1, max_length=63, pattern=r"^[a-z][a-z0-9_]*$" + ), + _account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, object]: + """Return occupations that have observations in one imported rating source.""" + async with pool.acquire() as conn: + return await fetch_occupation_rating_occupations( + conn, + data_release_code=data_release_code, + source_table_code=source_table_code, + ) + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/occupation_rating_ingestion.py b/backend/app/occupation_rating_ingestion.py index 2324359f7..434ac54d4 100644 --- a/backend/app/occupation_rating_ingestion.py +++ b/backend/app/occupation_rating_ingestion.py @@ -5,6 +5,8 @@ from decimal import Decimal from typing import Any, Protocol +OCCUPATION_CATALOG_BOUND = 2000 + class RatingReadConnection(Protocol): """Small asyncpg-compatible surface used by the rating read projection.""" @@ -21,6 +23,14 @@ def _decimal_text(value: Decimal | None) -> str | None: return str(value) if value is not None else None +def _optional_mapping_value(row: Any, key: str) -> Any: + """Return a mapping value when the projection supplied the column.""" + try: + return row[key] + except (KeyError, IndexError): + return None + + async def fetch_occupation_ratings( conn: RatingReadConnection, *, @@ -38,21 +48,27 @@ async def fetch_occupation_ratings( rating_source.source_row_count, scale_source.source_artifact_url as scale_artifact_url, scale_source.source_artifact_sha256 as scale_artifact_sha256, - scale_source.source_row_count as scale_source_row_count + scale_source.source_row_count as scale_source_row_count, + occupation.occupation_title from occupational_source_table rating_source left join occupational_source_table scale_source on scale_source.data_release_code = rating_source.data_release_code and scale_source.source_table_code = 'scales_reference' + left join occupational_classification_entry occupation + on occupation.data_release_code = rating_source.data_release_code + and occupation.onetsoc_code = $3 where rating_source.data_release_code = $1 and rating_source.source_table_code = $2""", data_release_code, source_table_code, + onetsoc_code, ) if source is None: return { "data_release_code": data_release_code, "source_table_code": source_table_code, "onetsoc_code": onetsoc_code, + "occupation_title": None, "source_available": False, "source": None, "items": [], @@ -112,6 +128,7 @@ async def fetch_occupation_ratings( "data_release_code": data_release_code, "source_table_code": source_table_code, "onetsoc_code": onetsoc_code, + "occupation_title": _optional_mapping_value(source, "occupation_title"), "source_available": True, "source": { "source_table_name": source["source_table_name"], @@ -151,3 +168,57 @@ async def fetch_occupation_rating_sources( source.source_table_name, source.source_table_code""" ) return {"sources": [dict(row) for row in rows]} + + +async def fetch_occupation_rating_occupations( + conn: RatingReadConnection, + *, + data_release_code: str, + source_table_code: str, +) -> dict[str, object]: + """Return occupations that have observations in one imported rating source.""" + source = await conn.fetchrow( + """select source_table_code + from occupational_source_table + where data_release_code = $1 + and source_table_code = $2 + and source_table_code <> 'scales_reference'""", + data_release_code, + source_table_code, + ) + if source is None: + return { + "data_release_code": data_release_code, + "source_table_code": source_table_code, + "source_available": False, + "occupations": [], + } + rows = await conn.fetch( + """select occupation.onetsoc_code, occupation.occupation_title + from occupational_classification_entry occupation + where occupation.data_release_code = $1 + and exists ( + select 1 + from occupational_rating_observation observation + where observation.data_release_code = occupation.data_release_code + and observation.source_table_code = $2 + and observation.onetsoc_code = occupation.onetsoc_code + ) + order by occupation.occupation_title, occupation.onetsoc_code + limit $3""", + data_release_code, + source_table_code, + OCCUPATION_CATALOG_BOUND, + ) + return { + "data_release_code": data_release_code, + "source_table_code": source_table_code, + "source_available": True, + "occupations": [ + { + "onetsoc_code": row["onetsoc_code"], + "occupation_title": row["occupation_title"], + } + for row in rows + ], + } diff --git a/docs/adr/0261-occupation-rating-occupation-catalog.md b/docs/adr/0261-occupation-rating-occupation-catalog.md new file mode 100644 index 000000000..2b67c4d4b --- /dev/null +++ b/docs/adr/0261-occupation-rating-occupation-catalog.md @@ -0,0 +1,46 @@ +# ADR 0261: Imported occupation catalog for rating evidence + +- Status: Accepted +- Date: 2026-08-27 +- Extends: ADR 0257, ADR 0258, ADR 0259, ADR 0260 + +## Context + +ADR 0260 removed typed release and source-table codes, but occupation +selection still requires an exact O*NET-SOC code. That makes a valid product +action depend on repository knowledge and allows a user to request an +occupation that has no observation in the selected source. The normalized +store already owns `occupational_classification_entry` titles and the +observation identities that prove a source actually describes an occupation. + +## Decision + +1. Add an authenticated read endpoint that lists occupations with at least + one persisted observation in one exact imported rating source. Return the + official O*NET-SOC code and occupation title. Exclude occupations that + exist only as classification rows, and treat the Scales Reference support + artifact as unavailable. +2. Order by published title then code. Bound the catalog; do not rank, + recommend, or infer similarity. The catalog describes current database + state, not the complete official O*NET occupation list. +3. Distinguish an unavailable source from an available source with no + selectable occupation. Authentication matches ADR 0258. +4. The Dashboard occupation control is a native select populated only from + this catalog for the currently selected source. Changing the source + reloads and resets the occupation. If the occupation catalog is loading, + empty, or unavailable, disable profile submission and give the next + action. Do not retain a typed SOC fallback. +5. Display the published occupation title with the retained code on the + opened profile. Derive no ranking or recommendation. + +## Consequences + +Users select a published occupation that the chosen source actually +describes. An unavailable or empty occupation catalog cannot masquerade as a +typed code. Adding an official occupation remains an importer operation with +digest and row-count validation rather than a UI-created catalog row. + +## References + +National Center for O*NET Development. (2026). *O*NET 31.0 database* [Data +set]. https://www.onetcenter.org/database.html diff --git a/docs/adr/README.md b/docs/adr/README.md index 7b6dd1aa5..c31390658 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,6 +37,7 @@ decision from them. | Occupation-rating authenticated read projection | [0258](0258-occupation-rating-read-api.md) | | Occupation-rating Dashboard evidence view | [0259](0259-occupation-rating-evidence-ui.md) | | Imported occupation-rating source catalog | [0260](0260-occupation-rating-source-catalog.md) | +| Imported occupation catalog for rating evidence | [0261](0261-occupation-rating-occupation-catalog.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 49907c9ce..61d8b4f02 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -163,8 +163,9 @@ suppressed observation retains its value and warning flag together. ### PRD-FR-2F — Occupation-rating evidence view -- Let an authenticated user submit an exact O*NET-SOC code, release, and source - from the existing Dashboard without changing the governed GNB (ADR 0259). +- Let an authenticated user submit an imported source and a published + occupation from the existing Dashboard without changing the governed GNB + (ADR 0259, ADR 0260, ADR 0261). - Display published values beside bounds, sample/error/interval evidence, source time, and text warnings; link both source artifacts. - Give different next actions for unavailable source, empty occupation, @@ -188,6 +189,23 @@ order follows persisted import time rather than parsed version heuristics; and the real PostgreSQL integration test proves an imported synthetic artifact is listed while its supporting scale artifact is not. +### PRD-FR-2H — Imported occupation catalog + +- Populate the occupation selector only from occupations that have + observations in the currently selected imported source, showing the + published title with the retained O*NET-SOC code (ADR 0261). +- Reset the occupation when the source changes. +- Disable profile submission and state the next action while the occupation + catalog is loading, empty, or unavailable; keep unavailable sources + distinct from an empty occupation list. +- Display the published title on the opened profile without deriving a + ranking or recommendation. + +Acceptance: a user never types an O*NET-SOC code; occupation order follows +published title then code; and the real PostgreSQL integration test proves an +imported synthetic occupation is listed for its rating source while a +classification-only occupation is not. + ### PRD-FR-3 — Bounded ontology exploration - Apply RBAC/ABAC, source eligibility, and knowledge cutoff before graph diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 57787ac73..aaa127840 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -29,7 +29,7 @@ explicit unavailable state, not a reason to infer mappings from labels. | Occupation-to-construct relations | ADR 0257 defines a candidate 3NF, release/source-partitioned immutable observation store and deterministic pinned-CSV importer preserving value, optional category, sample/error/CI, suppression, relevance, exact `MM/YYYY` source update month, source digest, and domain provenance. The official O*NET 31.0 Abilities file (94,640 rows, 910 occupations, 52 elements; SHA-256 `7e9cd79791ce6014e1d26d0a449ae5b1e7aa7ef52d39b3934c3bb8d438104b88`) and all 33 Scales Reference rows (SHA-256 `bcba23858ce21ecaacbde303a8993e35d46724b4afb8c9ec2b10e04f42adcfc9`) imported into a throwaway local PostgreSQL database with all 94,640 observations, 55 suppression flags, 7,572 not-relevant flags, and source months from `12/2004` through `08/2026`; every scale retained `scales_reference` artifact provenance, the database was dropped afterward, and no corpus is committed or claimed deployed | Pass exact-head review/checks and protected merge; validate and import every selected official rating artifact through an authorized runtime, returning only aggregate evidence; never invent or locally normalize a weight | | Job-family and job-series semantics | No authoritative employer-specific job architecture is present | Define an organization-neutral import contract that preserves the authorized source hierarchy and distinguishes standard occupation codes from employer job families/series; no label-based binding | | Temporal and multilevel interpretation | Static vocabulary only; no person-level inference is asserted | Version valid and transaction time, preserve occupation/organization/unit nesting and multiple membership, and require TEPP or the owning Rust psychometric service before any calibrated temporal or multilevel result | -| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view with exact value/error/warning semantics; ADR 0260 replaces internal code entry with a candidate authenticated catalog of artifacts that actually contain observations. Component/API/PostgreSQL tests and Storybook build cover the current contract; synthetic populated scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated catalog, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | +| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view with exact value/error/warning semantics; ADR 0260 replaces internal source-code entry with a candidate authenticated catalog of artifacts that actually contain observations; ADR 0261 replaces typed O*NET-SOC entry with a candidate catalog of occupations that have observations in the selected source, showing published titles. Component/API/PostgreSQL tests and Storybook build cover the current contract; synthetic populated scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated source/occupation catalogs, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | ### Current exact-head PR queue diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 2bd6f3a35..961a90679 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,7 +6,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | -| `Ontology/OccupationRatingProfile` | Select an imported release/source, enter an exact O*NET-SOC code, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `SourceUnavailable`, and `EmptyOccupation` cover the catalog-backed form, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | +| `Ontology/OccupationRatingProfile` | Select an imported release/source, then a published occupation that has observations in that source, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `OccupationCatalogEmpty`, `OccupationCatalogUnavailable`, `SourceUnavailable`, and `EmptyOccupation` cover the catalog-backed form, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 692bdf10e..7ddf1f081 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1448,7 +1448,7 @@ .occupation-rating-form { display: grid; - grid-template-columns: minmax(14rem, 1fr) minmax(16rem, 1fr) auto; + grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.4fr) auto; align-items: end; gap: var(--space-control-gap); margin: 1rem 0; diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index bd1a0efc5..a43e8893b 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BackendError, fetchMe, + fetchOccupationRatingOccupations, fetchOccupationRatingSources, fetchOccupationRatings, fetchOperationsDashboard, @@ -58,6 +59,24 @@ describe("backendFetch provider-error boundary", () => { expect(fetchMock.mock.calls[0][0]).toContain("/api/occupation-rating-sources"); }); + it("reads occupations that have observations in one imported source", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ source_available: true, occupations: [] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchOccupationRatingOccupations("access-token", { + dataReleaseCode: "onet-31.0", + sourceTableCode: "abilities", + }); + + expect(fetchMock.mock.calls[0][0]).toContain( + "/api/occupation-rating-sources/onet-31.0/abilities/occupations", + ); + }); + it("does not expose provider details from server failures", async () => { vi.stubGlobal( "fetch", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3c60a59bb..205cb72e1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -983,6 +983,7 @@ export interface OccupationRatingProfile { data_release_code: string; source_table_code: string; onetsoc_code: string; + occupation_title: string | null; source_available: boolean; source: { source_table_name: string; @@ -1009,12 +1010,32 @@ export interface OccupationRatingSource { source_row_count: number; } +export interface OccupationRatingOccupation { + onetsoc_code: string; + occupation_title: string; +} + export function fetchOccupationRatingSources( accessToken: string, ): Promise<{ sources: OccupationRatingSource[] }> { return backendFetch("/api/occupation-rating-sources", accessToken); } +export function fetchOccupationRatingOccupations( + accessToken: string, + query: { dataReleaseCode: string; sourceTableCode: string }, +): Promise<{ + data_release_code: string; + source_table_code: string; + source_available: boolean; + occupations: OccupationRatingOccupation[]; +}> { + return backendFetch( + `/api/occupation-rating-sources/${encodeURIComponent(query.dataReleaseCode)}/${encodeURIComponent(query.sourceTableCode)}/occupations`, + accessToken, + ); +} + export function fetchOccupationRatings( accessToken: string, query: { diff --git a/frontend/src/components/OccupationRatingProfile.stories.tsx b/frontend/src/components/OccupationRatingProfile.stories.tsx index e78e7a39e..50d56b91a 100644 --- a/frontend/src/components/OccupationRatingProfile.stories.tsx +++ b/frontend/src/components/OccupationRatingProfile.stories.tsx @@ -4,7 +4,8 @@ import { OccupationRatingProfile, OccupationRatingProfileView } from "./Occupati import "../App.css"; const ready = { - data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", source_available: true, + data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", + occupation_title: "Synthetic occupation", source_available: true, source: { source_table_name: "Abilities", source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), source_row_count: 94640, scale_artifact_url: "https://example.test/scales.csv", scale_artifact_sha256: "b".repeat(64), scale_source_row_count: 33 }, items: [ { element_id: "1.A.1.a.1", element_name: "Oral Comprehension", scale_id: "IM", scale_name: "Importance", minimum_value: "1.00", maximum_value: "5.00", category_value: null, data_value: "4.10", sample_size: 120, standard_error: "0.0800", lower_ci_bound: "3.9432", upper_ci_bound: "4.2568", recommend_suppress: true, not_relevant: null, source_updated_month: "08/2026", domain_source_code: "Analyst" }, @@ -13,6 +14,46 @@ const ready = { next_offset: null, }; +const importedSource = { + data_release_code: "onet-31.0", release_version: "31.0", + source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", + source_table_code: "abilities", source_table_name: "Abilities", + source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), + source_row_count: 94640, +}; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); +} + +function mockOccupationCatalog({ + sources = [importedSource], + occupations = [{ onetsoc_code: "15-1252.00", occupation_title: "Synthetic occupation" }], + sourceAvailable = true, +}: { + sources?: typeof importedSource[]; + occupations?: { onetsoc_code: string; occupation_title: string }[]; + sourceAvailable?: boolean; +} = {}) { + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/occupations") && url.includes("occupation-rating-sources")) { + return jsonResponse({ + data_release_code: "onet-31.0", + source_table_code: "abilities", + source_available: sourceAvailable, + occupations, + }); + } + if (url.includes("occupation-rating-sources")) { + return jsonResponse({ sources }); + } + return jsonResponse(ready); + }; + return () => { globalThis.fetch = previousFetch; }; +} + const meta = { title: "Ontology/OccupationRatingProfile", component: OccupationRatingProfileView, parameters: { layout: "fullscreen" }, args: { profile: ready } } satisfies Meta; export default meta; type Story = StoryObj; @@ -20,6 +61,7 @@ type Story = StoryObj; export const EvidenceReady: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(canvas.getByText("Synthetic occupation")).toBeVisible(); await expect(canvas.getByText("4.10")).toBeVisible(); await expect(canvas.getByText(/정밀도가 낮아/)).toBeVisible(); }, @@ -27,24 +69,10 @@ export const EvidenceReady: Story = { export const InteractiveEvidenceReady: Story = { render: () => , - beforeEach: () => { - const previousFetch = globalThis.fetch; - globalThis.fetch = async (input) => new Response(JSON.stringify( - String(input).includes("occupation-rating-sources") - ? { sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 94640, - }] } - : ready, - ), { headers: { "Content-Type": "application/json" } }); - return () => { globalThis.fetch = previousFetch; }; - }, + beforeEach: () => mockOccupationCatalog(), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByLabelText("O*NET-SOC 직업 코드"), "15-1252.00"); + await canvas.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); await userEvent.click(canvas.getByRole("button", { name: "직업 근거 열기" })); await expect(canvas.findByText("4.10")).resolves.toBeVisible(); }, @@ -56,13 +84,7 @@ export const NarrowViewport: Story = { }; export const CatalogEmpty: Story = { render: () => , - beforeEach: () => { - const previousFetch = globalThis.fetch; - globalThis.fetch = async () => new Response(JSON.stringify({ sources: [] }), { - headers: { "Content-Type": "application/json" }, - }); - return () => { globalThis.fetch = previousFetch; }; - }, + beforeEach: () => mockOccupationCatalog({ sources: [] }), play: async ({ canvasElement }) => { await expect(within(canvasElement).findByText(/가져온 직업 근거 표가 없습니다/)).resolves.toBeVisible(); }, @@ -78,5 +100,32 @@ export const CatalogUnavailable: Story = { await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("잠시 후 다시 열어 보세요"); }, }; +export const OccupationCatalogEmpty: Story = { + render: () => , + beforeEach: () => mockOccupationCatalog({ occupations: [] }), + play: async ({ canvasElement }) => { + await expect(within(canvasElement).findByText(/이 근거 표에서 확인할 수 있는 직업이 없습니다/)).resolves.toBeVisible(); + }, +}; +export const OccupationCatalogUnavailable: Story = { + render: () => , + beforeEach: () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/occupations") && url.includes("occupation-rating-sources")) { + throw new Error("synthetic occupation catalog failure"); + } + if (url.includes("occupation-rating-sources")) { + return jsonResponse({ sources: [importedSource] }); + } + return jsonResponse(ready); + }; + return () => { globalThis.fetch = previousFetch; }; + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("사용 가능한 직업을 확인하지 못했습니다"); + }, +}; export const SourceUnavailable: Story = { args: { profile: { ...ready, source_available: false, source: null, items: [] } } }; export const EmptyOccupation: Story = { args: { profile: { ...ready, items: [] } } }; diff --git a/frontend/src/components/OccupationRatingProfile.test.tsx b/frontend/src/components/OccupationRatingProfile.test.tsx index c2c9284aa..166900524 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + fetchOccupationRatingOccupations, fetchOccupationRatingSources, fetchOccupationRatings, type OccupationRatingProfile as Payload, @@ -10,6 +11,7 @@ import { OccupationRatingProfile, OccupationRatingProfileView } from "./Occupati vi.mock("../api", async (importOriginal) => ({ ...(await importOriginal()), + fetchOccupationRatingOccupations: vi.fn(), fetchOccupationRatingSources: vi.fn(), fetchOccupationRatings: vi.fn(), })); @@ -18,6 +20,7 @@ const ready: Payload = { data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", + occupation_title: "Synthetic occupation", source_available: true, source: { source_table_name: "Abilities", @@ -38,26 +41,47 @@ const ready: Payload = { next_offset: null, }; +const importedSource = { + data_release_code: "onet-31.0", release_version: "31.0", + source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", + source_table_code: "abilities", source_table_name: "Abilities", + source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), + source_row_count: 2, +}; + +const observedOccupations = { + data_release_code: "onet-31.0", + source_table_code: "abilities", + source_available: true, + occupations: [ + { onetsoc_code: "11-1011.00", occupation_title: "Synthetic chief occupation" }, + { onetsoc_code: "15-1252.00", occupation_title: "Synthetic occupation" }, + ], +}; + describe("OccupationRatingProfile", () => { - it("submits exact identifiers and renders warnings beside the retained value", async () => { - vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ - sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 2, - }], - }); + beforeEach(() => { + vi.mocked(fetchOccupationRatingOccupations).mockReset(); + vi.mocked(fetchOccupationRatingSources).mockReset(); + vi.mocked(fetchOccupationRatings).mockReset(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + it("submits catalog identifiers and renders warnings beside the retained value", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); vi.mocked(fetchOccupationRatings).mockResolvedValue(ready); render(); expect(await screen.findByRole("option", { name: "31.0 · Abilities" })).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText("O*NET-SOC 직업 코드"), "15-1252.00"); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.selectOptions(screen.getByLabelText("직업"), "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); expect(fetchOccupationRatings).toHaveBeenCalledWith("synthetic-token", { onetsocCode: "15-1252.00", dataReleaseCode: "onet-31.0", sourceTableCode: "abilities", offset: 0, }); - expect(await screen.findByText("4.10")).toBeInTheDocument(); + expect(await screen.findByText("Synthetic occupation")).toBeInTheDocument(); + expect(screen.getByText("4.10")).toBeInTheDocument(); expect(screen.getByText(/정밀도가 낮아/)).toBeInTheDocument(); expect(screen.getByText(/해당 없음 응답이 포함됩니다/)).toBeInTheDocument(); expect(screen.getByText(/표를 가로로 밀어/)).toBeInTheDocument(); @@ -69,29 +93,35 @@ describe("OccupationRatingProfile", () => { expect(await screen.findByText(/가져온 직업 근거 표가 없습니다/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + expect(fetchOccupationRatingOccupations).not.toHaveBeenCalled(); }); - it("keeps pagination bound to the loaded profile after form edits", async () => { - vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ - sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 2, - }], + it("fails closed when the selected source has no observed occupation", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue({ + ...observedOccupations, + occupations: [], }); + render(); + + expect(await screen.findByText(/이 근거 표에서 확인할 수 있는 직업이 없습니다/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + }); + + it("keeps pagination bound to the loaded profile after form edits", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); vi.mocked(fetchOccupationRatings) .mockResolvedValueOnce({ ...ready, next_offset: 100 }) .mockResolvedValueOnce({ ...ready, items: [{ ...ready.items[0], scale_id: "LV" }] }); render(); - const occupation = screen.getByLabelText("O*NET-SOC 직업 코드"); - await userEvent.type(occupation, "15-1252.00"); + const occupation = await screen.findByLabelText("직업"); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.selectOptions(occupation, "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); await screen.findByText("4.10"); - await userEvent.clear(occupation); - await userEvent.type(occupation, "11-1011.00"); + await userEvent.selectOptions(occupation, "11-1011.00"); await userEvent.click(screen.getByRole("button", { name: "다음 관측값 불러오기" })); expect(fetchOccupationRatings).toHaveBeenLastCalledWith("synthetic-token", { @@ -101,26 +131,19 @@ describe("OccupationRatingProfile", () => { }); it("removes stale evidence while a fresh occupation loads", async () => { - vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ - sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 2, - }], - }); + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); vi.mocked(fetchOccupationRatings) .mockResolvedValueOnce(ready) .mockImplementationOnce(() => new Promise(() => undefined)); render(); - const occupation = screen.getByLabelText("O*NET-SOC 직업 코드"); - await userEvent.type(occupation, "15-1252.00"); + const occupation = await screen.findByLabelText("직업"); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.selectOptions(occupation, "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); await screen.findByText("4.10"); - await userEvent.clear(occupation); - await userEvent.type(occupation, "11-1011.00"); + await userEvent.selectOptions(occupation, "11-1011.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); expect(screen.queryByText("4.10")).not.toBeInTheDocument(); diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx index 299d355e1..274a78baa 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -1,13 +1,19 @@ import { useEffect, useState } from "react"; import { + fetchOccupationRatingOccupations, fetchOccupationRatingSources, fetchOccupationRatings, + type OccupationRatingOccupation, type OccupationRatingProfile as OccupationRatingProfilePayload, type OccupationRatingSource, } from "../api"; type Props = { accessToken: string }; +function sourceKey(source: Pick): string { + return `${source.data_release_code}|${source.source_table_code}`; +} + function safeHttpUrl(value: string | null | undefined): string | null { if (!value) return null; try { @@ -20,10 +26,12 @@ function safeHttpUrl(value: string | null | undefined): string | null { /** Lets an authenticated user inspect one exact imported occupation profile. */ export function OccupationRatingProfile({ accessToken }: Props) { - const [onetsocCode, setOnetsocCode] = useState(""); const [sources, setSources] = useState(null); const [selectedSource, setSelectedSource] = useState(""); const [sourceCatalogError, setSourceCatalogError] = useState(false); + const [occupations, setOccupations] = useState(null); + const [selectedOccupation, setSelectedOccupation] = useState(""); + const [occupationCatalogError, setOccupationCatalogError] = useState(false); const [profile, setProfile] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); @@ -33,21 +41,43 @@ export function OccupationRatingProfile({ accessToken }: Props) { .then(({ sources: loaded }) => { if (!active) return; setSources(loaded); - setSelectedSource( - loaded[0] ? `${loaded[0].data_release_code}|${loaded[0].source_table_code}` : "", - ); + setSelectedSource(loaded[0] ? sourceKey(loaded[0]) : ""); }) .catch(() => active && setSourceCatalogError(true)); return () => { active = false; }; }, [accessToken]); + useEffect(() => { + const source = sources?.find((item) => sourceKey(item) === selectedSource); + if (!source) { + setOccupations(null); + setSelectedOccupation(""); + setOccupationCatalogError(false); + return; + } + let active = true; + setOccupations(null); + setSelectedOccupation(""); + setOccupationCatalogError(false); + fetchOccupationRatingOccupations(accessToken, { + dataReleaseCode: source.data_release_code, + sourceTableCode: source.source_table_code, + }) + .then((payload) => { + if (!active) return; + const loaded = payload.source_available ? payload.occupations : []; + setOccupations(loaded); + setSelectedOccupation(loaded[0]?.onetsoc_code ?? ""); + }) + .catch(() => active && setOccupationCatalogError(true)); + return () => { active = false; }; + }, [accessToken, sources, selectedSource]); + function load(offset: number | null = null) { - const source = sources?.find( - (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, - ); - const request = offset == null && source + const source = sources?.find((item) => sourceKey(item) === selectedSource); + const request = offset == null && source && selectedOccupation ? { - onetsocCode, + onetsocCode: selectedOccupation, dataReleaseCode: source.data_release_code, sourceTableCode: source.source_table_code, } @@ -76,12 +106,15 @@ export function OccupationRatingProfile({ accessToken }: Props) { .catch(() => setStatus("error")); } + const occupationCatalogReady = occupations !== null && !occupationCatalogError; + const canSubmit = Boolean(selectedSource) && Boolean(selectedOccupation) && occupationCatalogReady && occupations.length > 0; + return (

공개 직업 근거

직업별 업무 특성 확인

-

직업 코드와 근거 표를 선택해 관측값, 오차, 사용 주의사항을 함께 확인하세요.

+

직업과 근거 표를 선택해 관측값, 오차, 사용 주의사항을 함께 확인하세요.

- -
{sources === null && !sourceCatalogError ?

사용 가능한 근거 표를 확인하는 중입니다.

: null} {sources?.length === 0 ?

가져온 직업 근거 표가 없습니다. 데이터 담당자에게 근거 가져오기를 요청하세요.

: null} {sourceCatalogError ?

사용 가능한 근거 표를 확인하지 못했습니다. 잠시 후 다시 열어 보세요.

: null} + {selectedSource && occupations === null && !occupationCatalogError ? ( +

사용 가능한 직업을 확인하는 중입니다.

+ ) : null} + {occupations?.length === 0 && !occupationCatalogError ? ( +

이 근거 표에서 확인할 수 있는 직업이 없습니다. 다른 근거 표를 선택하거나 데이터 담당자에게 가져오기를 요청하세요.

+ ) : null} + {occupationCatalogError ?

사용 가능한 직업을 확인하지 못했습니다. 잠시 후 다시 열어 보세요.

: null} {status === "error" ? ( -

직업 근거를 불러오지 못했습니다. 코드와 접근 권한을 확인한 뒤 다시 시도하세요.

+

직업 근거를 불러오지 못했습니다. 선택한 직업과 접근 권한을 확인한 뒤 다시 시도하세요.

) : null} {profile ? : null} {profile?.next_offset != null ? ( @@ -154,7 +203,7 @@ export function OccupationRatingProfileView({ if (profile.items.length === 0) { return (

- 이 근거 표에는 선택한 직업의 관측값이 없습니다. 직업 코드나 근거 표를 바꿔 확인하세요. + 이 근거 표에는 선택한 직업의 관측값이 없습니다. 직업이나 근거 표를 바꿔 확인하세요.

); } @@ -163,8 +212,9 @@ export function OccupationRatingProfileView({ return ( <>
- {profile.source?.source_table_name} + {profile.occupation_title ?? profile.onetsoc_code} {profile.data_release_code} · {profile.onetsoc_code} + {profile.source?.source_table_name} {sourceArtifactUrl ? ( 평정 원문 열기 ) : ( diff --git a/tests/test_occupation_rating_ingestion.py b/tests/test_occupation_rating_ingestion.py index c396478f9..18248af51 100644 --- a/tests/test_occupation_rating_ingestion.py +++ b/tests/test_occupation_rating_ingestion.py @@ -3,8 +3,14 @@ import asyncio from decimal import Decimal -from backend.app.main import read_occupation_rating_sources, read_occupation_ratings +from backend.app.main import ( + read_occupation_rating_occupations, + read_occupation_rating_sources, + read_occupation_ratings, +) from backend.app.occupation_rating_ingestion import ( + OCCUPATION_CATALOG_BOUND, + fetch_occupation_rating_occupations, fetch_occupation_rating_sources, fetch_occupation_ratings, ) @@ -18,15 +24,17 @@ def __init__(self, source, rows=()): self.rows = list(rows) self.fetch_called = False self.last_fetch_query = "" + self.last_fetch_args: tuple[object, ...] = () async def fetchrow(self, _query: str, *_args: object): """Return configured source metadata.""" return self.source - async def fetch(self, query: str, *_args: object): + async def fetch(self, query: str, *args: object): """Return configured observation rows.""" self.fetch_called = True self.last_fetch_query = query + self.last_fetch_args = args return self.rows @@ -70,6 +78,7 @@ def test_unimported_source_is_not_an_empty_observed_profile() -> None: ) assert result["source_available"] is False + assert result["occupation_title"] is None assert result["items"] == [] assert conn.fetch_called is False @@ -83,6 +92,7 @@ def test_rating_projection_preserves_exact_decimal_and_warning_flags() -> None: "scale_artifact_url": "https://example.test/scales.csv", "scale_artifact_sha256": "b" * 64, "scale_source_row_count": 33, + "occupation_title": "Synthetic occupation", } row = { "element_id": "1.A.1.a.1", @@ -120,6 +130,7 @@ def test_rating_projection_preserves_exact_decimal_and_warning_flags() -> None: assert item["standard_error"] == "0.1830" assert item["recommend_suppress"] is True assert item["not_relevant"] is None + assert result["occupation_title"] == "Synthetic occupation" assert result["source"]["scale_artifact_sha256"] == "b" * 64 assert result["next_offset"] == 1 @@ -133,6 +144,7 @@ def test_empty_profile_keeps_imported_scale_provenance() -> None: "scale_artifact_url": "https://example.test/scales.csv", "scale_artifact_sha256": "b" * 64, "scale_source_row_count": 33, + "occupation_title": None, } result = asyncio.run( @@ -147,6 +159,7 @@ def test_empty_profile_keeps_imported_scale_provenance() -> None: ) assert result["source_available"] is True + assert result["occupation_title"] is None assert result["items"] == [] assert result["source"]["scale_artifact_sha256"] == "b" * 64 @@ -197,3 +210,67 @@ def test_authenticated_source_catalog_route_uses_shared_projection() -> None: ) assert result == {"sources": []} + + +def test_occupation_catalog_lists_observed_titles_in_title_order() -> None: + occupations = ( + { + "onetsoc_code": "11-1011.00", + "occupation_title": "Synthetic chief occupation", + }, + { + "onetsoc_code": "15-1252.00", + "occupation_title": "Synthetic occupation", + }, + ) + conn = FakeConnection({"source_table_code": "abilities"}, occupations) + + result = asyncio.run( + fetch_occupation_rating_occupations( + conn, + data_release_code="onet-31.0", + source_table_code="abilities", + ) + ) + + assert result == { + "data_release_code": "onet-31.0", + "source_table_code": "abilities", + "source_available": True, + "occupations": list(occupations), + } + assert "order by occupation.occupation_title, occupation.onetsoc_code" in ( + conn.last_fetch_query + ) + assert "and exists" in conn.last_fetch_query + assert conn.last_fetch_args[-1] == OCCUPATION_CATALOG_BOUND + + +def test_occupation_catalog_unavailable_source_is_not_an_empty_list() -> None: + conn = FakeConnection(None) + + result = asyncio.run( + fetch_occupation_rating_occupations( + conn, + data_release_code="onet-31.0", + source_table_code="scales_reference", + ) + ) + + assert result["source_available"] is False + assert result["occupations"] == [] + assert conn.fetch_called is False + + +def test_authenticated_occupation_catalog_route_uses_shared_projection() -> None: + result = asyncio.run( + read_occupation_rating_occupations( + data_release_code="onet-31.0", + source_table_code="abilities", + _account=object(), + pool=FakePool(FakeConnection(None)), + ) + ) + + assert result["source_available"] is False + assert result["occupations"] == [] diff --git a/tests/test_schema.py b/tests/test_schema.py index 6d1b8b05c..2d766438d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -29,6 +29,7 @@ import pytest from backend.app.occupation_rating_ingestion import ( + fetch_occupation_rating_occupations, fetch_occupation_rating_sources, fetch_occupation_ratings, ) @@ -531,7 +532,7 @@ def test_onet_rating_importer_is_idempotent_against_postgresql( ) assert cur.fetchone() == (1, Decimal("4.10"), True) - async def read_imported_profile() -> tuple[dict[str, object], dict[str, object]]: + async def read_imported_profile() -> tuple[dict[str, object], dict[str, object], dict[str, object]]: conn = await asyncpg.connect(args.target_dsn) try: profile = await fetch_occupation_ratings( @@ -543,15 +544,56 @@ async def read_imported_profile() -> tuple[dict[str, object], dict[str, object]] offset=0, ) catalog = await fetch_occupation_rating_sources(conn) - return profile, catalog + occupations = await fetch_occupation_rating_occupations( + conn, + data_release_code=args.release_code, + source_table_code=args.source_table_code, + ) + return profile, catalog, occupations finally: await conn.close() - profile, catalog = asyncio.run(read_imported_profile()) + profile, catalog, occupations = asyncio.run(read_imported_profile()) assert profile["source_available"] is True + assert profile["occupation_title"] == "Synthetic occupation" assert profile["items"][0]["data_value"] == "4.10" assert profile["source"]["scale_artifact_sha256"] == args.scales_sha256 assert catalog["sources"][0]["source_table_code"] == "abilities" + assert occupations["source_available"] is True + assert occupations["occupations"] == [ + { + "onetsoc_code": "15-1252.00", + "occupation_title": "Synthetic occupation", + } + ] + + with schema_db.cursor() as cur: + cur.execute( + """ + insert into occupational_classification_entry + (data_release_code, onetsoc_code, occupation_title) + values (%s, '11-1011.00', 'Synthetic classification-only occupation') + """, + (args.release_code,), + ) + + occupations_after = asyncio.run(read_imported_profile())[2] + assert occupations_after["occupations"] == occupations["occupations"] + + async def read_unavailable_occupation_catalog() -> dict[str, object]: + conn = await asyncpg.connect(args.target_dsn) + try: + return await fetch_occupation_rating_occupations( + conn, + data_release_code=args.release_code, + source_table_code="scales_reference", + ) + finally: + await conn.close() + + unavailable = asyncio.run(read_unavailable_occupation_catalog()) + assert unavailable["source_available"] is False + assert unavailable["occupations"] == [] def test_global_ask_evidence_search_indexes_exist_on_normalized_tables(schema_db) -> None: