Skip to content

Commit 808db8f

Browse files
committed
feat(import-repair): implement legacy space-key alias handling and associated tests
1 parent b468a34 commit 808db8f

2 files changed

Lines changed: 124 additions & 8 deletions

File tree

‎src/state/__tests__/importRepair.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,37 @@ describe('applyImportRepairs — accepting fixes yields a model that validates c
179179
});
180180

181181
describe('benign normalizations — auto-applied and listed, never silent', () => {
182+
it('renames known legacy space-key schema fields before validation and attribution', () => {
183+
const model = loadNonconforming();
184+
const subject = model.subject as Record<string, unknown>;
185+
subject['subject id'] = subject.subject_id;
186+
delete subject.subject_id;
187+
model['electrode groups'] = model.electrode_groups;
188+
delete model.electrode_groups;
189+
model['ntrode electrode group channel map'] = model.ntrode_electrode_group_channel_map;
190+
delete model.ntrode_electrode_group_channel_map;
191+
192+
const plan = buildImportRepairPlan(model, 'legacy-space-keys.yml', { animals: {} });
193+
194+
expect(plan.decision).toEqual({ kind: 'new', subjectId: 'remy' });
195+
expect(plan.blockers.some((b) => b.path === 'subject.subject_id')).toBe(false);
196+
expect(plan.benign.map((b) => b.label)).toEqual(
197+
expect.arrayContaining([
198+
'subject id → subject_id',
199+
'electrode groups → electrode_groups',
200+
'ntrode electrode group channel map → ntrode_electrode_group_channel_map',
201+
])
202+
);
203+
204+
const repaired = applyImportRepairs(model, {});
205+
expect(repaired.subject).toHaveProperty('subject_id', 'remy');
206+
expect(repaired.subject).not.toHaveProperty('subject id');
207+
expect(repaired).toHaveProperty('electrode_groups');
208+
expect(repaired).not.toHaveProperty('electrode groups');
209+
expect(repaired).toHaveProperty('ntrode_electrode_group_channel_map');
210+
expect(repaired).not.toHaveProperty('ntrode electrode group channel map');
211+
});
212+
182213
it('renames task_epoch (singular) to task_epochs and lists it', () => {
183214
const model = {
184215
associated_video_files: [{ name: 'v', camera_id: 0, task_epoch: 2 }],

‎src/state/importRepair.ts‎

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ const SEX_SUGGESTIONS: Readonly<Record<string, string>> = {
7272
hermaphrodite: 'O',
7373
};
7474

75+
/** Known legacy YAML key spellings that differ only by spaces vs underscores. */
76+
const SPACE_KEY_ALIASES: Readonly<Record<string, string>> = {
77+
'subject id': 'subject_id',
78+
'data acq device': 'data_acq_device',
79+
'electrode groups': 'electrode_groups',
80+
'ntrode electrode group channel map': 'ntrode_electrode_group_channel_map',
81+
};
82+
7583
/** How a repair item is resolved in the UI. */
7684
export type RepairKind = 'suggestion' | 'input';
7785

@@ -205,6 +213,78 @@ interface VolumeShimItem {
205213
[key: string]: unknown;
206214
}
207215

216+
/**
217+
* Whether a decoded YAML value is a plain record that can carry schema keys.
218+
*
219+
* @param value - The value to check.
220+
* @returns True for mutable object records; false for arrays, null, Date, and scalars.
221+
*/
222+
function isRecord(value: unknown): value is Record<string, unknown> {
223+
return (
224+
value !== null &&
225+
typeof value === 'object' &&
226+
!Array.isArray(value) &&
227+
!(value instanceof Date)
228+
);
229+
}
230+
231+
/**
232+
* Convert a path segment list to a stable human-readable import-repair path.
233+
*
234+
* @param parts - The path parts.
235+
* @returns A dot path, or `(root)` for the document root.
236+
*/
237+
function formatImportPath(parts: ReadonlyArray<string | number>): string {
238+
if (parts.length === 0) return '(root)';
239+
return parts.map((part) => String(part)).join('.');
240+
}
241+
242+
/**
243+
* Apply known legacy space-key aliases in place, returning the listed benign normalizations.
244+
* Only known schema-key aliases are rewritten; arbitrary user keys are preserved.
245+
*
246+
* @param value - The decoded model or nested value.
247+
* @param path - The current traversal path.
248+
* @returns The benign normalizations applied.
249+
*/
250+
function applySpaceKeyAliases(
251+
value: unknown,
252+
path: Array<string | number> = []
253+
): BenignNormalization[] {
254+
const benign: BenignNormalization[] = [];
255+
256+
if (Array.isArray(value)) {
257+
value.forEach((item, index) => {
258+
benign.push(...applySpaceKeyAliases(item, [...path, index]));
259+
});
260+
return benign;
261+
}
262+
263+
if (!isRecord(value)) return benign;
264+
265+
for (const [legacyKey, canonicalKey] of Object.entries(SPACE_KEY_ALIASES)) {
266+
if (!Object.prototype.hasOwnProperty.call(value, legacyKey)) continue;
267+
if (Object.prototype.hasOwnProperty.call(value, canonicalKey)) continue;
268+
269+
value[canonicalKey] = value[legacyKey];
270+
delete value[legacyKey];
271+
const parentPath = formatImportPath(path);
272+
benign.push({
273+
path: parentPath,
274+
label: `${legacyKey} → ${canonicalKey}`,
275+
detail:
276+
`Renamed legacy key \`${legacyKey}\` → \`${canonicalKey}\` ` +
277+
`at ${parentPath} — no values changed.`,
278+
});
279+
}
280+
281+
for (const [key, child] of Object.entries(value)) {
282+
benign.push(...applySpaceKeyAliases(child, [...path, key]));
283+
}
284+
285+
return benign;
286+
}
287+
208288
/**
209289
* Build the repair items + blockers for a model's `validate` errors. Known leaf codes map to
210290
* suggestions/inputs; everything else becomes a blocker (fix-in-file).
@@ -350,6 +430,8 @@ function buildBenignAndShim(model: ValidationModel): {
350430
const benign: BenignNormalization[] = [];
351431
const shimItems: RepairItem[] = [];
352432

433+
benign.push(...applySpaceKeyAliases(structuredClone(model)));
434+
353435
// --- task_epoch (singular) → task_epochs in file/video lists (the field the app reads). ---
354436
for (const key of ['associated_files', 'associated_video_files'] as const) {
355437
const list = (model as Record<string, unknown>)[key];
@@ -401,14 +483,17 @@ function buildBenignAndShim(model: ValidationModel): {
401483
}
402484

403485
/**
404-
* Apply the benign, lossless normalizations to a (mutable) model: rename `task_epoch` → the
405-
* `task_epochs` key the app reads, and fill a missing volume spelling from the present one. Shared
406-
* by {@link buildImportRepairPlan} (which validates the NORMALIZED model, so a benign-fixable issue
407-
* never also surfaces as a repair item) and {@link applyImportRepairs}.
486+
* Apply the benign, lossless normalizations to a (mutable) model: recover known legacy space-key
487+
* schema spellings, rename `task_epoch` → the `task_epochs` key the app reads, and fill a missing
488+
* volume spelling from the present one. Shared by {@link buildImportRepairPlan} (which validates
489+
* the NORMALIZED model, so a benign-fixable issue never also surfaces as a repair item) and
490+
* {@link applyImportRepairs}.
408491
*
409492
* @param model - The model to mutate in place.
410493
*/
411494
function applyBenignNormalizations(model: Record<string, unknown>): void {
495+
applySpaceKeyAliases(model);
496+
412497
for (const key of ['associated_files', 'associated_video_files'] as const) {
413498
const list = model[key];
414499
if (!Array.isArray(list)) continue;
@@ -458,7 +543,7 @@ export function buildImportRepairPlan(
458543
const { items, blockers } = buildValidationItems(normalized as ValidationModel);
459544

460545
// Decision: match the subject id against the existing workspace (by key or subject.subject_id).
461-
const subjectId = (model.subject as { subject_id?: unknown } | undefined)?.subject_id;
546+
const subjectId = (normalized.subject as { subject_id?: unknown } | undefined)?.subject_id;
462547
let decision: ImportDecision;
463548
if (typeof subjectId !== 'string' || subjectId.trim() === '') {
464549
decision = { kind: 'blocked', reason: 'The file has no subject_id, so it cannot be attributed to an animal.' };
@@ -482,8 +567,8 @@ export function buildImportRepairPlan(
482567
/**
483568
* Apply the benign normalizations and the user's accepted resolutions to a model, returning a NEW
484569
* model (the input is never mutated, and no key is dropped). Resolutions are keyed by the same path
485-
* the repair items carry; benign normalizations (task_epoch rename, single-spelling volume fill)
486-
* are applied unconditionally because the screen lists them.
570+
* the repair items carry; benign normalizations (space-key aliases, task_epoch rename, single-spelling
571+
* volume fill) are applied unconditionally because the screen lists them.
487572
*
488573
* @param flatModel - The decoded flat model.
489574
* @param resolutions - Accepted/edited values keyed by repair-item path.
@@ -495,7 +580,7 @@ export function applyImportRepairs(
495580
): Record<string, unknown> {
496581
const model = structuredClone((flatModel ?? {}) as Record<string, unknown>);
497582

498-
// Benign, lossless normalizations (task_epoch rename, single-spelling volume fill).
583+
// Benign, lossless normalizations (space-key aliases, task_epoch rename, single-spelling volume fill).
499584
applyBenignNormalizations(model);
500585

501586
// Accepted/edited resolutions, applied at their paths. A reconciled volume sets BOTH spellings

0 commit comments

Comments
 (0)