From 31cc7bcadbc162414c8bc53731624d47121647e5 Mon Sep 17 00:00:00 2001 From: jstet Date: Sun, 27 Sep 2026 23:16:53 +0200 Subject: [PATCH] feat(catalogue): German type labels and variant presentation in QUESTION_TYPES (#161) Every question, structural, variant and composite type in the registry has a German label now: `prefLabelDe`, a JSON-LD context term for skos:prefLabel with @language "de", required by the schema so no type lacks one. QUESTION_TYPES carries `labels: { en, de }` next to `label`, and each variant its registry `presentation` (withOther, withLongList, appearanceString), so a consumer no longer infers them from the type name. The schema's consumedBy legend gains `catalogue` for the fields QuestionTypes.ts reads. qwacback serves these to qwac (CorrelAid/qwacback#40, CorrelAid/qwac#59). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +- codegen/emit_ts.py | 25 ++++++++- registry/entities/date/definition.jsonld | 5 ++ registry/entities/decimal/definition.jsonld | 5 ++ registry/entities/grid/definition.jsonld | 5 ++ registry/entities/integer/definition.jsonld | 5 ++ registry/entities/note/definition.jsonld | 5 ++ registry/entities/range/definition.jsonld | 10 +++- .../select_multiple/definition.jsonld | 5 ++ .../definition.jsonld | 5 ++ .../select_multiple_other/definition.jsonld | 5 ++ .../entities/select_one/definition.jsonld | 5 ++ .../select_one_long_list/definition.jsonld | 5 ++ .../select_one_other/definition.jsonld | 5 ++ registry/entities/text/definition.jsonld | 5 ++ registry/entities/time/definition.jsonld | 5 ++ registry/root.jsonld | 8 +++ registry/schema.jsonld | 56 ++++++++++++++++--- src/generated/QuestionTypes.ts | 45 ++++++++++++++- tests/ts/unit/registryCatalogues.test.ts | 31 +++++++++- 20 files changed, 230 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5f3c00c..da77b0e 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,10 @@ generate surveys rather than convert them: import { QUESTION_TYPES, APPEARANCES, TYPE_MAPPINGS } from '@correlaid/formtransform'; QUESTION_TYPES.select_one.label; // "Select One" +QUESTION_TYPES.select_one.labels.de; // "Einfachauswahl" QUESTION_TYPES.select_one.useWhen; // when to reach for this type QUESTION_TYPES.select_one_other.base; // "select_one" — a variant of it +QUESTION_TYPES.select_one_other.presentation; // { withOther: true, withLongList: false } QUESTION_TYPES.grid.bases; // composites span several types QUESTION_TYPES.select_one.constraints; // name/choice-code limits APPEARANCES.label.carriesData; // false — a matrix header stores no answer @@ -149,7 +151,8 @@ TYPE_MAPPINGS.select_one.limeSurveyType; // "L" — how it converts ``` `QUESTION_TYPES` is keyed by registry slug and answers *what a row can be* -(label, guidance, variant → base, authoring constraints, metadata rows); +(labels in English and German, guidance, variant → base and presentation, +authoring constraints, metadata rows); `TYPE_MAPPINGS` answers *how it converts*. Both are generated from `registry/` — never hardcode a type list or a label in a consumer. diff --git a/codegen/emit_ts.py b/codegen/emit_ts.py index 0cb11d0..de1e24c 100644 --- a/codegen/emit_ts.py +++ b/codegen/emit_ts.py @@ -419,12 +419,16 @@ def _entry_lines( is_composite: bool, aliases: list[str] | None = None, constraints: dict[str, Any] | None = None, + label_de: str | None = None, + presentation: dict[str, Any] | None = None, ) -> list[str]: """One `QUESTION_TYPES` record entry.""" + labels = {"en": label, **({"de": label_de} if label_de else {})} lines = [ f" {_ts_key(key)}: {{", f" id: {json.dumps(entry_id)},", f" label: {json.dumps(label, ensure_ascii=False)},", + f" labels: {json.dumps(labels, ensure_ascii=False)},", f" kind: {json.dumps(kind)},", f" useWhen: {json.dumps(use_when, ensure_ascii=False)},", f" isVariant: {'true' if is_variant else 'false'},", @@ -442,6 +446,8 @@ def _entry_lines( lines.append(f" aliases: {json.dumps(aliases)},") if constraints: lines.append(f" constraints: {json.dumps(constraints, ensure_ascii=False)},") + if presentation: + lines.append(f" presentation: {json.dumps(presentation, ensure_ascii=False)},") lines.append(" },") return lines @@ -490,8 +496,13 @@ def generate_question_types(registry: dict[str, Any], output: Path): " * a composite spans several rows, so it has no single type cell.", " */", " typeString?: string;", - " /** Display label, from `skos:prefLabel`. */", + " /** Display label in English, from `skos:prefLabel`. */", " label: string;", + " /**", + " * Display label per language: `en` (`label`) and, where the registry", + " * has one, `de` (`skos:prefLabel@de`, the `prefLabelDe` term).", + " */", + " labels: { readonly en: string; readonly de?: string };", " /** Methodology guidance, from `useWhen`. Empty for structural rows. */", " useWhen: string;", " kind: 'question' | 'metadata' | 'structural';", @@ -504,6 +515,15 @@ def generate_question_types(registry: dict[str, Any], output: Path): " /** Accepted alternative `type` strings (e.g. `string` for `text`). */", " aliases?: readonly string[];", " constraints?: QuestionTypeConstraints;", + " /**", + " * A variant's modifiers, from the registry's `presentation`: an \"other\"", + " * answer with a text field, a long list (and its appearance).", + " */", + " presentation?: {", + " readonly withOther: boolean;", + " readonly withLongList: boolean;", + " readonly appearanceString?: string;", + " };", "}", "", "/**", @@ -531,6 +551,7 @@ def broader_slugs(data: dict[str, Any]) -> list[str]: entry_id=type_id, type_string=data["xlsform"]["typeString"], label=data.get("skos:prefLabel", slug), + label_de=data.get("prefLabelDe"), kind=kinds[t], use_when=data.get("useWhen", ""), bases=[], @@ -552,11 +573,13 @@ def broader_slugs(data: dict[str, Any]) -> list[str]: entry_id=type_id, type_string=type_string, label=data.get("skos:prefLabel", slug), + label_de=data.get("prefLabelDe"), kind="question", use_when=data.get("useWhen", ""), bases=bases, is_variant=is_variant, is_composite=not is_variant, + presentation=data.get("presentation"), ) # Metadata rows are not registry entities — they come from the diff --git a/registry/entities/date/definition.jsonld b/registry/entities/date/definition.jsonld index f5ef52b..04e90a6 100644 --- a/registry/entities/date/definition.jsonld +++ b/registry/entities/date/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:date", "@type": "QuestionType", "skos:prefLabel": "Date", + "prefLabelDe": "Datum", "xlsform": { "typeString": "date" }, diff --git a/registry/entities/decimal/definition.jsonld b/registry/entities/decimal/definition.jsonld index cf7dd9b..12018e8 100644 --- a/registry/entities/decimal/definition.jsonld +++ b/registry/entities/decimal/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:decimal", "@type": "QuestionType", "skos:prefLabel": "Decimal/Float", + "prefLabelDe": "Dezimalzahl", "xlsform": { "typeString": "decimal" }, diff --git a/registry/entities/grid/definition.jsonld b/registry/entities/grid/definition.jsonld index c56d6cb..ca530b2 100644 --- a/registry/entities/grid/definition.jsonld +++ b/registry/entities/grid/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "composite:grid", "@type": "Composite", "skos:prefLabel": "Grid / Matrix Group", + "prefLabelDe": "Matrix-Gruppe", "skos:broader": [ { "@id": "type:begin_group" diff --git a/registry/entities/integer/definition.jsonld b/registry/entities/integer/definition.jsonld index 62ea21b..9ec376e 100644 --- a/registry/entities/integer/definition.jsonld +++ b/registry/entities/integer/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:integer", "@type": "QuestionType", "skos:prefLabel": "Integer", + "prefLabelDe": "Ganzzahl", "xlsform": { "typeString": "integer", "aliases": [ diff --git a/registry/entities/note/definition.jsonld b/registry/entities/note/definition.jsonld index 420d805..507e58e 100644 --- a/registry/entities/note/definition.jsonld +++ b/registry/entities/note/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:note", "@type": "QuestionType", "skos:prefLabel": "Note (Display Text)", + "prefLabelDe": "Hinweis (Anzeigetext)", "xlsform": { "typeString": "note" }, diff --git a/registry/entities/range/definition.jsonld b/registry/entities/range/definition.jsonld index 7cfac75..431c09a 100644 --- a/registry/entities/range/definition.jsonld +++ b/registry/entities/range/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:range", "@type": "QuestionType", "skos:prefLabel": "Range", + "prefLabelDe": "Schieberegler", "xlsform": { "typeString": "range", "parameters": { @@ -30,7 +35,10 @@ }, "integerOnly": { "attribute": "num_value_int_only", - "whenWhole": ["start", "step"] + "whenWhole": [ + "start", + "step" + ] } }, "ddi": { diff --git a/registry/entities/select_multiple/definition.jsonld b/registry/entities/select_multiple/definition.jsonld index 2f103b8..5d21b32 100644 --- a/registry/entities/select_multiple/definition.jsonld +++ b/registry/entities/select_multiple/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:select_multiple", "@type": "QuestionType", "skos:prefLabel": "Select Multiple", + "prefLabelDe": "Mehrfachauswahl", "xlsform": { "typeString": "select_multiple", "requiresListName": true diff --git a/registry/entities/select_multiple_long_list/definition.jsonld b/registry/entities/select_multiple_long_list/definition.jsonld index 017b4a7..50f08e7 100644 --- a/registry/entities/select_multiple_long_list/definition.jsonld +++ b/registry/entities/select_multiple_long_list/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "variant:select_multiple_long_list", "@type": "QuestionTypeVariant", "skos:prefLabel": "Select Multiple (Long List)", + "prefLabelDe": "Mehrfachauswahl (lange Liste)", "skos:broader": { "@id": "type:select_multiple" }, diff --git a/registry/entities/select_multiple_other/definition.jsonld b/registry/entities/select_multiple_other/definition.jsonld index 78bd5a2..72bbd3b 100644 --- a/registry/entities/select_multiple_other/definition.jsonld +++ b/registry/entities/select_multiple_other/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "variant:select_multiple_other", "@type": "QuestionTypeVariant", "skos:prefLabel": "Select Multiple with Other", + "prefLabelDe": "Mehrfachauswahl mit „Sonstiges“", "skos:broader": { "@id": "type:select_multiple" }, diff --git a/registry/entities/select_one/definition.jsonld b/registry/entities/select_one/definition.jsonld index 4a1b420..9a9a2fc 100644 --- a/registry/entities/select_one/definition.jsonld +++ b/registry/entities/select_one/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:select_one", "@type": "QuestionType", "skos:prefLabel": "Select One", + "prefLabelDe": "Einfachauswahl", "xlsform": { "typeString": "select_one", "requiresListName": true diff --git a/registry/entities/select_one_long_list/definition.jsonld b/registry/entities/select_one_long_list/definition.jsonld index dcbe190..66363ba 100644 --- a/registry/entities/select_one_long_list/definition.jsonld +++ b/registry/entities/select_one_long_list/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "variant:select_one_long_list", "@type": "QuestionTypeVariant", "skos:prefLabel": "Select One (Long List)", + "prefLabelDe": "Einfachauswahl (lange Liste)", "skos:broader": { "@id": "type:select_one" }, diff --git a/registry/entities/select_one_other/definition.jsonld b/registry/entities/select_one_other/definition.jsonld index ea484fa..95b97c2 100644 --- a/registry/entities/select_one_other/definition.jsonld +++ b/registry/entities/select_one_other/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "variant:select_one_other", "@type": "QuestionTypeVariant", "skos:prefLabel": "Select One with Other", + "prefLabelDe": "Einfachauswahl mit „Sonstiges“", "skos:broader": { "@id": "type:select_one" }, diff --git a/registry/entities/text/definition.jsonld b/registry/entities/text/definition.jsonld index 0244f08..151c902 100644 --- a/registry/entities/text/definition.jsonld +++ b/registry/entities/text/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:text", "@type": "QuestionType", "skos:prefLabel": "Text (Short Free Text)", + "prefLabelDe": "Text (kurze Freitextantwort)", "xlsform": { "typeString": "text", "aliases": [ diff --git a/registry/entities/time/definition.jsonld b/registry/entities/time/definition.jsonld index 0ed5f85..da22e85 100644 --- a/registry/entities/time/definition.jsonld +++ b/registry/entities/time/definition.jsonld @@ -3,6 +3,10 @@ "@version": 1.1, "@vocab": "https://civic-data.de/survey/", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/" @@ -12,6 +16,7 @@ "@id": "type:time", "@type": "QuestionType", "skos:prefLabel": "Time", + "prefLabelDe": "Uhrzeit", "xlsform": { "typeString": "time" }, diff --git a/registry/root.jsonld b/registry/root.jsonld index bd5f872..d45f0d6 100644 --- a/registry/root.jsonld +++ b/registry/root.jsonld @@ -4,6 +4,10 @@ "@vocab": "https://civic-data.de/survey/", "xsd": "http://www.w3.org/2001/XMLSchema#", "skos": "http://www.w3.org/2004/02/skos/core#", + "prefLabelDe": { + "@id": "skos:prefLabel", + "@language": "de" + }, "type": "https://civic-data.de/survey/type/", "variant": "https://civic-data.de/survey/variant/", "composite": "https://civic-data.de/survey/composite/", @@ -29,6 +33,7 @@ "@id": "type:end_group", "@type": "StructuralType", "skos:prefLabel": "Group (End)", + "prefLabelDe": "Gruppe (Ende)", "xlsform": { "typeString": "end_group" }, @@ -45,6 +50,7 @@ "@id": "type:select_one_from_file", "@type": "QuestionType", "skos:prefLabel": "Select One (from file)", + "prefLabelDe": "Einfachauswahl (aus Datei)", "xlsform": { "typeString": "select_one_from_file", "requiresListName": true @@ -75,6 +81,7 @@ "@id": "type:select_multiple_from_file", "@type": "QuestionType", "skos:prefLabel": "Select Multiple (from file)", + "prefLabelDe": "Mehrfachauswahl (aus Datei)", "xlsform": { "typeString": "select_multiple_from_file", "requiresListName": true @@ -217,6 +224,7 @@ "@id": "type:begin_group", "@type": "StructuralType", "skos:prefLabel": "Group (Begin)", + "prefLabelDe": "Gruppe (Beginn)", "xlsform": { "typeString": "begin_group" }, diff --git a/registry/schema.jsonld b/registry/schema.jsonld index 390065f..e450dab 100644 --- a/registry/schema.jsonld +++ b/registry/schema.jsonld @@ -37,7 +37,8 @@ "conventions": "Read at runtime by the library via src/generated/conventions.json.", "examples": "Feeds the per-entity fixtures under registry/entities// (xlsform.xlsx + blessed snapshots).", "validation": "Read only by codegen._validate_registry / conformance tests (structural integrity, no output).", - "docs": "No runtime effect — rendered only into registry/entities//docs.md, skills/cdl-survey-types/ and the docs site." + "docs": "No runtime effect — rendered only into registry/entities//docs.md, skills/cdl-survey-types/ and the docs site.", + "catalogue": "Read into src/generated/QuestionTypes.ts, the labelled catalogue exported as QUESTION_TYPES." }, "classes": { "QuestionType": { @@ -66,10 +67,19 @@ "type": "string", "required": true, "consumedBy": [ - "docs" + "docs", + "catalogue" ], "description": "Canonical human-readable name in English." }, + "prefLabelDe": { + "type": "string", + "required": true, + "consumedBy": [ + "catalogue" + ], + "description": "The German name (skos:prefLabel@de, via the prefLabelDe context term): shown by German UIs (qwacback's question-type catalogue)." + }, "useWhen": { "type": "string", "required": true, @@ -423,9 +433,18 @@ "type": "string", "required": true, "consumedBy": [ - "docs" + "docs", + "catalogue" ] }, + "prefLabelDe": { + "type": "string", + "required": true, + "consumedBy": [ + "catalogue" + ], + "description": "The German name (skos:prefLabel@de, via the prefLabelDe context term): shown by German UIs (qwacback's question-type catalogue)." + }, "xlsform": { "type": "object", "required": true, @@ -503,9 +522,18 @@ "type": "string", "required": true, "consumedBy": [ - "docs" + "docs", + "catalogue" ] }, + "prefLabelDe": { + "type": "string", + "required": true, + "consumedBy": [ + "catalogue" + ], + "description": "The German name (skos:prefLabel@de, via the prefLabelDe context term): shown by German UIs (qwacback's question-type catalogue)." + }, "skos:broader": { "type": "reference | reference[]", "required": true, @@ -676,10 +704,19 @@ "required": true, "consumedBy": [ "examples", - "docs" + "docs", + "catalogue" ], "description": "English canonical variant kind name (e.g. `Single Choice with Other`). NOT the fixture subject." }, + "prefLabelDe": { + "type": "string", + "required": true, + "consumedBy": [ + "catalogue" + ], + "description": "The German name (skos:prefLabel@de, via the prefLabelDe context term): shown by German UIs (qwacback's question-type catalogue)." + }, "skos:broader": { "type": "reference", "required": true, @@ -707,7 +744,8 @@ "required": true, "consumedBy": [ "examples", - "docs" + "docs", + "catalogue" ] }, "withLongList": { @@ -715,7 +753,8 @@ "required": true, "consumedBy": [ "examples", - "docs" + "docs", + "catalogue" ] }, "appearanceString": { @@ -723,7 +762,8 @@ "required": false, "consumedBy": [ "examples", - "docs" + "docs", + "catalogue" ], "description": "Optional XLSForm appearance value applied by this variant." } diff --git a/src/generated/QuestionTypes.ts b/src/generated/QuestionTypes.ts index 380217a..947b5a9 100644 --- a/src/generated/QuestionTypes.ts +++ b/src/generated/QuestionTypes.ts @@ -28,8 +28,13 @@ export interface QuestionTypeEntry { * a composite spans several rows, so it has no single type cell. */ typeString?: string; - /** Display label, from `skos:prefLabel`. */ + /** Display label in English, from `skos:prefLabel`. */ label: string; + /** + * Display label per language: `en` (`label`) and, where the registry + * has one, `de` (`skos:prefLabel@de`, the `prefLabelDe` term). + */ + labels: { readonly en: string; readonly de?: string }; /** Methodology guidance, from `useWhen`. Empty for structural rows. */ useWhen: string; kind: 'question' | 'metadata' | 'structural'; @@ -42,6 +47,15 @@ export interface QuestionTypeEntry { /** Accepted alternative `type` strings (e.g. `string` for `text`). */ aliases?: readonly string[]; constraints?: QuestionTypeConstraints; + /** + * A variant's modifiers, from the registry's `presentation`: an "other" + * answer with a text field, a long list (and its appearance). + */ + presentation?: { + readonly withOther: boolean; + readonly withLongList: boolean; + readonly appearanceString?: string; + }; } /** @@ -54,6 +68,7 @@ export const QUESTION_TYPES = { grid: { id: "composite:grid", label: "Grid / Matrix Group", + labels: {"en": "Grid / Matrix Group", "de": "Matrix-Gruppe"}, kind: "question", useWhen: "When multiple survey items share the same response scale and introductory text, forming a battery (e.g. a Likert scale measuring agreement across several statements).", isVariant: false, @@ -64,6 +79,7 @@ export const QUESTION_TYPES = { begin_group: { id: "type:begin_group", label: "Group (Begin)", + labels: {"en": "Group (Begin)", "de": "Gruppe (Beginn)"}, kind: "structural", useWhen: "", isVariant: false, @@ -73,6 +89,7 @@ export const QUESTION_TYPES = { date: { id: "type:date", label: "Date", + labels: {"en": "Date", "de": "Datum"}, kind: "question", useWhen: "When capturing a specific calendar date (e.g. date of birth, appointment date).", isVariant: false, @@ -83,6 +100,7 @@ export const QUESTION_TYPES = { decimal: { id: "type:decimal", label: "Decimal/Float", + labels: {"en": "Decimal/Float", "de": "Dezimalzahl"}, kind: "question", useWhen: "When collecting open decimal numeric values without predefined categories (e.g. height in metres, temperature).", isVariant: false, @@ -93,6 +111,7 @@ export const QUESTION_TYPES = { end_group: { id: "type:end_group", label: "Group (End)", + labels: {"en": "Group (End)", "de": "Gruppe (Ende)"}, kind: "structural", useWhen: "", isVariant: false, @@ -102,6 +121,7 @@ export const QUESTION_TYPES = { integer: { id: "type:integer", label: "Integer", + labels: {"en": "Integer", "de": "Ganzzahl"}, kind: "question", useWhen: "When collecting open integer values without predefined categories, where respondents provide a raw number (e.g. age, household size).", isVariant: false, @@ -113,6 +133,7 @@ export const QUESTION_TYPES = { note: { id: "type:note", label: "Note (Display Text)", + labels: {"en": "Note (Display Text)", "de": "Hinweis (Anzeigetext)"}, kind: "question", useWhen: "When displaying non-interactive informational text, instructions, or section headers that require no respondent answer.", isVariant: false, @@ -123,6 +144,7 @@ export const QUESTION_TYPES = { range: { id: "type:range", label: "Range", + labels: {"en": "Range", "de": "Schieberegler"}, 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, @@ -133,6 +155,7 @@ export const QUESTION_TYPES = { select_multiple: { id: "type:select_multiple", label: "Select Multiple", + labels: {"en": "Select Multiple", "de": "Mehrfachauswahl"}, kind: "question", useWhen: "When options are not mutually exclusive and respondents may select any number of applicable answers (e.g. languages spoken, devices owned).", isVariant: false, @@ -143,6 +166,7 @@ export const QUESTION_TYPES = { select_multiple_from_file: { id: "type:select_multiple_from_file", label: "Select Multiple (from file)", + labels: {"en": "Select Multiple (from file)", "de": "Mehrfachauswahl (aus Datei)"}, kind: "question", useWhen: "When selecting from a long non-exclusive list of options maintained as a controlled vocabulary, allowing multiple selections.", isVariant: false, @@ -153,6 +177,7 @@ export const QUESTION_TYPES = { select_one: { id: "type:select_one", label: "Select One", + labels: {"en": "Select One", "de": "Einfachauswahl"}, kind: "question", useWhen: "When answer categories are exhaustive and mutually exclusive, and exactly one option should be selected (e.g. education level, employment status).", isVariant: false, @@ -163,6 +188,7 @@ export const QUESTION_TYPES = { select_one_from_file: { id: "type:select_one_from_file", label: "Select One (from file)", + labels: {"en": "Select One (from file)", "de": "Einfachauswahl (aus Datei)"}, kind: "question", useWhen: "When selecting from a long closed list of exhaustive, mutually exclusive options maintained as a controlled vocabulary (e.g. country, occupation), presented via dropdown or autocomplete.", isVariant: false, @@ -173,6 +199,7 @@ export const QUESTION_TYPES = { text: { id: "type:text", label: "Text (Short Free Text)", + labels: {"en": "Text (Short Free Text)", "de": "Text (kurze Freitextantwort)"}, kind: "question", useWhen: "When responses are free text that cannot be meaningfully pre-categorised, or when exploring unknown topics exploratively.", isVariant: false, @@ -184,6 +211,7 @@ export const QUESTION_TYPES = { time: { id: "type:time", label: "Time", + labels: {"en": "Time", "de": "Uhrzeit"}, kind: "question", useWhen: "When capturing a time of day without an associated date (e.g. preferred appointment time).", isVariant: false, @@ -194,46 +222,55 @@ export const QUESTION_TYPES = { select_multiple_long_list: { id: "variant:select_multiple_long_list", label: "Select Multiple (Long List)", + labels: {"en": "Select Multiple (Long List)", "de": "Mehrfachauswahl (lange Liste)"}, kind: "question", useWhen: "When selecting multiple answers from a long non-exclusive list maintained as a controlled vocabulary, presented via a compact interface.", isVariant: true, isComposite: false, typeString: "select_multiple", base: "select_multiple", + presentation: {"appearanceString": "minimal", "withOther": false, "withLongList": true}, }, select_multiple_other: { id: "variant:select_multiple_other", label: "Select Multiple with Other", + labels: {"en": "Select Multiple with Other", "de": "Mehrfachauswahl mit „Sonstiges“"}, kind: "question", useWhen: "When a multi-select question has an open-ended 'other' response option for unlisted selections.", isVariant: true, isComposite: false, typeString: "select_multiple", base: "select_multiple", + presentation: {"withOther": true, "withLongList": false}, }, select_one_long_list: { id: "variant:select_one_long_list", label: "Select One (Long List)", + labels: {"en": "Select One (Long List)", "de": "Einfachauswahl (lange Liste)"}, kind: "question", useWhen: "When selecting one answer from a long closed list (typically 15+ options) such as countries or occupations, where a dropdown or autocomplete interface is preferable to many radio buttons.", isVariant: true, isComposite: false, typeString: "select_one", base: "select_one", + presentation: {"appearanceString": "minimal", "withOther": false, "withLongList": true}, }, select_one_other: { id: "variant:select_one_other", label: "Select One with Other", + labels: {"en": "Select One with Other", "de": "Einfachauswahl mit „Sonstiges“"}, kind: "question", useWhen: "When a single-select question has an open-ended 'other' response option for answers not covered by the predefined categories.", isVariant: true, isComposite: false, typeString: "select_one", base: "select_one", + presentation: {"withOther": true, "withLongList": false}, }, start: { id: "metadataRow:start", label: "start", + labels: {"en": "start"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, @@ -243,6 +280,7 @@ export const QUESTION_TYPES = { end: { id: "metadataRow:end", label: "end", + labels: {"en": "end"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, @@ -252,6 +290,7 @@ export const QUESTION_TYPES = { today: { id: "metadataRow:today", label: "today", + labels: {"en": "today"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, @@ -261,6 +300,7 @@ export const QUESTION_TYPES = { deviceid: { id: "metadataRow:deviceid", label: "deviceid", + labels: {"en": "deviceid"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, @@ -270,6 +310,7 @@ export const QUESTION_TYPES = { username: { id: "metadataRow:username", label: "username", + labels: {"en": "username"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, @@ -279,6 +320,7 @@ export const QUESTION_TYPES = { hidden: { id: "metadataRow:hidden", label: "hidden", + labels: {"en": "hidden"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, @@ -288,6 +330,7 @@ export const QUESTION_TYPES = { audit: { id: "metadataRow:audit", label: "audit", + labels: {"en": "audit"}, kind: "metadata", useWhen: "skip silently — device/session metadata rows carry no authored content", isVariant: false, diff --git a/tests/ts/unit/registryCatalogues.test.ts b/tests/ts/unit/registryCatalogues.test.ts index 591661b..465a653 100644 --- a/tests/ts/unit/registryCatalogues.test.ts +++ b/tests/ts/unit/registryCatalogues.test.ts @@ -2,12 +2,37 @@ import { describe, it, expect } from 'vitest'; import { QUESTION_TYPES, APPEARANCES, TYPE_MAPPINGS } from '../../../src/index'; /** - * The two generated catalogues are a public API: qwac renders type labels and - * matrix previews from them, formulaid derives its question-type union from - * them. These tests guard the shape consumers rely on, and the invariants that + * The two generated catalogues are a public API: qwacback serves type labels + * and presentation from them (CorrelAid/qwacback#40), formulaid derives its + * question-type union from them. These tests guard the shape consumers rely on, and the invariants that * would silently break a consumer if the registry lost an entry. */ describe('QUESTION_TYPES', () => { + it('labels every registered type in English and German (#161)', () => { + for (const [slug, entry] of Object.entries(QUESTION_TYPES)) { + expect(entry.labels.en, `${slug}.labels.en`).toBe(entry.label); + // Metadata rows come from a convention, not the registry: no German. + if (entry.kind === 'metadata') continue; + expect(entry.labels.de, `${slug}.labels.de`).toBeTruthy(); + } + expect(QUESTION_TYPES.select_one.labels.de).toBe('Einfachauswahl'); + }); + + it("gives every variant the registry's presentation (#161)", () => { + for (const [slug, entry] of Object.entries(QUESTION_TYPES)) { + if (!entry.isVariant) continue; + expect(entry.presentation, `${slug}.presentation`).toBeDefined(); + } + expect(QUESTION_TYPES.select_one_other.presentation).toEqual({ + withOther: true, + withLongList: false, + }); + expect(QUESTION_TYPES.select_one_long_list.presentation).toMatchObject({ + withLongList: true, + appearanceString: 'minimal', + }); + }); + it('covers every type string TYPE_MAPPINGS knows, aliases included', () => { const catalogued = new Set(); for (const entry of Object.values(QUESTION_TYPES)) {