From 259c0cbf23e493c4d4dd85a56ac5bfcb93184a72 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 29 Jun 2026 15:53:22 +0300 Subject: [PATCH 1/5] feat: add canonical export-name converter for GeoPackage columns (#211) Adds toExportColumnName / convertKeysToExportColumns and the EXPORT_COLUMN_NAME_OVERRIDES table (change-case snakeCase with a single resolutionDegree -> resolution_deg override). This is the single source of truth for deriving snake_case export column names, to be shared by polygon-parts-worker (Layer Parts) and overseer (Layer Metadata). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 164 ++++++++++++++++++++++++++++++- package.json | 5 +- src/utils/export.utils.ts | 42 ++++++++ src/utils/index.ts | 1 + tests/utils/export.utils.spec.ts | 72 ++++++++++++++ 5 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 src/utils/export.utils.ts create mode 100644 tests/utils/export.utils.spec.ts diff --git a/package-lock.json b/package-lock.json index 77eab08..0d09fd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@map-colonies/mc-priority-queue": "^9.1.0", "@map-colonies/types": "^1.4.0", + "change-case": "^4.1.2", "geojson": "^0.5.0", "zod": "^3.24.1" }, @@ -6909,6 +6910,16 @@ "node": ">=6" } }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -6956,6 +6967,17 @@ } ] }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6972,6 +6994,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", + "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "capital-case": "^1.0.4", + "constant-case": "^3.0.4", + "dot-case": "^3.0.4", + "header-case": "^2.0.4", + "no-case": "^3.0.4", + "param-case": "^3.0.4", + "pascal-case": "^3.1.2", + "path-case": "^3.0.4", + "sentence-case": "^3.0.4", + "snake-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -7210,6 +7252,17 @@ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", "dev": true }, + "node_modules/constant-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", + "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case": "^2.0.2" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -8069,6 +8122,16 @@ "node": ">=6.0.0" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -10094,6 +10157,16 @@ "node": ">= 0.4" } }, + "node_modules/header-case": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", + "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", + "license": "MIT", + "dependencies": { + "capital-case": "^1.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -11778,6 +11851,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -12277,6 +12359,16 @@ "node": ">=v0.2.0" } }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -12591,6 +12683,16 @@ "node": ">=6" } }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12631,12 +12733,32 @@ "node": ">= 0.8" } }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "license": "MIT" }, + "node_modules/path-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", + "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13602,6 +13724,17 @@ "license": "MIT", "peer": true }, + "node_modules/sentence-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", + "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -13778,6 +13911,16 @@ "node": ">=8" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14567,8 +14710,7 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -14884,6 +15026,24 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", + "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index a42437a..539a18b 100644 --- a/package.json +++ b/package.json @@ -63,15 +63,16 @@ "jest-html-reporters": "^3.1.4", "prettier": "^3.4.2", "pretty-quick": "^4.0.0", + "standard-version": "^9.5.0", "ts-jest": "^29.0.5", "tsc-alias": "^1.8.10", "typedoc": "^0.27.6", - "typescript": "^5.7.2", - "standard-version": "^9.5.0" + "typescript": "^5.7.2" }, "dependencies": { "@map-colonies/mc-priority-queue": "^9.1.0", "@map-colonies/types": "^1.4.0", + "change-case": "^4.1.2", "geojson": "^0.5.0", "zod": "^3.24.1" } diff --git a/src/utils/export.utils.ts b/src/utils/export.utils.ts new file mode 100644 index 0000000..fa593fd --- /dev/null +++ b/src/utils/export.utils.ts @@ -0,0 +1,42 @@ +import { snakeCase } from 'change-case'; + +/** + * Canonical naming exceptions for export (GeoPackage) column names. + * + * The general rule is `change-case`'s {@link snakeCase}. Entries here override that rule for fields + * whose required export column name differs from the plain snake_case form. This object is the single + * place such exceptions are declared. + * + * Current entries: + * - `resolutionDegree -> resolution_deg`: the per-part resolution field. `snakeCase` yields + * `resolution_degree`, but the product spec requires the abbreviated `resolution_deg` + * (matching the already-abbreviated aggregate fields `maxResolutionDeg`/`minResolutionDeg`). + */ +export const EXPORT_COLUMN_NAME_OVERRIDES: Record = { + resolutionDegree: 'resolution_deg', +}; + +/** + * Maps a single camelCase property name to its export (GeoPackage) column name. + * + * Consults {@link EXPORT_COLUMN_NAME_OVERRIDES} first, then falls back to `change-case`'s snake_case. + * This is the canonical export-column naming and is NOT a generic snake_case utility — it carries + * the override table above. + */ +export const toExportColumnName = (propertyName: string): string => { + return EXPORT_COLUMN_NAME_OVERRIDES[propertyName] ?? snakeCase(propertyName); +}; + +/** + * Returns a new object whose keys are converted to their export column names via {@link toExportColumnName}. + * Values (including `null`, used for fixed-schema columns) are preserved as-is. + */ +export const convertKeysToExportColumns = (obj: Record): Record => { + // Null-prototype accumulator so a converted key such as `__proto__` becomes a plain own property + // rather than mutating the prototype chain (this helper is exported for reuse with arbitrary keys). + const result = Object.create(null) as Record; + for (const [key, value] of Object.entries(obj)) { + result[toExportColumnName(key)] = value; + } + return result; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 9ebfc10..aba266a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './export.utils'; export * from './geo.utils'; export * from './helpers.utils'; export * from './layer.utils'; diff --git a/tests/utils/export.utils.spec.ts b/tests/utils/export.utils.spec.ts new file mode 100644 index 0000000..0860c3a --- /dev/null +++ b/tests/utils/export.utils.spec.ts @@ -0,0 +1,72 @@ +import { EXPORT_COLUMN_NAME_OVERRIDES, convertKeysToExportColumns, toExportColumnName } from '../../src/utils/export.utils'; + +describe('export-name converter', () => { + describe('toExportColumnName', () => { + it('converts a plain camelCase key to snake_case', () => { + expect(toExportColumnName('sourceName')).toBe('source_name'); + }); + + it('keeps acronym/number tokens together (CE90, UTC)', () => { + expect(toExportColumnName('horizontalAccuracyCE90')).toBe('horizontal_accuracy_ce90'); + expect(toExportColumnName('imagingTimeBeginUTC')).toBe('imaging_time_begin_utc'); + expect(toExportColumnName('imagingTimeEndUTC')).toBe('imaging_time_end_utc'); + expect(toExportColumnName('ingestionDateUTC')).toBe('ingestion_date_utc'); + }); + + it('leaves single-word keys unchanged', () => { + expect(toExportColumnName('sensors')).toBe('sensors'); + expect(toExportColumnName('id')).toBe('id'); + }); + + it('applies the override table before the general rule', () => { + // resolutionDegree would snake_case to resolution_degree, but the product spec wants resolution_deg + expect(toExportColumnName('resolutionDegree')).toBe('resolution_deg'); + }); + + it('does not override fields that already abbreviate correctly', () => { + expect(toExportColumnName('resolutionMeter')).toBe('resolution_meter'); + expect(toExportColumnName('maxResolutionDeg')).toBe('max_resolution_deg'); + expect(toExportColumnName('minResolutionDeg')).toBe('min_resolution_deg'); + }); + }); + + describe('override table', () => { + it('declares resolutionDegree -> resolution_deg as its single entry', () => { + expect(EXPORT_COLUMN_NAME_OVERRIDES).toStrictEqual({ resolutionDegree: 'resolution_deg' }); + }); + }); + + describe('convertKeysToExportColumns', () => { + it('renames every key of an object, preserving values', () => { + const input = { + id: 'abc', + sourceName: 'src', + horizontalAccuracyCE90: 3, + imagingTimeBeginUTC: '2020-01-01', + resolutionDegree: 0.5, + sensors: ['a', 'b'], + }; + + // toEqual (not toStrictEqual): the result intentionally has a null prototype for safety. + expect(convertKeysToExportColumns(input)).toEqual({ + id: 'abc', + source_name: 'src', + horizontal_accuracy_ce90: 3, + imaging_time_begin_utc: '2020-01-01', + resolution_deg: 0.5, + sensors: ['a', 'b'], + }); + }); + + it('preserves null values (used for fixed-schema columns)', () => { + expect(convertKeysToExportColumns({ description: null, cities: null })).toEqual({ + description: null, + cities: null, + }); + }); + + it('returns an object with no prototype to avoid prototype-chain writes', () => { + expect(Object.getPrototypeOf(convertKeysToExportColumns({ sourceName: 'x' }))).toBeNull(); + }); + }); +}); From f9a860f9a4736715df819a13555180ef8a4619bd Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 29 Jun 2026 16:11:04 +0300 Subject: [PATCH 2/5] test: silence naming-convention rule for snake_case export-column assertions Co-Authored-By: Claude Opus 4.8 --- tests/utils/export.utils.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/utils/export.utils.spec.ts b/tests/utils/export.utils.spec.ts index 0860c3a..8b8cfbb 100644 --- a/tests/utils/export.utils.spec.ts +++ b/tests/utils/export.utils.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention -- assertions intentionally use snake_case export column names */ import { EXPORT_COLUMN_NAME_OVERRIDES, convertKeysToExportColumns, toExportColumnName } from '../../src/utils/export.utils'; describe('export-name converter', () => { From 415b7f8c2d087447a82eb60dc826b959f0b4d07e Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 29 Jun 2026 16:39:39 +0300 Subject: [PATCH 3/5] docs: tighten export.utils comments to one line each Co-Authored-By: Claude Opus 4.8 --- src/utils/export.utils.ts | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/src/utils/export.utils.ts b/src/utils/export.utils.ts index fa593fd..5e22ce6 100644 --- a/src/utils/export.utils.ts +++ b/src/utils/export.utils.ts @@ -1,40 +1,18 @@ import { snakeCase } from 'change-case'; -/** - * Canonical naming exceptions for export (GeoPackage) column names. - * - * The general rule is `change-case`'s {@link snakeCase}. Entries here override that rule for fields - * whose required export column name differs from the plain snake_case form. This object is the single - * place such exceptions are declared. - * - * Current entries: - * - `resolutionDegree -> resolution_deg`: the per-part resolution field. `snakeCase` yields - * `resolution_degree`, but the product spec requires the abbreviated `resolution_deg` - * (matching the already-abbreviated aggregate fields `maxResolutionDeg`/`minResolutionDeg`). - */ +/** Export column names that differ from plain `snakeCase` (e.g. `resolution_degree` -> `resolution_deg`). */ export const EXPORT_COLUMN_NAME_OVERRIDES: Record = { resolutionDegree: 'resolution_deg', }; -/** - * Maps a single camelCase property name to its export (GeoPackage) column name. - * - * Consults {@link EXPORT_COLUMN_NAME_OVERRIDES} first, then falls back to `change-case`'s snake_case. - * This is the canonical export-column naming and is NOT a generic snake_case utility — it carries - * the override table above. - */ +/** Maps a camelCase property to its GeoPackage export column name: an override if present, else `snakeCase`. */ export const toExportColumnName = (propertyName: string): string => { return EXPORT_COLUMN_NAME_OVERRIDES[propertyName] ?? snakeCase(propertyName); }; -/** - * Returns a new object whose keys are converted to their export column names via {@link toExportColumnName}. - * Values (including `null`, used for fixed-schema columns) are preserved as-is. - */ +/** Renames an object's keys to their export column names, preserving values (incl. `null`). */ export const convertKeysToExportColumns = (obj: Record): Record => { - // Null-prototype accumulator so a converted key such as `__proto__` becomes a plain own property - // rather than mutating the prototype chain (this helper is exported for reuse with arbitrary keys). - const result = Object.create(null) as Record; + const result = Object.create(null) as Record; // null-proto: a `__proto__` key stays a plain own property for (const [key, value] of Object.entries(obj)) { result[toExportColumnName(key)] = value; } From ba71e67737e482db648f9c262eada1c1636d6fc4 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 29 Jun 2026 16:44:35 +0300 Subject: [PATCH 4/5] test: scope naming-convention disable to the snake_case assertion only Replaces the file-wide eslint-disable with a block-scoped disable around the single toEqual whose keys are export column names, keeping the rule active elsewhere. Co-Authored-By: Claude Opus 4.8 --- tests/utils/export.utils.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/utils/export.utils.spec.ts b/tests/utils/export.utils.spec.ts index 8b8cfbb..50493cb 100644 --- a/tests/utils/export.utils.spec.ts +++ b/tests/utils/export.utils.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention -- assertions intentionally use snake_case export column names */ import { EXPORT_COLUMN_NAME_OVERRIDES, convertKeysToExportColumns, toExportColumnName } from '../../src/utils/export.utils'; describe('export-name converter', () => { @@ -49,6 +48,7 @@ describe('export-name converter', () => { }; // toEqual (not toStrictEqual): the result intentionally has a null prototype for safety. + /* eslint-disable @typescript-eslint/naming-convention -- snake_case keys are the expected export column names */ expect(convertKeysToExportColumns(input)).toEqual({ id: 'abc', source_name: 'src', @@ -57,6 +57,7 @@ describe('export-name converter', () => { resolution_deg: 0.5, sensors: ['a', 'b'], }); + /* eslint-enable @typescript-eslint/naming-convention */ }); it('preserves null values (used for fixed-schema columns)', () => { From 3d3f8ce5af46bbe75e97164ba0c65ef3064e78d9 Mon Sep 17 00:00:00 2001 From: razbroc Date: Mon, 29 Jun 2026 17:51:14 +0300 Subject: [PATCH 5/5] test: consolidate converter tests into convertKeysToExportColumns Drops the redundant toExportColumnName block (covered transitively) and folds the per-key cases into a single object-level assertion. Co-Authored-By: Claude Opus 4.8 --- tests/utils/export.utils.spec.ts | 68 ++++++++++---------------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/tests/utils/export.utils.spec.ts b/tests/utils/export.utils.spec.ts index 50493cb..efea048 100644 --- a/tests/utils/export.utils.spec.ts +++ b/tests/utils/export.utils.spec.ts @@ -1,74 +1,48 @@ -import { EXPORT_COLUMN_NAME_OVERRIDES, convertKeysToExportColumns, toExportColumnName } from '../../src/utils/export.utils'; +import { EXPORT_COLUMN_NAME_OVERRIDES, convertKeysToExportColumns } from '../../src/utils/export.utils'; describe('export-name converter', () => { - describe('toExportColumnName', () => { - it('converts a plain camelCase key to snake_case', () => { - expect(toExportColumnName('sourceName')).toBe('source_name'); - }); - - it('keeps acronym/number tokens together (CE90, UTC)', () => { - expect(toExportColumnName('horizontalAccuracyCE90')).toBe('horizontal_accuracy_ce90'); - expect(toExportColumnName('imagingTimeBeginUTC')).toBe('imaging_time_begin_utc'); - expect(toExportColumnName('imagingTimeEndUTC')).toBe('imaging_time_end_utc'); - expect(toExportColumnName('ingestionDateUTC')).toBe('ingestion_date_utc'); - }); - - it('leaves single-word keys unchanged', () => { - expect(toExportColumnName('sensors')).toBe('sensors'); - expect(toExportColumnName('id')).toBe('id'); - }); - - it('applies the override table before the general rule', () => { - // resolutionDegree would snake_case to resolution_degree, but the product spec wants resolution_deg - expect(toExportColumnName('resolutionDegree')).toBe('resolution_deg'); - }); - - it('does not override fields that already abbreviate correctly', () => { - expect(toExportColumnName('resolutionMeter')).toBe('resolution_meter'); - expect(toExportColumnName('maxResolutionDeg')).toBe('max_resolution_deg'); - expect(toExportColumnName('minResolutionDeg')).toBe('min_resolution_deg'); - }); - }); - - describe('override table', () => { - it('declares resolutionDegree -> resolution_deg as its single entry', () => { - expect(EXPORT_COLUMN_NAME_OVERRIDES).toStrictEqual({ resolutionDegree: 'resolution_deg' }); - }); - }); - describe('convertKeysToExportColumns', () => { - it('renames every key of an object, preserving values', () => { + it('renames camelCase keys to snake_case export columns, applying overrides and preserving values', () => { const input = { - id: 'abc', - sourceName: 'src', - horizontalAccuracyCE90: 3, - imagingTimeBeginUTC: '2020-01-01', - resolutionDegree: 0.5, + id: 'abc', // single-word key is unchanged sensors: ['a', 'b'], + sourceName: 'src', // plain camelCase -> snake_case + horizontalAccuracyCE90: 3, // acronym + number kept together + imagingTimeBeginUTC: '2020-01-01', // trailing acronym (UTC) + ingestionDateUTC: '2021-02-03', + resolutionDegree: 0.5, // override -> resolution_deg (not resolution_degree) + resolutionMeter: 12, // not overridden + maxResolutionDeg: 0.7, // already abbreviated, no override needed }; // toEqual (not toStrictEqual): the result intentionally has a null prototype for safety. /* eslint-disable @typescript-eslint/naming-convention -- snake_case keys are the expected export column names */ expect(convertKeysToExportColumns(input)).toEqual({ id: 'abc', + sensors: ['a', 'b'], source_name: 'src', horizontal_accuracy_ce90: 3, imaging_time_begin_utc: '2020-01-01', + ingestion_date_utc: '2021-02-03', resolution_deg: 0.5, - sensors: ['a', 'b'], + resolution_meter: 12, + max_resolution_deg: 0.7, }); /* eslint-enable @typescript-eslint/naming-convention */ }); it('preserves null values (used for fixed-schema columns)', () => { - expect(convertKeysToExportColumns({ description: null, cities: null })).toEqual({ - description: null, - cities: null, - }); + expect(convertKeysToExportColumns({ description: null, cities: null })).toEqual({ description: null, cities: null }); }); it('returns an object with no prototype to avoid prototype-chain writes', () => { expect(Object.getPrototypeOf(convertKeysToExportColumns({ sourceName: 'x' }))).toBeNull(); }); }); + + describe('override table', () => { + it('declares resolutionDegree -> resolution_deg as its single entry', () => { + expect(EXPORT_COLUMN_NAME_OVERRIDES).toStrictEqual({ resolutionDegree: 'resolution_deg' }); + }); + }); });