diff --git a/HANDOVER_CDL_WP_EINS.md b/HANDOVER_CDL_WP_EINS.md index 7f137d7..be018f5 100644 --- a/HANDOVER_CDL_WP_EINS.md +++ b/HANDOVER_CDL_WP_EINS.md @@ -23,11 +23,11 @@ Companion document: [`HANDOVER_QWAC.md`](HANDOVER_QWAC.md). Check every content claim against this. From `src/generated/TypeMappings.ts` in this repo: -| Supported question types | Registered but **not** convertible | Structural | -|---|---|---| -| `text` (alias `string`), `integer` (alias `int`), `decimal`, `date`, `time`, `note`, `select_one`, `select_multiple` | `select_one_from_file`, `select_multiple_from_file` | `begin_group` / `end_group` | +| Supported question types | Structural | +|---|---| +| `text` (alias `string`), `integer` (alias `int`), `decimal`, `range`, `date`, `time`, `note`, `select_one`, `select_multiple`, `select_one_from_file`, `select_multiple_from_file` (registered vocabularies only, e.g. `iso_3166_1.csv`) | `begin_group` / `end_group` | -Anything else — `rank`, `range`, `calculate`, `image`, `audio`, `geopoint`, +Anything else — `rank`, `calculate`, `image`, `audio`, `geopoint`, `dateTime` — is **not registered at all**: the converters reject such a form rather than approximating it. Same for unregistered appearances, identifiers over the sanitisation limits, group nesting deeper than three levels, and selects @@ -68,9 +68,10 @@ Everything below was checked against the working copy at the time of writing. `src/content/pages/xlsform-standard.mdx` (919 lines) documents, as if usable: `## Fragetypen` → `### Rang` (line 226), `### Bereich` (253), `### Mehrfachauswahl aus Datei` (187), `## Berechnung` (523), plus -`### Externe CSV-Daten` (395). None of those convert: `rank`, `range` and -`calculate` are not in the registry at all, and `select_*_from_file` is -registered as *not* convertible. +`### Externe CSV-Daten` (395). `rank` and `calculate` are not in the registry +at all. `range` converts (since formtransform#33), but LimeSurvey enforces only +its bounds, not its `step`. `select_*_from_file` converts only with a +registered vocabulary, not an arbitrary external CSV. `src/content/pages/fragetypen.mdx` is in better shape — it pulls live examples from qwacback via `` — but its ids diff --git a/HANDOVER_QWAC.md b/HANDOVER_QWAC.md index 3b209a2..ca24967 100644 --- a/HANDOVER_QWAC.md +++ b/HANDOVER_QWAC.md @@ -130,13 +130,11 @@ pinned to one version. The plan holds, but: `lstsv2xlsform` now exist, which the brief predates. - **Coverage handoff:** survey2ddi's qwacback equivalence test now lives in formtransform (`tests/live/qwacback/`, formtransform#14). Against qwacback - `main` (`c99de96`), 13 of 15 types match. The two that differ change - qwacback's DDI when it swaps converters: - - `range`: qwacback emits numeric/`contin`, formtransform text (unregistered - type, formtransform#33). - - `note`: qwacback emits a ``, formtransform none. This is intended: a - note stores no response, so formtransform folds it into - ``/``. + `main` (`c99de96`), 14 of 15 types match (`range` since formtransform#33). + The one that differs changes qwacback's DDI when it swaps converters: + `note`. qwacback emits a `` for it, formtransform none. This is + intended: a note stores no response, so formtransform folds it into + ``/``. qwacback's own converter tests go with the Go converter. diff --git a/codegen/emit_ts.py b/codegen/emit_ts.py index d644aa2..0cb11d0 100644 --- a/codegen/emit_ts.py +++ b/codegen/emit_ts.py @@ -29,6 +29,12 @@ def generate_typescript(registry: dict[str, Any], output: Path): " answerClass?: 'A' | 'SQ' | null;", " requiresListName?: boolean;", " dateFormat?: string;", + " /** XLSForm `parameters` keys this type reads, with their defaults. */", + " parameters?: Record;", + " /** XLSForm parameter key → LimeSurvey question attribute. */", + " parameterAttributes?: Record;", + " /** Set `attribute` to 1 when all `whenWhole` parameters are whole numbers. */", + " integerOnly?: { attribute: string; whenWhole: string[] };", "}", "", "export const TYPE_MAPPINGS: Record = {", @@ -53,6 +59,12 @@ def emit(name: str, kind: str, data: dict): lines.append(" requiresListName: true,") if ls.get("dateFormat"): lines.append(f" dateFormat: {json.dumps(ls['dateFormat'])},") + if xls.get("parameters"): + lines.append(f" parameters: {json.dumps(xls['parameters'])},") + if ls.get("parameterAttributes"): + lines.append(f" parameterAttributes: {json.dumps(ls['parameterAttributes'])},") + if ls.get("integerOnly"): + lines.append(f" integerOnly: {json.dumps(ls['integerOnly'])},") lines.append(" },") kind_map = { diff --git a/registry/entities/range/ddi.xml b/registry/entities/range/ddi.xml new file mode 100644 index 0000000..1a6b104 --- /dev/null +++ b/registry/entities/range/ddi.xml @@ -0,0 +1,32 @@ + + + + + + range + + + 2026-09-24 + + + + + + data.csv + + 0 + + Comma-separated values (CSV) + text/csv + + + + + + Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll) + + Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll) + + + + diff --git a/registry/entities/range/definition.jsonld b/registry/entities/range/definition.jsonld new file mode 100644 index 0000000..7cfac75 --- /dev/null +++ b/registry/entities/range/definition.jsonld @@ -0,0 +1,53 @@ +{ + "@context": { + "@version": 1.1, + "@vocab": "https://civic-data.de/survey/", + "skos": "http://www.w3.org/2004/02/skos/core#", + "type": "https://civic-data.de/survey/type/", + "variant": "https://civic-data.de/survey/variant/", + "composite": "https://civic-data.de/survey/composite/" + }, + "@graph": [ + { + "@id": "type:range", + "@type": "QuestionType", + "skos:prefLabel": "Range", + "xlsform": { + "typeString": "range", + "parameters": { + "start": "1", + "end": "10", + "step": "1" + } + }, + "limesurvey": { + "typeCode": "N", + "supportsOther": false, + "answerClass": null, + "parameterAttributes": { + "start": "min_num_value_n", + "end": "max_num_value_n" + }, + "integerOnly": { + "attribute": "num_value_int_only", + "whenWhole": ["start", "step"] + } + }, + "ddi": { + "intrvl": "contin", + "formatType": "numeric", + "responseDomainType": "numeric" + }, + "kobo": { + "nativeSupport": true + }, + "constraints": { + "maxNameLength": 20, + "namePattern": "^[a-zA-Z0-9]+$" + }, + "exampleDir": "registry/entities/range", + "examplePath": "registry/entities/range/xlsform.json", + "useWhen": "When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself." + } + ] +} diff --git a/registry/entities/range/fixtures/xlsform.json b/registry/entities/range/fixtures/xlsform.json new file mode 100644 index 0000000..abd3e0f --- /dev/null +++ b/registry/entities/range/fixtures/xlsform.json @@ -0,0 +1,11 @@ +{ + "survey": [ + { + "type": "range", + "name": "zufriedenheit", + "label": "Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll)", + "parameters": "start=0 end=10 step=1" + } + ], + "choices": [] +} diff --git a/registry/entities/range/tsv.tsv b/registry/entities/range/tsv.tsv new file mode 100644 index 0000000..9fa0535 --- /dev/null +++ b/registry/entities/range/tsv.tsv @@ -0,0 +1,7 @@ +class type/scale name relevance text help language validation em_validation_q mandatory other default same_default hidden hide_tip min_num_value_n max_num_value_n num_value_int_only +S language 1 en en +S format 1 A en +S shownoanswer 1 N en +SL surveyls_title 1 Untitled Survey en +G Questions 1 Questions en +Q N zufriedenheit 1 Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll) en 1 0 10 1 \ No newline at end of file diff --git a/registry/schema.jsonld b/registry/schema.jsonld index e3b6f53..7877236 100644 --- a/registry/schema.jsonld +++ b/registry/schema.jsonld @@ -109,6 +109,15 @@ "ls-transformer" ], "description": "True for select_* variants where the type cell needs a choice list name." + }, + "parameters": { + "type": "object", + "required": false, + "consumedBy": [ + "ls-transformer", + "docs" + ], + "description": "Keys this type reads from the XLSForm `parameters` column (`start=0 end=100 step=5`), mapped to the value used when the row omits the key (pyxform's defaults)." } } }, @@ -161,6 +170,22 @@ "consumedBy": [ "docs" ] + }, + "parameterAttributes": { + "type": "object", + "required": false, + "consumedBy": [ + "ls-transformer" + ], + "description": "XLSForm parameter key → LS question attribute that receives its value, e.g. `range`'s `start` → `min_num_value_n`. Defaults from `xlsform.parameters` apply." + }, + "integerOnly": { + "type": "object", + "required": false, + "consumedBy": [ + "ls-transformer" + ], + "description": "`{attribute, whenWhole}`: set LS `attribute` to `1` when every parameter in `whenWhole` is a whole number, so the question accepts integers only (e.g. `range` with integer `start` and `step`)." } } }, diff --git a/skills/cdl-survey-types/SKILL.md b/skills/cdl-survey-types/SKILL.md index a55772d..54f846b 100644 --- a/skills/cdl-survey-types/SKILL.md +++ b/skills/cdl-survey-types/SKILL.md @@ -17,6 +17,7 @@ Generated from the [`formtransform`](https://github.com/) — the single source | `decimal` | Decimal/Float | When collecting open decimal numeric values without predefined categories (e.g. height in metres, temperature). | | `integer` | Integer | When collecting open integer values without predefined categories, where respondents provide a raw number (e.g. age, household size). | | `note` | Note (Display Text) | When displaying non-interactive informational text, instructions, or section headers that require no respondent answer. | +| `range` | Range | When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself. | | `select_multiple` | Select Multiple | When options are not mutually exclusive and respondents may select any number of applicable answers (e.g. languages spoken, devices owned). | | `select_multiple_from_file` | Select Multiple (from file) | When selecting from a long non-exclusive list of options maintained as a controlled vocabulary, allowing multiple selections. | | `select_one` | Select One | When answer categories are exhaustive and mutually exclusive, and exactly one option should be selected (e.g. education level, employment status). | diff --git a/skills/cdl-survey-types/references/question-types.md b/skills/cdl-survey-types/references/question-types.md index 985fc39..1dcb366 100644 --- a/skills/cdl-survey-types/references/question-types.md +++ b/skills/cdl-survey-types/references/question-types.md @@ -28,6 +28,12 @@ **Constraints:** variable `name` ≤ 20 chars, `^[a-zA-Z0-9]+$` +## `range` — Range + +**Use when:** When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself. + +**Constraints:** variable `name` ≤ 20 chars, `^[a-zA-Z0-9]+$` + ## `select_multiple` — Select Multiple **Use when:** When options are not mutually exclusive and respondents may select any number of applicable answers (e.g. languages spoken, devices owned). diff --git a/src/generated/DdiMappings.ts b/src/generated/DdiMappings.ts index 4f96061..e60fa1a 100644 --- a/src/generated/DdiMappings.ts +++ b/src/generated/DdiMappings.ts @@ -26,6 +26,7 @@ export const TYPE_MAP: Record = { integer: "integer", int: "integer", note: "note", + range: "range", select_multiple: "select_multiple", select_one: "select_one", text: "text", @@ -51,6 +52,7 @@ export const DDI_TYPE_MAP: Record = { date: ["discrete", "character"], decimal: ["contin", "numeric"], integer: ["contin", "numeric"], + range: ["contin", "numeric"], select_multiple: ["discrete", "numeric"], select_one: ["discrete", "numeric"], text: ["discrete", "character"], @@ -64,6 +66,7 @@ export const RESPONSE_DOMAIN_MAP: Record = { date: "text", decimal: "numeric", integer: "numeric", + range: "numeric", select_multiple: "multiple", select_one: "category", text: "text", @@ -77,6 +80,7 @@ export const MEASURE_MAP: Record = { date: "interval", decimal: "ratio", integer: "ratio", + range: "ratio", select_multiple: "nominal", select_one: "nominal", time: "interval", diff --git a/src/generated/QuestionTypes.ts b/src/generated/QuestionTypes.ts index 00f3c9d..380217a 100644 --- a/src/generated/QuestionTypes.ts +++ b/src/generated/QuestionTypes.ts @@ -120,6 +120,16 @@ export const QUESTION_TYPES = { typeString: "note", constraints: {"maxNameLength": 20, "namePattern": "^[a-zA-Z0-9]+$"}, }, + range: { + id: "type:range", + label: "Range", + kind: "question", + useWhen: "When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself.", + isVariant: false, + isComposite: false, + typeString: "range", + constraints: {"maxNameLength": 20, "namePattern": "^[a-zA-Z0-9]+$"}, + }, select_multiple: { id: "type:select_multiple", label: "Select Multiple", diff --git a/src/generated/TypeMappings.ts b/src/generated/TypeMappings.ts index 0b1f95c..0c74955 100644 --- a/src/generated/TypeMappings.ts +++ b/src/generated/TypeMappings.ts @@ -14,6 +14,12 @@ export interface TypeMapping { answerClass?: 'A' | 'SQ' | null; requiresListName?: boolean; dateFormat?: string; + /** XLSForm `parameters` keys this type reads, with their defaults. */ + parameters?: Record; + /** XLSForm parameter key → LimeSurvey question attribute. */ + parameterAttributes?: Record; + /** Set `attribute` to 1 when all `whenWhole` parameters are whole numbers. */ + integerOnly?: { attribute: string; whenWhole: string[] }; } export const TYPE_MAPPINGS: Record = { @@ -64,6 +70,14 @@ export const TYPE_MAPPINGS: Record = { limeSurveyType: "X", supported: true, }, + range: { + kind: "question", + limeSurveyType: "N", + supported: true, + parameters: {"start": "1", "end": "10", "step": "1"}, + parameterAttributes: {"start": "min_num_value_n", "end": "max_num_value_n"}, + integerOnly: {"attribute": "num_value_int_only", "whenWhole": ["start", "step"]}, + }, select_multiple: { kind: "question", limeSurveyType: "M", diff --git a/src/lstsv/serialize.ts b/src/lstsv/serialize.ts index b159b77..3386b4b 100644 --- a/src/lstsv/serialize.ts +++ b/src/lstsv/serialize.ts @@ -26,6 +26,12 @@ interface TSVRow { // questions — controls whether the widget shows a date, a time, or both. // Sourced from the registry's limesurvey.dateFormat; empty elsewhere. date_format?: string; + // LimeSurvey numeric-input (N) attributes, from the XLSForm `parameters` + // column via the registry (limesurvey.parameterAttributes / integerOnly). + // Only `range` sets them. + min_num_value_n?: string; + max_num_value_n?: string; + num_value_int_only?: string; } export class TSVGenerator { @@ -57,7 +63,14 @@ export class TSVGenerator { // Attribute columns are only emitted when at least one row carries them, // so surveys that don't use a given attribute keep a lean TSV. The order // here is the column order in the output. - for (const attr of ['cssclass', 'hide_tip', 'date_format'] as const) { + for (const attr of [ + 'cssclass', + 'hide_tip', + 'date_format', + 'min_num_value_n', + 'max_num_value_n', + 'num_value_int_only', + ] as const) { if (this.rows.some((r) => r[attr])) { headers.push(attr); } diff --git a/src/pipelines/lstsv2xlsform/README.md b/src/pipelines/lstsv2xlsform/README.md index 31be32c..766bfdc 100644 --- a/src/pipelines/lstsv2xlsform/README.md +++ b/src/pipelines/lstsv2xlsform/README.md @@ -34,7 +34,8 @@ CLI: `formtransform lstsv2xlsform `. | LimeSurvey input | XLSForm output | Notes | | --- | --- | --- | -| type L/M/N/D/S/X, plus F | `select_one` / `select_multiple` / `integer`\|`decimal` / `date`\|`time` / `text` / `note`, plus grid | `N` is lossy — see below | +| type L/M/N/D/S/X, plus F | `select_one` / `select_multiple` / `integer`\|`decimal`\|`range` / `date`\|`time` / `text` / `note`, plus grid | `N` is lossy — see below | +| `N` with `min_num_value_n` + `max_num_value_n` | `range`, `parameters` rebuilt from the bounds | registry `limesurvey.parameterAttributes`; see below for `step` | | A / SQ rows | `choices` (list_name = question name) | codes exact under strict validation | | `other=Y` | re-add the `other` choice + `${base}_other` companion + relevance | shares `OTHER_CODE`/`OTHER_SUFFIX`/`otherLabelFor` with `lstsv/toVariables.ts` | | `cssclass=cdlvocab-` | `select_*_from_file .csv`, inlined A rows dropped | shares `vocabFromCssClass` with the DDI path | @@ -93,6 +94,11 @@ listed here must match exactly. identical bar the key). No signal exists to read back without changing the LimeSurvey side. `lstsvRowsToXlsform` emits the canonical `decimal`; `integer` fixtures round-trip with `type` differing. + + An `N` with both bound attributes is read back as `range` (the only type + that writes them). Its `step` is not stored: `num_value_int_only=1` comes + back as `step=1`, so `start=0 end=100 step=5` returns as + `start=0 end=100 step=1`, and a fractional step is dropped. 2. **Choice `list_name` is synthesized, not recovered.** Applies to *every* `select_one`/`select_multiple`, not just grids: the authored list name (`"select_one quelle"`) is never written to the TSV at all — only the diff --git a/src/pipelines/lstsv2xlsform/toXlsform.ts b/src/pipelines/lstsv2xlsform/toXlsform.ts index 358e90b..9e939e8 100644 --- a/src/pipelines/lstsv2xlsform/toXlsform.ts +++ b/src/pipelines/lstsv2xlsform/toXlsform.ts @@ -17,7 +17,9 @@ * array's name for a grid) — the original authored list name is not * stored anywhere in the TSV. * - `N` (integer vs decimal) has no distinguishing signal in LimeSurvey; - * `decimal` is the canonical default. + * `decimal` is the canonical default. An `N` carrying the bound attributes + * a parameterized type declares (`range`) becomes that type; its `step` is + * only known to be 1 or fractional. * - a plain (non-grid) group's machine `name` is not recoverable — only its * rendered label is in the TSV — so it is slugified from the label. */ @@ -81,6 +83,36 @@ function canonicalTypeFor(lsCode: string): string { ); } const CANONICAL_TEXT_TYPE = canonicalTypeFor('S'); + +/** Types that write their XLSForm `parameters` into LS attributes (`range`). */ +const PARAMETERIZED_TYPES = Object.entries(TYPE_MAPPINGS).filter( + ([, m]) => m.parameterAttributes, +); + +/** + * The parameterized type whose LS attributes this row carries, with its + * `parameters` cell rebuilt. `step` isn't stored: the integer-only flag means + * a whole step (written as 1), otherwise it's left to the default. + */ +function resolveParameterized( + lsCode: string, + row: Row, +): ResolvedType | undefined { + for (const [xfType, m] of PARAMETERIZED_TYPES) { + if (m.limeSurveyType !== lsCode) continue; + const attrs = Object.entries(m.parameterAttributes ?? {}); + if (!attrs.every(([, attr]) => cell(row, attr) !== '')) continue; + const params = attrs.map(([key, attr]) => `${key}=${cell(row, attr)}`); + const intOnly = m.integerOnly; + if (intOnly && cell(row, intOnly.attribute) === '1') { + for (const key of intOnly.whenWhole) { + if (!m.parameterAttributes?.[key]) params.push(`${key}=1`); + } + } + return { base: xfType, parameters: params.join(' ') }; + } + return undefined; +} const CANONICAL_NUMERIC_TYPE = canonicalTypeFor('N'); /** Appearance name → LS type-override code it produces (e.g. `minimal` → `!`). */ @@ -92,10 +124,12 @@ for (const [name, spec] of Object.entries(APPEARANCES)) { interface ResolvedType { base: string; appearance?: string; + parameters?: string; } -/** Resolve a Q-row's `type/scale` (+ `date_format` hint) to an XLSForm type. */ -function resolveType(lsCode: string, dateFormat: string): ResolvedType { +/** Resolve a Q-row's `type/scale` (+ attribute hints) to an XLSForm type. */ +function resolveType(lsCode: string, row: Row): ResolvedType { + const dateFormat = cell(row, 'date_format'); const overrideAppearance = APPEARANCE_BY_OVERRIDE[lsCode]; if (overrideAppearance) { const spec = APPEARANCES[overrideAppearance]; @@ -110,7 +144,9 @@ function resolveType(lsCode: string, dateFormat: string): ResolvedType { case 'M': return { base: 'select_multiple' }; case 'N': - return { base: CANONICAL_NUMERIC_TYPE }; + return ( + resolveParameterized(lsCode, row) ?? { base: CANONICAL_NUMERIC_TYPE } + ); case 'D': return { base: @@ -272,7 +308,8 @@ interface PlainQuestion { name: string; lsType: string; cssclass: string; - dateFormat: string; + /** The Q row itself, for type-resolving attributes (date_format, bounds). */ + row: Row; mandatory: string; defaultVal: string; otherFlag: boolean; @@ -315,7 +352,7 @@ function readLogicalQuestions( name, lsType, cssclass: cell(row, 'cssclass'), - dateFormat: cell(row, 'date_format'), + row, mandatory: cell(row, 'mandatory'), defaultVal: cell(row, 'default'), otherFlag: cell(row, 'other') === 'Y', @@ -635,6 +672,7 @@ function buildPlainQuestionRow( if (item.mandatory === 'Y') row.required = 'yes'; if (item.defaultVal) row.default = item.defaultVal; if (resolved.appearance) row.appearance = resolved.appearance; + if (resolved.parameters) row.parameters = resolved.parameters; const relevant = reverseRelevance(item.relevance, ctx.selectCtx); if (relevant) row.relevant = relevant; const constraint = reverseConstraint(item.emValidationQ); @@ -656,7 +694,7 @@ function emitQuestions( emitArrayQuestion(item, choicesByName, ctx); continue; } - const resolved = resolveType(item.lsType, item.dateFormat); + const resolved = resolveType(item.lsType, item.row); const { type } = composeTypeWithList( resolved.base, item, diff --git a/src/pipelines/xlsform2lstsv/constants.ts b/src/pipelines/xlsform2lstsv/constants.ts index ed6d7fd..c1f477a 100644 --- a/src/pipelines/xlsform2lstsv/constants.ts +++ b/src/pipelines/xlsform2lstsv/constants.ts @@ -48,6 +48,9 @@ export interface TSVRowData { cssclass?: string; hide_tip?: string; date_format?: string; + min_num_value_n?: string; + max_num_value_n?: string; + num_value_int_only?: string; } // Internal state interfaces diff --git a/src/pipelines/xlsform2lstsv/index.ts b/src/pipelines/xlsform2lstsv/index.ts index 5ffeeb9..6272372 100644 --- a/src/pipelines/xlsform2lstsv/index.ts +++ b/src/pipelines/xlsform2lstsv/index.ts @@ -25,6 +25,7 @@ import { TranspilerHelper } from './transpilerHelper.js'; import { FieldNameHandler } from './fieldNameHandler.js'; import { AppearanceHandler } from './appearanceHandler.js'; import { registeredFileChoices } from '../../vocab.js'; +import { parameterAttributes } from './parameters.js'; // Registry appearances are an allowlist: only 'handled' entries are // registered. Anything else (or a handled appearance on the wrong type) @@ -411,6 +412,11 @@ export class XLSFormToTSVConverter { lsType, fields, cdlVocab, + attributes: parameterAttributes( + xfTypeInfo.base, + row['parameters'], + questionName, + ), }; this.rowEmitter.emitForEachLanguage((lang) => @@ -528,7 +534,7 @@ export class XLSFormToTSVConverter { questionName: string, ctx: QuestionRowContext, ): Partial & Pick { - const { lsType, fields, cdlVocab } = ctx; + const { lsType, fields, cdlVocab, attributes } = ctx; let text: string; if (fields.isCalculate) { text = `{${fields.calculationExpr}}`; @@ -562,6 +568,8 @@ export class XLSFormToTSVConverter { // It's a machine hook only (no styling effect); the faithful reference // still lives in DDI's concept/@vocab. Empty for all other questions. ...(cdlVocab ? { cssclass: `cdlvocab-${cdlVocab}` } : {}), + // Bounds etc. from the `parameters` column (range), per the registry. + ...attributes, }; } @@ -584,4 +592,6 @@ interface QuestionRowContext { isCalculate: boolean; }; cdlVocab: string; + /** LS question attributes from the `parameters` column (parameters.ts). */ + attributes: Record; } diff --git a/src/pipelines/xlsform2lstsv/parameters.ts b/src/pipelines/xlsform2lstsv/parameters.ts new file mode 100644 index 0000000..404a124 --- /dev/null +++ b/src/pipelines/xlsform2lstsv/parameters.ts @@ -0,0 +1,54 @@ +/** + * XLSForm `parameters` column → LimeSurvey question attributes, driven by the + * registry (`xlsform.parameters`, `limesurvey.parameterAttributes`, + * `limesurvey.integerOnly`). Used by `range`: `start=0 end=100 step=5` becomes + * `min_num_value_n=0`, `max_num_value_n=100`, `num_value_int_only=1`. + */ +import { TYPE_MAPPINGS } from '../../generated/TypeMappings.js'; + +/** Parse `key=value` pairs separated by spaces, commas or semicolons. */ +export function parseParameters(cell: unknown): Record { + const out: Record = {}; + if (typeof cell !== 'string' && typeof cell !== 'number') return out; + for (const token of String(cell).split(/[\s,;]+/)) { + const eq = token.indexOf('='); + if (eq <= 0) continue; + out[token.slice(0, eq).trim().toLowerCase()] = token.slice(eq + 1).trim(); + } + return out; +} + +const isNumber = (v: string) => v !== '' && Number.isFinite(Number(v)); + +/** + * The LimeSurvey attributes a row's `parameters` produce for its type, or `{}` + * when the type declares none. Throws when a declared parameter isn't a number: + * LimeSurvey would otherwise import a question without its bounds. + */ +export function parameterAttributes( + baseType: string, + cell: unknown, + questionName: string, +): Record { + const mapping = TYPE_MAPPINGS[baseType]; + if (!mapping?.parameters) return {}; + + const values = { ...mapping.parameters, ...parseParameters(cell) }; + for (const key of Object.keys(mapping.parameters)) { + if (!isNumber(values[key])) { + throw new Error( + `${baseType} '${questionName}': parameter ${key}=${values[key]} is not a number`, + ); + } + } + + const attrs: Record = {}; + for (const [key, attr] of Object.entries(mapping.parameterAttributes ?? {})) { + attrs[attr] = String(Number(values[key])); + } + const intOnly = mapping.integerOnly; + if (intOnly?.whenWhole.every((k) => Number.isInteger(Number(values[k])))) { + attrs[intOnly.attribute] = '1'; + } + return attrs; +} diff --git a/tests/README.md b/tests/README.md index 981e5a7..ad75fff 100644 --- a/tests/README.md +++ b/tests/README.md @@ -148,8 +148,9 @@ reverse subset validator each hardcoded the appearance overrides they knew new override teaches both sides automatically. No registry entity uses `minimal`, which is exactly why single-question fixtures could not see it. -`testA` ships no snapshots on purpose — it carries an unimplemented `range` type, -so the bless script skips it and the suite skips it with it. +`testA` is a real survey with a `range` question (`start=0 end=100 step=5`). +Its reverse DDI renames three `select_multiple` variables through LimeSurvey's +5-character answer codes, which `REVERSE_DDI_STRUCTURAL_DIFF` records. ### Live LimeSurvey import (`tests/live/limesurvey/test_registry_entities.py`) @@ -187,8 +188,8 @@ Ported from survey2ddi (formtransform#14). For every answer type qwacback supports, the same XLSForm goes through `buildDdiXml` and qwacback's `POST /api/convert/xlsform-to-ddi`, and the ``/`` shapes are compared. qwacback returns a bare `` or `` when there's only one, -so the test wraps it in a ``. Two cases are strict xfails: `range` -(#33) and `note` (by design: formtransform emits no `` for a note). +so the test wraps it in a ``. `note` is a strict xfail, by design: +formtransform emits no `` for a note. The fixture starts qwacback from its own compose file, or uses `QWACBACK_URL`. `ghcr.io/correlaid/qwacback` is private: log in to ghcr.io, or build it diff --git a/tests/fixtures/surveys/testA/ddi.xml b/tests/fixtures/surveys/testA/ddi.xml new file mode 100644 index 0000000..d918798 --- /dev/null +++ b/tests/fixtures/surveys/testA/ddi.xml @@ -0,0 +1,516 @@ + + + + + + testA + + + 2020-01-01 + + + Hey! Wir möchten gerne wissen, wie TestWerk für dich ist und was es dir bringt. Deine Antworten sind anonym und helfen uns, unser Angebot zu verbessern. Es gibt keine richtigen oder falschen Antworten - sag einfach deine Meinung! + Vielen Dank! Deine Meinung hilft uns sehr weiter! + + + + data.csv + + 0 + + Comma-separated values (CSV) + text/csv + + + + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) + + + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) + Holzwerkstatt + + + 0 + + + 1 + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich): Holzwerkstatt + + + + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) + Metallwerkstatt + + + 0 + + + 1 + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich): Metallwerkstatt + + + + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) + Textilwerkstatt + + + 0 + + + 1 + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich): Textilwerkstatt + + + + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) + Medienwerkstatt/Digital + + + 0 + + + 1 + + Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich): Medienwerkstatt/Digital + + + + + Wie lange kommst du schon zu TestWerk? + + + 1 + Erst seit Kurzem (1-2 Monate) + + + 2 + Ein halbes Jahr + + + 3 + Ca. ein Jahr + + + 4 + Länger als ein Jahr aber kürzer als drei Jahre + + + l_nger_als_drei_jahre + Länger als drei Jahre + + Wie lange kommst du schon zu TestWerk? + + + + + Wie oft kommst du ungefähr? + + + 1 + 1-2 Mal insgesamt + + + 2 + Ab und zu (1x im Monat) + + + 3 + Regelmäßig (mehrmals im Monat) + + + 4 + Sehr oft (jede Woche oder öfter) + + Wie oft kommst du ungefähr? + + + + + Ca. wie viele Stunden warst du insgesamt bei TestWerk? (grobe Schätzung reicht) + + Ca. wie viele Stunden warst du insgesamt bei TestWerk? (grobe Schätzung reicht) + + + + + In welcher Werkstatt bist du hauptsächlich? + + + holz + Holzwerkstatt + + + metall + Metallwerkstatt + + + textil + Textilwerkstatt + + + digital + Medienwerkstatt/Digital + + + keine + Ich wechsle gleichmäßig + + In welcher Werkstatt bist du hauptsächlich? + + + + + Hattest du vorher schon handwerkliche Erfahrung? + + + nein + Nein, gar nicht + + + etwas + Ein bisschen + + + ja + Ja, viel + + Hattest du vorher schon handwerkliche Erfahrung? + + + + + Ich fühle mich bei TestWerk wohl und akzeptiert. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Ich fühle mich bei TestWerk wohl und akzeptiert. + + + + + Ich habe bei TestWerk Freund*innen gefunden. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Ich habe bei TestWerk Freund*innen gefunden. + + + + + Ich traue mir zu, eigene Ideen und Projekte umzusetzen. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Ich traue mir zu, eigene Ideen und Projekte umzusetzen. + + + + + Wenn bei einem Projekt etwas nicht klappt, finde ich eine Lösung. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Wenn bei einem Projekt etwas nicht klappt, finde ich eine Lösung. + + + + + Ich habe bei TestWerk handwerkliche Fähigkeiten gelernt. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Ich habe bei TestWerk handwerkliche Fähigkeiten gelernt. + + + + + Jetzt denk mal an deine berufliche Zukunft: + Bevor ich zu TestWerk kam, hatte ich eine klare Vorstellung von meiner beruflichen Zukunft. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Bevor ich zu TestWerk kam, hatte ich eine klare Vorstellung von meiner beruflichen Zukunft. + + + + + Aktuell habe ich eine klare Vorstellung von meiner beruflichen Zukunft. + + + 1 + Stimme gar nicht zu + + + 2 + Stimme eher nicht zu + + + 3 + Weder noch + + + 4 + Stimme eher zu + + + 5 + Stimme voll zu + + Aktuell habe ich eine klare Vorstellung von meiner beruflichen Zukunft. + + + + + Ca. Wie viel Prozent dieser Veränderung geht auf TestWerk zurück? + + Ca. Wie viel Prozent dieser Veränderung geht auf TestWerk zurück? + + + + + Was hast du bei TestWerk gemacht oder gebaut? (gerne Beispiele) + + Was hast du bei TestWerk gemacht oder gebaut? (gerne Beispiele) + + + + + Was hat dir bei TestWerk am meisten gebracht? + + Was hat dir bei TestWerk am meisten gebracht? + + + + + Was könnten wir besser machen? + + Was könnten wir besser machen? + + + + + Würdest du TestWerk Freund*innen empfehlen? + + + 0 + 0 + + + 1 + 1 + + + 2 + 2 + + + 3 + 3 + + + 4 + 4 + + + 5 + 5 + + + 6 + 6 + + + 7 + 7 + + + 8 + 8 + + + 9 + 9 + + + 10 + 10 + + Würdest du TestWerk Freund*innen empfehlen? + + + + + Wie alt bist du? (optional) + + + 1 + 12-14 Jahre + + + 2 + 15-17 Jahre + + + 3 + 18-21 Jahre + + + 4 + Älter + + Wie alt bist du? (optional) + + + + + Geschlecht (optional) + + + w_1 + Weiblich + + + m + Männlich + + + nb + Nicht-binär + + + d + Divers + + + andere + Anderes Geschlecht + + Geschlecht (optional) + + + + + Falls "Anderes": Bitte angeben (optional) + + Falls "Anderes": Bitte angeben (optional) + + + + diff --git a/tests/fixtures/surveys/testA/tsv.tsv b/tests/fixtures/surveys/testA/tsv.tsv new file mode 100644 index 0000000..1e39b2d --- /dev/null +++ b/tests/fixtures/surveys/testA/tsv.tsv @@ -0,0 +1,112 @@ +class type/scale name relevance text help language validation em_validation_q mandatory other default same_default hidden hide_tip min_num_value_n max_num_value_n num_value_int_only +S language 1 en en +S format 1 A en +S shownoanswer 1 N en +SL surveyls_title 1 Untitled Survey en +G 1 intro 1 en +Q X introtext 1 Hey! Wir möchten gerne wissen, wie TestWerk für dich ist und was es dir bringt. Deine Antworten sind anonym und helfen uns, unser Angebot zu verbessern. Es gibt keine richtigen oder falschen Antworten - sag einfach deine Meinung! en +G 2 teilnahme 1 en +Q L dauer 1 Wie lange kommst du schon zu TestWerk? en Y 1 +A 1 Erst seit Kurzem (1-2 Monate) en +A 2 Ein halbes Jahr en +A 3 Ca. ein Jahr en +A 4 Länger als ein Jahr aber kürzer als drei Jahre en +A lnger Länger als drei Jahre en +Q L haeufigkeit 1 Wie oft kommst du ungefähr? en Y 1 +A 1 1-2 Mal insgesamt en +A 2 Ab und zu (1x im Monat) en +A 3 Regelmäßig (mehrmals im Monat) en +A 4 Sehr oft (jede Woche oder öfter) en +Q N stundengesamt 1 Ca. wie viele Stunden warst du insgesamt bei TestWerk? (grobe Schätzung reicht) z.B. 10, 50, 100... en 1 +Q L hauptwerkstatt 1 In welcher Werkstatt bist du hauptsächlich? en Y 1 +A holz Holzwerkstatt en +A metal Metallwerkstatt en +A texti Textilwerkstatt en +A digit Medienwerkstatt/Digital en +A keine Ich wechsle gleichmäßig en +Q M bereiche 1 Welche Bereiche nutzt du überhaupt? (Mehrfachauswahl möglich) en 1 +SQ holz Holzwerkstatt en +SQ metal Metallwerkstatt en +SQ texti Textilwerkstatt en +SQ digit Medienwerkstatt/Digital en +Q L vorwissen 1 Hattest du vorher schon handwerkliche Erfahrung? en Y 1 +A nein Nein, gar nicht en +A etwas Ein bisschen en +A ja Ja, viel en +G 3 wirkung 1 Bitte gib an, wie sehr du zustimmst (1 = stimme gar nicht zu, 5 = stimme voll zu): en +Q L wohlfuehlen 1 Ich fühle mich bei TestWerk wohl und akzeptiert. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q L freundschaften 1 Ich habe bei TestWerk Freund*innen gefunden. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q L eigeneideen 1 Ich traue mir zu, eigene Ideen und Projekte umzusetzen. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q L problemloesung 1 Wenn bei einem Projekt etwas nicht klappt, finde ich eine Lösung. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q L skillsgelernt 1 Ich habe bei TestWerk handwerkliche Fähigkeiten gelernt. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q X berufnote 1 Jetzt denk mal an deine berufliche Zukunft: en +Q L berufpre 1 Bevor ich zu TestWerk kam, hatte ich eine klare Vorstellung von meiner beruflichen Zukunft. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q L berufpost 1 Aktuell habe ich eine klare Vorstellung von meiner beruflichen Zukunft. en Y 1 +A 1 Stimme gar nicht zu en +A 2 Stimme eher nicht zu en +A 3 Weder noch en +A 4 Stimme eher zu en +A 5 Stimme voll zu en +Q N attributionberuf berufpost != berufpre Ca. Wie viel Prozent dieser Veränderung geht auf TestWerk zurück? 0% = gar nicht, 100% = komplett en 1 0 100 1 +G 4 erfahrungen 1 en +Q S projekte 1 Was hast du bei TestWerk gemacht oder gebaut? (gerne Beispiele) en 1 +Q S ammeistengebracht 1 Was hat dir bei TestWerk am meisten gebracht? en 1 +Q S verbesserungen 1 Was könnten wir besser machen? en 1 +G 5 empfehlung 1 en +Q L npsscore 1 Würdest du TestWerk Freund*innen empfehlen? 0 = auf keinen Fall, 10 = auf jeden Fall en Y 1 +A 0 0 en +A 1 1 en +A 2 2 en +A 3 3 en +A 4 4 en +A 5 5 en +A 6 6 en +A 7 7 en +A 8 8 en +A 9 9 en +A 10 10 en +G 6 demografie 1 en +Q L alter 1 Wie alt bist du? (optional) en 1 +A 1 12-14 Jahre en +A 2 15-17 Jahre en +A 3 18-21 Jahre en +A 4 Älter en +Q L geschlecht 1 Geschlecht (optional) en 1 +A w1 Weiblich en +A m Männlich en +A nb Nicht-binär en +A d Divers en +A ander Anderes Geschlecht en +Q S geschlechtandere geschlecht == 'ander' Falls "Anderes": Bitte angeben (optional) en 1 +G 7 G6 1 G6 en +Q X danke 1 Vielen Dank! Deine Meinung hilft uns sehr weiter! en \ No newline at end of file diff --git a/tests/live/limesurvey/answers/range.json b/tests/live/limesurvey/answers/range.json new file mode 100644 index 0000000..f2c0d73 --- /dev/null +++ b/tests/live/limesurvey/answers/range.json @@ -0,0 +1,3 @@ +{ + "zufriedenheit": "7" +} diff --git a/tests/live/limesurvey/expected/range.json b/tests/live/limesurvey/expected/range.json new file mode 100644 index 0000000..f2c0d73 --- /dev/null +++ b/tests/live/limesurvey/expected/range.json @@ -0,0 +1,3 @@ +{ + "zufriedenheit": "7" +} diff --git a/tests/live/limesurvey/test_registry_entities.py b/tests/live/limesurvey/test_registry_entities.py index d533d93..c2a1db2 100644 --- a/tests/live/limesurvey/test_registry_entities.py +++ b/tests/live/limesurvey/test_registry_entities.py @@ -38,12 +38,12 @@ def _expected_vocab(entity_dir: Path) -> tuple[str, int]: """Which vocabulary an entity references, and how many options it should inline. - Derived from the entity's own `xlsform.json` type string plus the referenced + Derived from the entity's own `fixtures/xlsform.json` type string plus the referenced CSV's row count — never a hardcoded vocabulary. Vocabularies are open-ended (convention:externalCodeList's `vocabularyDeclaration`), so adding one must not require editing this test. """ - xlsform = json.loads((entity_dir / "xlsform.json").read_text()) + xlsform = json.loads((entity_dir / "fixtures" / "xlsform.json").read_text()) filenames = [ row["type"].split(maxsplit=1)[1] for row in xlsform.get("survey", []) diff --git a/tests/live/limesurvey/test_xlsx_surveys.py b/tests/live/limesurvey/test_xlsx_surveys.py index 778114f..6f7cded 100644 --- a/tests/live/limesurvey/test_xlsx_surveys.py +++ b/tests/live/limesurvey/test_xlsx_surveys.py @@ -22,13 +22,25 @@ # =========================== -def test_testA_tsv_not_generated(generated_files_dir: Path): - """Verify that testA.tsv was NOT generated (contains unimplemented 'range' type).""" +def test_testA_range_bounds_survive_import(limesurvey_client: Client, generated_files_dir: Path): + """testA's `range` question (start=0 end=100 step=5) imports as a numeric + input whose bounds LimeSurvey stores (formtransform#33).""" tsv_path = generated_files_dir / "testA.tsv" - assert not tsv_path.exists(), ( - "testA.tsv should not be generated because testA.xlsx contains an unimplemented 'range' type" - ) - print("✓ testA.tsv correctly not generated (unimplemented 'range' type)") + assert tsv_path.exists(), f"testA.tsv was not generated in {generated_files_dir}" + survey_id = import_survey_from_tsv(limesurvey_client, tsv_path, "testA range bounds") + try: + questions = limesurvey_client.list_questions(survey_id) + q = next(q for q in questions if q["title"] == "attributionberuf") + assert q["type"] == "N" + props = limesurvey_client.get_question_properties(q["qid"], settings=["attributes"]) + attrs = props.get("attributes", {}) + assert (attrs.get("min_num_value_n"), attrs.get("max_num_value_n"), attrs.get("num_value_int_only")) == ( + "0", + "100", + "1", + ), f"range bounds lost on import: {attrs}" + finally: + cleanup_survey(limesurvey_client, survey_id) # =========================== diff --git a/tests/live/qwacback/test_qwacback_equivalence.py b/tests/live/qwacback/test_qwacback_equivalence.py index 0fc93dd..62adf5c 100644 --- a/tests/live/qwacback/test_qwacback_equivalence.py +++ b/tests/live/qwacback/test_qwacback_equivalence.py @@ -202,10 +202,6 @@ def _shape(xml_str: str) -> tuple[dict, dict]: [{"type": "range", "name": "score", "label": "Score", "required": "false", "appearance": None}], {}, id="range", - marks=pytest.mark.xfail( - strict=True, - reason="range isn't a registered type: formtransform emits text, qwacback numeric (formtransform#33)", - ), ), pytest.param( "date", @@ -281,8 +277,8 @@ def _shape(xml_str: str) -> tuple[dict, dict]: ] -# Two cases are strict xfails, so a fix on either side shows up as XPASS: -# `range` (formtransform#33) and `note` (intended; qwacback changes on the swap). +# `note` is a strict xfail, so a change on either side shows up as XPASS: it's +# intended here, and qwacback's output changes when it swaps converters. class TestXlsformToDdiEquivalence: diff --git a/tests/ts/contract/surveySnapshots.test.ts b/tests/ts/contract/surveySnapshots.test.ts index cb60793..3e6d3ba 100644 --- a/tests/ts/contract/surveySnapshots.test.ts +++ b/tests/ts/contract/surveySnapshots.test.ts @@ -58,6 +58,9 @@ const REVERSE_DDI_STRUCTURAL_DIFF: Record = { '(`descriptivestatistics` → `descriptivestatistic`), 5-char answer-code truncation feeding ' + 'multipleResp variable names (`projectidprojectalpha` → `projectidproje`), plus the ' + 'metadata variables (`start`, `end`) the TSV never carries', + testA: + '5-char answer-code truncation feeding multipleResp variable names ' + + '(`bereichemetall` → `bereichemetal`)', all_types_survey: 'metadata/hidden rows (start, end, today, deviceid, username, audit, hidden1) are ' + 'variables in the forward DDI but are skipped by the TSV, so the reverse cannot know them', @@ -105,7 +108,6 @@ function discoverCases(): SurveyCase[] { for (const name of fs.readdirSync(SURVEYS_DIR).sort()) { const dir = path.join(SURVEYS_DIR, name); if (!fs.statSync(dir).isDirectory()) continue; - // testA has no snapshots on purpose (unimplemented `range` type). if (!fs.existsSync(path.join(dir, 'tsv.tsv'))) continue; const fixture = loadFixture(dir); if (!fixture) continue; diff --git a/tests/ts/integration/testA.test.ts b/tests/ts/integration/testA.test.ts index d1fdf33..7b91452 100644 --- a/tests/ts/integration/testA.test.ts +++ b/tests/ts/integration/testA.test.ts @@ -23,15 +23,27 @@ describe('Integration: testA.xlsx', () => { expect(settingsData.length).toBeGreaterThan(0); }); - it('should throw on unimplemented range type during conversion', async () => { + it('converts its range question with bounds from parameters (#33)', async () => { const { surveyData, choicesData, settingsData } = XLSLoader.parseXLSData( testFileData, { skipValidation: true }, ); - const converter = new XLSFormToTSVConverter(); - await expect( - converter.convert(surveyData, choicesData, settingsData), - ).rejects.toThrow(/Unimplemented XLSForm type: 'range'/); + const tsv = await new XLSFormToTSVConverter().convert( + surveyData, + choicesData, + settingsData, + ); + const header = tsv.split('\n')[0].split('\t'); + const row = tsv + .split('\n') + .map((l) => l.split('\t')) + .find((cells) => cells[2] === 'attributionberuf'); + expect(row).toBeDefined(); + const cell = (col: string) => row![header.indexOf(col)]; + expect(cell('type/scale')).toBe('N'); + expect(cell('min_num_value_n')).toBe('0'); + expect(cell('max_num_value_n')).toBe('100'); + expect(cell('num_value_int_only')).toBe('1'); }); }); diff --git a/tests/ts/unit/pipelines/lstsv2xlsform/toXlsform.test.ts b/tests/ts/unit/pipelines/lstsv2xlsform/toXlsform.test.ts index 67964b8..c9e3ad5 100644 --- a/tests/ts/unit/pipelines/lstsv2xlsform/toXlsform.test.ts +++ b/tests/ts/unit/pipelines/lstsv2xlsform/toXlsform.test.ts @@ -355,3 +355,43 @@ describe('lstsvRowsToXlsform — multi-language', () => { }); }); }); + +describe('lstsvRowsToXlsform — range (#33)', () => { + const numeric = (attrs: Partial) => + lstsvRowsToXlsform([ + LANG_EN, + DEFAULT_GROUP, + row({ + class: 'Q', + 'type/scale': 'N', + name: 'score', + text: 'Score', + ...attrs, + }), + ]).survey[0]; + + test('a bounded integer-only N becomes range with step=1', () => { + expect( + numeric({ + min_num_value_n: '0', + max_num_value_n: '10', + num_value_int_only: '1', + }), + ).toMatchObject({ type: 'range', parameters: 'start=0 end=10 step=1' }); + }); + + test('without the integer-only flag the step is left to the default', () => { + expect( + numeric({ min_num_value_n: '0', max_num_value_n: '1' }), + ).toMatchObject({ + type: 'range', + parameters: 'start=0 end=1', + }); + }); + + test('an N with only one bound stays decimal', () => { + const q = numeric({ min_num_value_n: '0' }); + expect(q.type).toBe('decimal'); + expect(q.parameters).toBeUndefined(); + }); +}); diff --git a/tests/ts/unit/questionTypes/range.test.ts b/tests/ts/unit/questionTypes/range.test.ts new file mode 100644 index 0000000..57fc4c8 --- /dev/null +++ b/tests/ts/unit/questionTypes/range.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from 'vitest'; +import { convertAndParse, findRowByName } from '../helpers'; +import { parseParameters } from '../../../../src/pipelines/xlsform2lstsv/parameters.js'; +import { buildDdiXml } from '../../../../src/index.js'; + +const range = (parameters?: string) => [ + { + type: 'range', + name: 'score', + label: 'Score', + ...(parameters ? { parameters } : {}), + }, +]; + +describe('Range Question Type (#33)', () => { + test('becomes a numeric input bounded by start/end', async () => { + const q = findRowByName( + await convertAndParse(range('start=0 end=100 step=5')), + 'score', + ); + expect(q?.['type/scale']).toBe('N'); + expect(q?.min_num_value_n).toBe('0'); + expect(q?.max_num_value_n).toBe('100'); + expect(q?.num_value_int_only).toBe('1'); + }); + + test("uses pyxform's defaults (1..10, step 1) without parameters", async () => { + const q = findRowByName(await convertAndParse(range()), 'score'); + expect(q?.min_num_value_n).toBe('1'); + expect(q?.max_num_value_n).toBe('10'); + expect(q?.num_value_int_only).toBe('1'); + }); + + test('allows decimals when start or step is fractional', async () => { + const q = findRowByName( + await convertAndParse(range('start=0 end=1 step=0.1')), + 'score', + ); + expect(q?.max_num_value_n).toBe('1'); + expect(q?.num_value_int_only ?? '').toBe(''); + }); + + test('rejects a non-numeric bound', async () => { + await expect(convertAndParse(range('start=low end=10'))).rejects.toThrow( + /range 'score': parameter start=low is not a number/, + ); + }); + + test('is numeric/contin in DDI', () => { + const xml = buildDdiXml(range('start=0 end=10'), []); + expect(xml).toMatch(/]*name="score" intrvl="contin"/); + expect(xml).toContain('responseDomainType="numeric"'); + expect(xml).toContain(' { + const q = findRowByName( + await convertAndParse([ + { type: 'integer', name: 'n', label: 'N', parameters: 'start=0' }, + ]), + 'n', + ); + expect(q?.min_num_value_n ?? '').toBe(''); + }); +}); + +describe('parseParameters', () => { + test('accepts space, comma and semicolon separators and ignores junk', () => { + expect(parseParameters('start=0, end=100;step=5 junk')).toEqual({ + start: '0', + end: '100', + step: '5', + }); + }); + + test('returns {} for an empty or missing cell', () => { + expect(parseParameters(undefined)).toEqual({}); + expect(parseParameters('')).toEqual({}); + }); +}); diff --git a/tests/ts/unit/subsetValidation.test.ts b/tests/ts/unit/subsetValidation.test.ts index 536a87b..0b3fe21 100644 --- a/tests/ts/unit/subsetValidation.test.ts +++ b/tests/ts/unit/subsetValidation.test.ts @@ -20,9 +20,12 @@ describe('validateSubset', () => { }); test('flags an unregistered type as an error', () => { - const v = XLSValidator.validateSubset([{ type: 'range', name: 'r' }], []); + const v = XLSValidator.validateSubset( + [{ type: 'geopoint', name: 'r' }], + [], + ); expect(v).toContainEqual(expect.objectContaining({ severity: 'error' })); - expect(v[0].message).toMatch(/range.*not in the registry/); + expect(v[0].message).toMatch(/geopoint.*not in the registry/); }); test('flags an illegal name/code as an error', () => {