diff --git a/ddi-validation/schematron/ddi_custom_rules.sch b/ddi-validation/schematron/ddi_custom_rules.sch index bee93cf..05114c8 100644 --- a/ddi-validation/schematron/ddi_custom_rules.sch +++ b/ddi-validation/schematron/ddi_custom_rules.sch @@ -216,10 +216,11 @@ - Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:choice_column, cdl:column, cdl:constraint, cdl:constraint_message, cdl:default, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_column, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting). + Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:choice_column, cdl:column, cdl:constraint, cdl:constraint_message, cdl:default, cdl:end_column, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_column, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting). A cdl:choice_column note needs a subject: the name of what it holds. A cdl:column note needs a subject: the name of what it holds. A cdl:constraint note needs subject="xlsform-xpath": its text is an expression in that syntax. + A cdl:end_column note needs a subject: the name of what it holds. A cdl:language note needs a subject: the name of what it holds. A cdl:position note needs a subject: the name of what it holds. A cdl:relevant note needs subject="xlsform-xpath": its text is an expression in that syntax. @@ -232,10 +233,11 @@ A cdl:setting note needs a subject: the name of what it holds. - Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:choice_column, cdl:column, cdl:constraint, cdl:constraint_message, cdl:default, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_column, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting). + Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:choice_column, cdl:column, cdl:constraint, cdl:constraint_message, cdl:default, cdl:end_column, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_column, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting). A cdl:choice_column note needs a subject: the name of what it holds. A cdl:column note needs a subject: the name of what it holds. A cdl:constraint note needs subject="xlsform-xpath": its text is an expression in that syntax. + A cdl:end_column note needs a subject: the name of what it holds. A cdl:language note needs a subject: the name of what it holds. A cdl:position note needs a subject: the name of what it holds. A cdl:relevant note needs subject="xlsform-xpath": its text is an expression in that syntax. diff --git a/registry/conventions/ddiFields.jsonld b/registry/conventions/ddiFields.jsonld index cf5f11a..7d987c8 100644 --- a/registry/conventions/ddiFields.jsonld +++ b/registry/conventions/ddiFields.jsonld @@ -55,7 +55,7 @@ "var", "varGrp" ], - "text": "the appearance cell, lowercased" + "text": "the appearance cell as authored (readers compare it lowercased)" }, "parameters": { "type": "cdl:parameters", @@ -213,6 +213,14 @@ ], "subject": "the row's name, a space, the column", "text": "the cell of a column the model doesn't lift, of a note row or cdl:row" + }, + "end_column": { + "type": "cdl:end_column", + "on": [ + "varGrp[@type='section' or @type='grid']" + ], + "subject": "the column", + "text": "a cell of the group's end_group row but its type (a name, Kobo's $kuid)" } }, "fields": { @@ -318,6 +326,9 @@ }, "InstrumentChoice.columns": { "cdlNote": "cdl:choice_column" + }, + "GroupItem.endColumns": { + "cdlNote": "cdl:end_column" } }, "liftedColumns": { diff --git a/src/conventions/columns.ts b/src/conventions/columns.ts index 55414b0..3e7a30a 100644 --- a/src/conventions/columns.ts +++ b/src/conventions/columns.ts @@ -29,13 +29,12 @@ function cellText(value: unknown): string { */ export function otherColumns( row: Record, - sheet: Sheet, + sheet: Sheet | null, ): Record { const out: Record = {}; for (const [key, value] of Object.entries(row)) { - if (key.startsWith('_') || liftedBy[sheet].has(key.split('::')[0])) { - continue; - } + if (key.startsWith('_')) continue; + if (sheet && liftedBy[sheet].has(key.split('::')[0])) continue; const cells: Array<[string, unknown]> = value !== null && typeof value === 'object' ? Object.entries(value).map(([lang, v]) => [`${key}::${lang}`, v]) @@ -47,3 +46,13 @@ export function otherColumns( } return out; } + +/** + * A row's every cell but the loader's, by column name, `{ lang: text }` + * cells one per language: a settings row, an `end_group` row. + */ +export function allColumns( + row: Record, +): Record { + return otherColumns(row, null); +} diff --git a/src/ddi/codebook.ts b/src/ddi/codebook.ts index 7c08f33..e00a31d 100644 --- a/src/ddi/codebook.ts +++ b/src/ddi/codebook.ts @@ -1009,7 +1009,9 @@ export function buildDdiCodebook( const dataDscr = root.child('dataDscr'); const buckets = splitDataVars(dataVars); - const described = variables.filter((v) => v.row === undefined); + const described = variables.filter( + (v) => v.row === undefined && !v.emptyGroup, + ); const ctx = logicContext(described, lang, questionIds(buckets.units)); addVarGroups( dataDscr, diff --git a/src/ddi/fields.ts b/src/ddi/fields.ts index bdeaab4..b68c357 100644 --- a/src/ddi/fields.ts +++ b/src/ddi/fields.ts @@ -4,6 +4,7 @@ * ``. */ import conventions from '../generated/conventions.js'; +import { allColumns } from '../conventions/columns.js'; import { GRID_APPEARANCE } from '../conventions/grid.js'; import { parseParameters } from '../utils/parameters.js'; import { TYPE_MAPPINGS } from '../generated/TypeMappings.js'; @@ -28,7 +29,9 @@ export function addFieldNotes(el: XmlElement, v: Variable): void { el.textChild('notes', v.default, { type: NOTES.default.type }); } if (v.appearance) { - el.textChild('notes', v.appearance, { type: NOTES.appearance.type }); + el.textChild('notes', v.appearanceCell ?? v.appearance, { + type: NOTES.appearance.type, + }); } // As authored (#160): a range's bounds are its valrng too, and a // guidance_hint inside is its ivuInstr too, but only the cell says so. @@ -60,14 +63,19 @@ export function addGroupFieldNotes( type: NOTES.hint.type, }); } - if (group.appearance && !(grid && group.appearance === GRID_APPEARANCE)) { - el.textChild('notes', group.appearance, { type: NOTES.appearance.type }); + const gridOnly = + grid && group.appearance === GRID_APPEARANCE && !group.appearanceCell; + if (group.appearance && !gridOnly) { + el.textChild('notes', group.appearanceCell ?? group.appearance, { + type: NOTES.appearance.type, + }); } // Its txt is its name, for readers that show one (#160). if (group.unlabelled) { el.textChild('notes', 'yes', { type: NOTES.no_label.type }); } addColumnNotes(el, group.columns); + addColumnNotes(el, group.endColumns, NOTES.end_column.type); } /** Settings in a standard element: `titl`, `IDNo`, `verStmt/version`. */ @@ -82,13 +90,19 @@ export function addSettingNotes( stdy: XmlElement, settings: Record, ): void { - const keys = Object.keys(settings).sort(); - for (const [key, value] of keys.map((k) => [k, settings[k]] as const)) { - if (STANDARD_SETTINGS.has(key)) continue; - if (typeof value !== 'string' && typeof value !== 'number') continue; - const text = String(value).trim(); - if (!text) continue; - stdy.textChild('notes', text, { type: NOTES.setting.type, subject: key }); + // A setting per language is one note per `::` (#160). + const cells = allColumns(settings); + for (const column of Object.keys(cells).sort()) { + const base = column.split('::')[0]; + // A standard setting, or one of its languages when titl/parTitl has them. + if (STANDARD_SETTINGS.has(column)) continue; + if (STANDARD_SETTINGS.has(base) && typeof settings[base] === 'object') { + continue; + } + stdy.textChild('notes', cells[column], { + type: NOTES.setting.type, + subject: column, + }); } } @@ -161,7 +175,7 @@ export function addRowFieldNotes(stdy: XmlElement, v: Variable): void { }); } if (v.appearance) { - stdy.textChild('notes', v.appearance, { + stdy.textChild('notes', v.appearanceCell ?? v.appearance, { type: NOTES.row_appearance.type, subject, }); diff --git a/src/ddi/fromInstrument.ts b/src/ddi/fromInstrument.ts index 6419acd..254aa42 100644 --- a/src/ddi/fromInstrument.ts +++ b/src/ddi/fromInstrument.ts @@ -352,6 +352,7 @@ function pushRow(q: QuestionItem, ctx: GroupContext, state: ProjectState) { }), ...(q.relevant.trim() ? { relevant: q.relevant.trim() } : {}), ...(q.appearance ? { appearance: q.appearance } : {}), + ...appearanceCell(q), ...(translations ? { translations } : {}), ...columnsOf(q), group: ctx.path, @@ -364,6 +365,15 @@ function pushRow(q: QuestionItem, ctx: GroupContext, state: ProjectState) { }); } +/** The appearance cell as authored, when its case isn't the model's (#160). */ +function appearanceCell(item: Item): { appearanceCell?: string } { + const cell = item.row['appearance']; + const raw = typeof cell === 'string' ? cell.trim() : ''; + return raw && raw !== item.appearance && raw.toLowerCase() === item.appearance + ? { appearanceCell: raw } + : {}; +} + /** An item's or choice's columns the model doesn't lift, absent when none. */ function columnsOf(x: { columns?: Record }): { columns?: Record; @@ -432,6 +442,7 @@ function pushQuestion( ...logicOf(q, state.lang), ...(translations ? { translations } : {}), ...(added ? { orOther: otherOrigin(q) } : {}), + ...appearanceCell(q), ...columnsOf(q), }); @@ -461,7 +472,12 @@ function project(items: Item[], ctx: GroupContext, state: ProjectState): void { ? {} : { unlabelled: true }), ...columnsOf(item), + ...appearanceCell(item), + ...(item.endColumns && Object.keys(item.endColumns).length + ? { endColumns: item.endColumns } + : {}), }; + const before = state.variables.length; project( item.children, { @@ -473,6 +489,22 @@ function project(items: Item[], ctx: GroupContext, state: ProjectState): void { }, state, ); + // Nothing in it the codebook has: still a group, placed where it was. + if (state.variables.length === before) { + state.variables.push({ + name: item.name, + type: 'begin_group', + label: '', + group: path, + groupLabel: label, + groupAppearance: item.appearance, + listName: '', + vocab: '', + choices: [], + groups: [...ctx.groups, group], + emptyGroup: true, + }); + } } } diff --git a/src/ddi/notes.ts b/src/ddi/notes.ts index a978264..d6f756a 100644 --- a/src/ddi/notes.ts +++ b/src/ddi/notes.ts @@ -103,7 +103,7 @@ export function classifyNotes(variables: Variable[]): ClassifiedNotes { let pending: number[] = []; variables.forEach((v, i) => { - if (v.row !== undefined) { + if (v.row !== undefined || v.emptyGroup) { placed.push(i); return; } @@ -181,7 +181,7 @@ function placements( }); emptyGroups.push(chain.slice(0, depth + 1)); }); - positions.push(positionOf(variables, i)); + if (!variables[i].emptyGroup) positions.push(positionOf(variables, i)); } return { positions, emptyGroups }; } diff --git a/src/ddi/types.ts b/src/ddi/types.ts index 1f75c66..f27cf6f 100644 --- a/src/ddi/types.ts +++ b/src/ddi/types.ts @@ -47,6 +47,10 @@ export interface DdiGroup { unlabelled?: boolean; /** Its columns the model doesn't lift: `cdl:column` notes (#160). */ columns?: Record; + /** Its `end_group` row's cells: `cdl:end_column` notes (#160). */ + endColumns?: Record; + /** The appearance cell when its case isn't `appearance`'s (#160). */ + appearanceCell?: string; /** `hint` in the form's other languages. */ hintTranslations?: Translations; } @@ -119,6 +123,13 @@ export interface Variable { orOther?: 'shorthand' | 'added'; /** The `or_other` companion the projection added, not an authored row. */ synthesized?: boolean; + /** The appearance cell when its case isn't `appearance`'s (#160). */ + appearanceCell?: string; + /** + * Stands for a group with nothing in it the codebook has (`groups` ends + * with it): a section `varGrp` placed by `cdl:position` (#160). + */ + emptyGroup?: boolean; /** * Its columns the model doesn't lift: `cdl:column` notes, a row's without * data or a note's `cdl:row_column` (#160). diff --git a/src/generated/conventions.json b/src/generated/conventions.json index dcc16b9..430eb3d 100644 --- a/src/generated/conventions.json +++ b/src/generated/conventions.json @@ -27,7 +27,7 @@ "var", "varGrp" ], - "text": "the appearance cell, lowercased" + "text": "the appearance cell as authored (readers compare it lowercased)" }, "parameters": { "type": "cdl:parameters", @@ -185,6 +185,14 @@ ], "subject": "the row's name, a space, the column", "text": "the cell of a column the model doesn't lift, of a note row or cdl:row" + }, + "end_column": { + "type": "cdl:end_column", + "on": [ + "varGrp[@type='section' or @type='grid']" + ], + "subject": "the column", + "text": "a cell of the group's end_group row but its type (a name, Kobo's $kuid)" } }, "fields": { @@ -290,6 +298,9 @@ }, "InstrumentChoice.columns": { "cdlNote": "cdl:choice_column" + }, + "GroupItem.endColumns": { + "cdlNote": "cdl:end_column" } }, "liftedColumns": { diff --git a/src/generated/conventions.ts b/src/generated/conventions.ts index b948436..9357534 100644 --- a/src/generated/conventions.ts +++ b/src/generated/conventions.ts @@ -36,7 +36,7 @@ const conventions = { "var", "varGrp" ], - "text": "the appearance cell, lowercased" + "text": "the appearance cell as authored (readers compare it lowercased)" }, "parameters": { "type": "cdl:parameters", @@ -194,6 +194,14 @@ const conventions = { ], "subject": "the row's name, a space, the column", "text": "the cell of a column the model doesn't lift, of a note row or cdl:row" + }, + "end_column": { + "type": "cdl:end_column", + "on": [ + "varGrp[@type='section' or @type='grid']" + ], + "subject": "the column", + "text": "a cell of the group's end_group row but its type (a name, Kobo's $kuid)" } }, "fields": { @@ -299,6 +307,9 @@ const conventions = { }, "InstrumentChoice.columns": { "cdlNote": "cdl:choice_column" + }, + "GroupItem.endColumns": { + "cdlNote": "cdl:end_column" } }, "liftedColumns": { diff --git a/src/instrument/fromDdi.ts b/src/instrument/fromDdi.ts index a26fa74..b592285 100644 --- a/src/instrument/fromDdi.ts +++ b/src/instrument/fromDdi.ts @@ -223,6 +223,16 @@ function emptyQuestion(name: string): QuestionItem { }; } +/** + * An appearance note: the model's lowercase, the cell as authored in the row + * when its case differs (#160). No note keeps what the item has. + */ +function setAppearance(item: Item, cell: string): void { + if (!cell) return; + item.appearance = cell.toLowerCase(); + if (cell !== item.appearance) item.row = { ...item.row, appearance: cell }; +} + /** Columns the model doesn't lift (`cdl:column`), by column name. */ function columnsOf( node: XmlNode, @@ -241,6 +251,11 @@ function withColumns(columns: Record) { return Object.keys(columns).length ? { columns } : {}; } +/** `endColumns`, absent when there are none. */ +function withEndColumns(endColumns: Record) { + return Object.keys(endColumns).length ? { endColumns } : {}; +} + /** Split ` ` subjects (a row's or choice's columns) by first. */ function columnsBy( node: XmlNode, @@ -280,7 +295,7 @@ function readNotes(q: QuestionItem, node: XmlNode, state: ReadState): void { q.row = { ...q.row, required }; } q.default = noteText(node, FIELDS.default.type, state); - q.appearance = noteText(node, FIELDS.appearance.type, state); + setAppearance(q, noteText(node, FIELDS.appearance.type, state)); q.parameters = noteText(node, FIELDS.parameters.type, state); const columns = columnsOf(node); if (Object.keys(columns).length) q.columns = columns; @@ -655,14 +670,14 @@ function groupItem(id: string, state: ReadState): GroupItem { : childTexts(grp, 'txt', state), hint: texts(notesOf(grp, FIELDS.hint.type), state), relevant: noteText(grp, LOGIC.relevant.type, state), - appearance: - noteText(grp, FIELDS.appearance.type, state) || - (grid ? GRID_APPEARANCE : ''), + appearance: grid ? GRID_APPEARANCE : '', row: {}, ...withColumns(columnsOf(grp)), + ...withEndColumns(columnsOf(grp, FIELDS.end_column.type)), children: lead, closed: true, }; + setAppearance(item, noteText(grp, FIELDS.appearance.type, state)); state.groupItems.set(grp.attrs['name'] ?? id, item); return item; } @@ -869,7 +884,9 @@ function readRowFields( const hint = hints.get(item.name); if (hint) item.hint = texts(hint, state); item.relevant ||= subjectText(relevants, item.name); - item.appearance ||= subjectText(appearances, item.name); + if (!item.appearance) { + setAppearance(item, subjectText(appearances, item.name)); + } const own = columns.get(item.name); if (own) item.columns = own; } diff --git a/src/instrument/fromXlsform.ts b/src/instrument/fromXlsform.ts index 77d0e53..78b977b 100644 --- a/src/instrument/fromXlsform.ts +++ b/src/instrument/fromXlsform.ts @@ -3,7 +3,7 @@ * sees: `label::` columns (JSON fixtures, hand-built rows) and the * loader's `{ lang: text }` cells. */ -import { otherColumns } from '../conventions/columns.js'; +import { allColumns, otherColumns } from '../conventions/columns.js'; import { extractLanguageCode, languageTagOf } from '../utils/languageUtils.js'; import type { GroupItem, @@ -146,7 +146,11 @@ function parseBody(survey: Row[]): Item[] { stack.push(group); } else if (/^end[_ ]group$/.test(rawType)) { const closed = stack.pop(); - if (closed) closed.closed = true; + if (closed) { + closed.closed = true; + const { type: _, ...cells } = allColumns(row); + if (Object.keys(cells).length) closed.endColumns = cells; + } } else { append(question(row, rawType)); } diff --git a/src/instrument/types.ts b/src/instrument/types.ts index fd7b3be..f1dc5b8 100644 --- a/src/instrument/types.ts +++ b/src/instrument/types.ts @@ -56,6 +56,8 @@ export interface GroupItem extends ItemBase { children: Item[]; /** The source closed it (an `end_group` row); unclosed groups end with the survey. */ closed: boolean; + /** Its `end_group` row's cells but the type (`name`, `$kuid`), by column (#160). */ + endColumns?: Record; } export interface QuestionItem extends ItemBase { diff --git a/src/pipelines/ddi2xlsform/README.md b/src/pipelines/ddi2xlsform/README.md index d57b6a5..0f0f9b1 100644 --- a/src/pipelines/ddi2xlsform/README.md +++ b/src/pipelines/ddi2xlsform/README.md @@ -37,6 +37,8 @@ Standard DDI first, `cdl:` notes where DDI has no element | exclusive | `cdl:exclusive` on the select_multiple's `varGrp` | | settings | `titl` (+ `parTitl` per language), `IDNo`, `verStmt/version`, `codeBook/@xml:lang`, every other setting a `cdl:setting` | | language columns | `xml:lang`; the form's name for each (`label::Deutsch (de)`) in `cdl:language` | +| an `end_group` row's cells | `cdl:end_column` on the group's `varGrp` | +| a group with nothing the codebook has | a section `varGrp` without members, placed by `cdl:position` | | every other column (media, `choice_filter`, `read_only`, `$kuid`, …) | `cdl:column` (subject: the column) on the `var` / `varGrp`; a choice's `cdl:choice_column` (subject: ` `) on the first question using the list; a note's or data-less row's `cdl:row_column` on `stdyDscr` | ## Input it accepts @@ -60,11 +62,11 @@ Standard DDI first, `cdl:` notes where DDI has no element What a CDL codebook doesn't give back (`canonicalInstrument.ts` folds these out): -1. **Rows no registry type covers** (`calculate`, …) and **groups with - nothing in them**. -2. **Settings that are neither a string nor a number**, a column of an - `end_group` row, and the case of an `appearance` (it comes back lowercase). -3. **Whitespace in the type cell** comes back as one space. +1. **Rows no registry type covers** (`calculate`, …): out by design. +2. **Cell spellings**: whitespace in the type cell comes back as one space, + `begin group` / `end group` as `begin_group` / `end_group`, a boolean + setting as its text (`true`), a setting's `{ lang: text }` cell as one + `::` column per language. ## Tests diff --git a/src/xlsform/fromInstrument.ts b/src/xlsform/fromInstrument.ts index 5f21882..07b1948 100644 --- a/src/xlsform/fromInstrument.ts +++ b/src/xlsform/fromInstrument.ts @@ -218,12 +218,12 @@ function emitGroup(group: GroupItem, ctx: EmitCtx): void { }; const hint = ctx.label(group.hint); if (hint) row.hint = htmlLabel(hint); - if (group.appearance) row.appearance = group.appearance; + if (group.appearance) row.appearance = appearanceCell(group); if (group.relevant) row.relevant = group.relevant; Object.assign(row, group.columns); ctx.survey.push(row); emitItems(group.children, ctx); - ctx.survey.push({ type: 'end_group' }); + ctx.survey.push({ type: 'end_group', ...group.endColumns }); } function emitQuestion(q: QuestionItem, siblings: Item[], ctx: EmitCtx): void { @@ -285,7 +285,7 @@ function questionRow(q: QuestionItem, ctx: EmitCtx): SurveyRow { const plain: Array<[string, string]> = [ ['required', q.required ? requiredCell(q) : ''], ['default', q.default], - ['appearance', q.appearance], + ['appearance', appearanceCell(q)], ['parameters', q.parameters], ['relevant', q.relevant], ['constraint', q.constraint], @@ -296,6 +296,13 @@ function questionRow(q: QuestionItem, ctx: EmitCtx): SurveyRow { return row; } +/** The appearance cell: as the source row had it (`Minimal`), else the model's. */ +function appearanceCell(item: Item): string { + const cell = item.row['appearance']; + const raw = typeof cell === 'string' ? cell.trim() : ''; + return raw.toLowerCase() === item.appearance && raw ? raw : item.appearance; +} + /** The `required` cell: as the source row had it (`TRUE`), else `yes`. */ function requiredCell(q: QuestionItem): string { const cell = q.row['required']; diff --git a/tests/ts/contract/canonicalInstrument.ts b/tests/ts/contract/canonicalInstrument.ts index 8139d86..8b2dbc4 100644 --- a/tests/ts/contract/canonicalInstrument.ts +++ b/tests/ts/contract/canonicalInstrument.ts @@ -9,6 +9,7 @@ import { TYPE_MAP } from '../../../src/generated/DdiMappings.js'; import { isMetadataType } from '../../../src/conventions/metadata.js'; import { OTHER_CODE } from '../../../src/conventions/other.js'; import { isExclusive } from '../../../src/conventions/exclusive.js'; +import { allColumns } from '../../../src/conventions/columns.js'; import { languageTagOf } from '../../../src/utils/languageUtils.js'; import type { Instrument, @@ -45,6 +46,13 @@ function text(t: Text | undefined, ctx: Ctx): Record { return out; } +/** The appearance cell: as authored when only its case differs. */ +function appearance(item: Item): string { + const cell = item.row['appearance']; + const raw = typeof cell === 'string' ? cell.trim() : ''; + return raw && raw.toLowerCase() === item.appearance ? raw : item.appearance; +} + /** The `required` cell a required question has: as authored, else `yes`. */ function required(q: QuestionItem): string | false { if (!q.required) return false; @@ -76,7 +84,7 @@ function question(q: QuestionItem, ctx: Ctx): Canon { constraintMessage: text(q.constraintMessage, ctx), required: required(q), default: q.default, - appearance: q.appearance, + appearance: appearance(q), parameters: q.parameters.trim(), columns: q.columns ?? {}, }; @@ -86,15 +94,14 @@ function items(list: Item[], ctx: Ctx): Canon[] { const out: Canon[] = []; for (const item of list) { if (item.kind === 'group') { - // A group with nothing in it is not carried. - if (!item.children.length) continue; out.push({ group: item.name, label: text(item.label, ctx), hint: text(item.hint, ctx), relevant: item.relevant.trim(), - appearance: item.appearance, + appearance: appearance(item), columns: item.columns ?? {}, + endColumns: item.endColumns ?? {}, children: items(item.children, ctx), }); } else if (!kept(item)) { @@ -111,7 +118,7 @@ function items(list: Item[], ctx: Ctx): Canon[] { label: text(item.label, ctx), hint: text(item.hint, ctx), relevant: item.relevant.trim(), - appearance: item.appearance, + appearance: appearance(item), columns: item.columns ?? {}, }); } else { @@ -156,14 +163,14 @@ function lists(ctx: Ctx): Canon { return out; } +/** Settings by column: one per language for a `{ lang: text }` one. */ function settings(instrument: Instrument, ctx: Ctx): Canon { - const out: Canon = {}; - for (const [key, value] of Object.entries(instrument.settings)) { - if (typeof value === 'string' || typeof value === 'number') { - if (String(value).trim()) out[key] = String(value).trim(); - } else if (key === 'form_title' && value && typeof value === 'object') { - out[key] = text(value as Text, ctx); - } + const { form_title: title, ...rest } = instrument.settings; + const out: Canon = { ...allColumns(rest) }; + if (title && typeof title === 'object') { + out['form_title'] = text(title as Text, ctx); + } else if (typeof title === 'string' || typeof title === 'number') { + if (String(title).trim()) out['form_title'] = String(title).trim(); } return out; } diff --git a/tests/ts/contract/ddiRoundtripGenerated.test.ts b/tests/ts/contract/ddiRoundtripGenerated.test.ts index 4e47723..2867220 100644 --- a/tests/ts/contract/ddiRoundtripGenerated.test.ts +++ b/tests/ts/contract/ddiRoundtripGenerated.test.ts @@ -172,7 +172,7 @@ const block: fc.Arbitrary = fc.oneof( t: texts, unlabelled: fc.boolean(), relevant: fc.boolean(), - inner: fc.array(question, { minLength: 1, maxLength: 3 }), + inner: fc.array(question, { maxLength: 3 }), nested: fc.array(question, { maxLength: 2 }), }), }, @@ -218,7 +218,15 @@ function addColumns(survey: Row[], choices: Row[], multilingual: boolean) { }; survey.forEach((row, i) => { const type = String(row['type']); - if (type === 'end_group') return; + if (type === 'end_group') { + if (i % 2 === 0) row['$kuid'] = `k${i}`; + return; + } + // The appearance cell's case, as some authors write it. + if (typeof row['appearance'] === 'string' && i % 3 === 2) { + const a = row['appearance']; + row['appearance'] = a.charAt(0).toUpperCase() + a.slice(1); + } if (i % 2 === 0) media(row, String(row['name'])); if (type === 'text' && i % 3 === 0) row['read_only'] = 'yes'; if (type.startsWith('select_one') && i % 3 === 1) { diff --git a/tests/ts/unit/ddi/fields.test.ts b/tests/ts/unit/ddi/fields.test.ts index 0a5471e..d859170 100644 --- a/tests/ts/unit/ddi/fields.test.ts +++ b/tests/ts/unit/ddi/fields.test.ts @@ -138,7 +138,8 @@ describe('cdl: notes', () => { ]); const v = varXml(xml, 'q'); expect(v).toContain('no'); - expect(v).toContain('minimal'); + // As authored (#160); readers compare it lowercased. + expect(v).toContain('Minimal'); // The cell as authored (#160); its guidance_hint is the ivuInstr too. expect(v).toContain( 'randomize=true; guidance_hint=Only once', diff --git a/tests/ts/unit/ddi/fromDdi.test.ts b/tests/ts/unit/ddi/fromDdi.test.ts index f9bd0c7..1321bc3 100644 --- a/tests/ts/unit/ddi/fromDdi.test.ts +++ b/tests/ts/unit/ddi/fromDdi.test.ts @@ -239,3 +239,54 @@ describe("columns the model doesn't lift (#160)", () => { expect(back.choices[0]).toMatchObject({ f: 'a', 'media::image': 'y.png' }); }); }); + +describe('the last cells (#160)', () => { + const back = ( + survey: Record[], + settings: Record = {}, + ) => + ddiToXlsform(buildDdiXml(survey, [], { prodDate: '2020-01-01', settings })); + + test('an empty group keeps its place', () => { + const { survey } = back([ + { type: 'text', name: 'a', label: 'A' }, + { type: 'begin_group', name: 'empty', label: 'Empty' }, + { type: 'end_group' }, + { type: 'text', name: 'b', label: 'B' }, + ]); + expect(survey.map((r) => r.name ?? r.type)).toEqual([ + 'a', + 'empty', + 'end_group', + 'b', + ]); + }); + + test("an end_group row's cells", () => { + const { survey } = back([ + { type: 'begin_group', name: 'g', label: 'G' }, + { type: 'text', name: 'a', label: 'A' }, + { type: 'end_group', name: 'g', $kuid: 'k1' }, + ]); + expect(survey[2]).toEqual({ type: 'end_group', name: 'g', $kuid: 'k1' }); + }); + + test("an appearance's case", () => { + const { survey } = back([ + { type: 'text', name: 'a', label: 'A', appearance: 'Multiline' }, + ]); + expect(survey[0]).toMatchObject({ appearance: 'Multiline' }); + }); + + test('a setting per language, and a boolean one', () => { + const { settings } = back([{ type: 'text', name: 'a', label: 'A' }], { + instance_name: { de: 'Name', en: 'Name (en)' }, + allow_choice_duplicates: true, + }); + expect(settings[0]).toMatchObject({ + 'instance_name::de': 'Name', + 'instance_name::en': 'Name (en)', + allow_choice_duplicates: 'true', + }); + }); +});