From 0dd1e67f8c5e75534949544183da2a35556a2c02 Mon Sep 17 00:00:00 2001 From: jstet Date: Fri, 25 Sep 2026 16:50:48 +0200 Subject: [PATCH] fix(validate): count label:: columns as choice labels The empty-label warning (#48) read only `choice.label`, so a choices sheet with language-tagged columns (`label::English`, Kobo's layout once a form declares a language) and no plain `label` got "has no label" for every choice, although the DDI labels were fine (found by survey2ddi's parity gate). The check now collects label text from `label` (string, or the loader's {lang: text}) and from every `label::` column. It warns when all are empty, or names the empty languages. Closes #75 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/xlsform/validate.ts | 38 ++++++++++++++++++++------ tests/ts/unit/subsetValidation.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/xlsform/validate.ts b/src/xlsform/validate.ts index a7df6a2..e32c117 100644 --- a/src/xlsform/validate.ts +++ b/src/xlsform/validate.ts @@ -435,17 +435,39 @@ export class XLSValidator { const code = (choice.name ?? '').toString().trim(); if (!code) return null; // reported as a missing code const where = `choice "${code}" (list "${String(choice.list_name ?? '').trim()}")`; + const labels = this.choiceLabels(choice); + if (labels.size === 0) return `${where} has no label`; + const missing = [...labels] + .filter(([, text]) => text === '') + .map(([k]) => k); + if (missing.length === 0) return null; + if (missing.length === labels.size) return `${where} has no label`; + return `${where} has no label in: ${missing.join(', ')}`; + } + + /** + * A choice's label text per language, trimmed (`''` = empty). Covers the + * loader's shape (`label` as a string, or `{lang: text}` with `_languages`) + * and raw rows that keep `label::` columns (Kobo's layout once a form + * declares a language). A plain `label` is keyed `''`. + */ + private static choiceLabels(choice: ChoiceRow): Map { + const out = new Map(); + const text = (v: unknown) => + typeof v === 'string' || typeof v === 'number' ? String(v).trim() : ''; const label = choice.label; if (label !== null && typeof label === 'object') { - const langs = choice._languages ?? Object.keys(label); - const missing = langs.filter( - (lang) => String(label[lang] ?? '').trim() === '', - ); - if (missing.length === 0) return null; - if (missing.length === langs.length) return `${where} has no label`; - return `${where} has no label in: ${missing.join(', ')}`; + for (const lang of choice._languages ?? Object.keys(label)) { + out.set(lang, text(label[lang])); + } + } else if (label !== undefined) { + out.set('', text(label)); + } + for (const [key, value] of Object.entries(choice)) { + if (key.startsWith('label::')) + out.set(key.slice('label::'.length), text(value)); } - return String(label ?? '').trim() === '' ? `${where} has no label` : null; + return out; } /** Type, choice-list and appearance findings for one survey row. */ diff --git a/tests/ts/unit/subsetValidation.test.ts b/tests/ts/unit/subsetValidation.test.ts index 62129cb..58e1b0a 100644 --- a/tests/ts/unit/subsetValidation.test.ts +++ b/tests/ts/unit/subsetValidation.test.ts @@ -270,3 +270,37 @@ describe("validateSubset — target 'ddi' (#52)", () => { ).toContainEqual(expect.objectContaining({ severity: 'error' })); }); }); + +describe('validateSubset — label:: choice columns (#75)', () => { + const warn = (choices: Record[]) => + XLSValidator.validateSubset( + [{ type: 'select_one g', name: 'q', 'label::English': 'Q' }], + choices, + { target: 'ddi' }, + ).map((v) => v.message); + + test('a label:: column counts as a label', () => { + expect( + warn([{ list_name: 'g', name: 'm', 'label::English': 'Male' }]), + ).toEqual([]); + }); + + test('names the language columns that are empty', () => { + expect( + warn([ + { + list_name: 'g', + name: 'm', + 'label::English': 'Male', + 'label::Deutsch': '', + }, + ]), + ).toEqual(['choice "m" (list "g") has no label in: Deutsch']); + }); + + test('still warns when every label column is empty', () => { + expect( + warn([{ list_name: 'g', name: 'm', 'label::English': ' ' }]), + ).toEqual(['choice "m" (list "g") has no label']); + }); +});