From d515a419e6364c4ffe0793cddebbc6a529930bb3 Mon Sep 17 00:00:00 2001 From: Nedelcho Delchev Date: Mon, 5 Oct 2026 11:21:29 +0300 Subject: [PATCH] A to-one relation can be labelled, in English and per country A field could declare `label:` and `countryLabels:` (#6424) but the relation next to it could not, and a relation is named for the model: `Year.company` rendered its picker, its list column and its details row as "Company", with no way to say "Issuing company" in the intent. The only place left to put the English caption was the generated catalog, which the next Generate overwrites. Both keys are now accepted on a to-one relation and on a `subset`, validated by the same rule a field's are (a blank label is refused, a key that is no ISO 3166-1 country is refused because it could never match a tenant), and emitted onto the relation's own FK property as `widgetLabel` / `widgetCountryLabels` - the property every generated surface already reads its label from, so nothing downstream changed. A `oneToMany` renders no control of its own, so a label authored there would be carried nowhere and is refused rather than dropped. An n:m is expanded before validation, so the authored keys travel with the to-target half of the materialized link, where the picker ends up. Fixes #7650 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/docs/intent-dsl-features.md | 2 +- .../generator/edm/EdmIntentGenerator.java | 31 +++++++- .../intent/model/RelationIntent.java | 37 ++++++++++ .../intent/parser/IntentParser.java | 37 ++++++++-- .../intent/parser/ManyToManyExpander.java | 6 ++ .../main/resources/intent-assistant-guide.md | 7 ++ .../generator/edm/EdmFieldLabelTest.java | 49 +++++++++++- .../intent/parser/FieldLabelIntentTest.java | 74 +++++++++++++++++++ 8 files changed, 233 insertions(+), 10 deletions(-) diff --git a/.claude/docs/intent-dsl-features.md b/.claude/docs/intent-dsl-features.md index 319dd3dc776..645ad46a97e 100644 --- a/.claude/docs/intent-dsl-features.md +++ b/.claude/docs/intent-dsl-features.md @@ -18,7 +18,7 @@ Loaded on demand, not on every session: read this before changing a DSL construc **The declarative state machine (`lifecycle:`, [#6714](https://github.com/eclipse-dirigible/dirigible/issues/6714)):** an entity may declare the WHOLE set of legal status edges over its `function: EntityStatus` nomenclature — `lifecycle: { edges: [{ from: DRAFT, to: [ISSUED, CANCELLED] }, ...] }`, either side a seeded name or an id — and every status write is validated against it. Until then the status machinery was point constructs (`init:`, a `transitions:` button's own guard, a workflow `setRelationField`, a check's rejection) with nothing stating which moves were legal at all, so any writer that was not a transition button could move a document from any status to any other. Enforcement is in the generated **repository** — the one choke point every writer passes through (`update`, `updateWithoutEvent`, and `updateProperties`, which the transition controller's `updateProperty` and the workflow setters route through) — rejecting an unmodeled move with 400 and a message naming both statuses; with `init:` declared, a record cannot be CREATED mid-lifecycle either. At parse time the graph is what the other status sites are held to: a `transitions:` entry's `from`→`setStatus` pair must be a declared edge (a button is presentation over the graph), and a status written by a workflow step or forced by a check must be one some edge reaches — so a reject path transiting through an approved status fails when the intent is read. There is no `on:` key (the graph is always over the EntityStatus relation, and YAML reads a bare `on` as `true`, so it is refused rather than silently dropped), and a cross-model nomenclature is declared where it is seeded. Details in the engine-intent guide's state-machine bullet. -**A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too. +**A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too. **A to-one relation takes the same two keys ([#7650](https://github.com/eclipse-dirigible/dirigible/issues/7650)):** the caption of a picker is the same problem one construct over - the relation carries the model's identifier, so `Year.company` rendered as "Company" with no way to say "Issuing company" in the intent, and the English caption had to be hand-edited into a catalog the next Generate overwrites. The keys are emitted onto the relation's own FK property, which is where every surface already reads its label, so nothing downstream changed; a `oneToMany` is refused, having no control of its own to caption. **Naming a rendered document (`fileName:`, [#6899](https://github.com/eclipse-dirigible/dirigible/issues/6899)):** the two server-side PDF renders — the snapshot copy a document mints on issue and the `attach: print` PDF a `notify` block sends — accept a **`fileName:`** pattern on the `function: Snapshot` child / inside the notify block: literals plus `{token}` interpolations, no expression language. `{field}` and one-hop `{relation.field}` use the same path vocabulary and **authored names** a notify subject does, `{field:pattern}` formats a `date`/`timestamp` through a `DateTimeFormatter` pattern, `{A|B}` renders the first non-blank operand, `{Version}` (snapshot only) places the copy's version and a pattern without it gets `_v` appended. Interpolated **values** are sanitized at run time by `sdk.print.FileNames` (trim, whitespace → one `_`, path/control characters stripped, non-ASCII deliberately kept); the literal separators are the author's. A pattern's relation loads are **merged into the notify block's own**, deduped by local, so a name may read a relation the message never mentions — but `attach: recordPrint` renders the anchor once, before the per-row loop, so only fields of the anchor are readable there. Absent a pattern, the snapshot default **changes** from the old primary-key form (`Order 42 v1.pdf`) to the same number-or-id expression the mail already used, plus the version — the deliberate fix for the two names disagreeing. An unresolvable path, an unbalanced brace, a format on a non-date field or a pattern that interpolates nothing are validation errors. Details in the engine-intent guide's fileName bullet. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 551c2227088..6250ea7c466 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -1331,10 +1331,34 @@ private static List> applyOrder(List> pr * @param field the authored field * @return the variants by country code, empty when none are declared */ + /** + * The authored caption of the control a RELATION renders (#7650) - the picker, its list column and + * its details row - plus its country variants, written exactly as a field's are (#6424) so every + * generated surface and the en-US catalog read one attribute whatever the property came from. + * Absent, the pipeline derives the humanized relation name as before. + * + * @param property the property map being built + * @param relation the relation it was built from + */ + private static void putRelationLabel(Map property, RelationIntent relation) { + if (notBlank(relation.getLabel())) { + property.put("widgetLabel", relation.getLabel() + .trim()); + } + Map variants = countryLabels(relation.getCountryLabels()); + if (!variants.isEmpty()) { + property.put("widgetCountryLabels", variants); + } + } + private static Map countryLabels(FieldIntent field) { + return countryLabels(field.getCountryLabels()); + } + + /** The canonical country-variant map - keys upper-cased, blank variants dropped. */ + private static Map countryLabels(Map authored) { Map canonical = new LinkedHashMap<>(); - for (Map.Entry variant : field.getCountryLabels() - .entrySet()) { + for (Map.Entry variant : (authored == null ? Map.of() : authored).entrySet()) { if (variant.getKey() == null || !notBlank(variant.getValue())) { continue; } @@ -1667,6 +1691,7 @@ private static Map relationProperty(String ownerEntity, Relation boolean oneToOne = "oneToOne".equals(relation.getKind()); Map p = new LinkedHashMap<>(); p.put("name", IntentNaming.pascalCase(relation.getName())); + putRelationLabel(p, relation); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", column); @@ -1749,6 +1774,7 @@ private static Map subsetProperty(String ownerEntity, RelationIn String targetPerspective) { Map p = new LinkedHashMap<>(); p.put("name", IntentNaming.pascalCase(relation.getName())); + putRelationLabel(p, relation); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", IntentNaming.upperSnake(ownerEntity) + "_" + IntentNaming.upperSnake(relation.getName())); @@ -1791,6 +1817,7 @@ private static Map crossModelRelationProperty(String ownerEntity boolean oneToOne = "oneToOne".equals(relation.getKind()); Map p = new LinkedHashMap<>(); p.put("name", IntentNaming.pascalCase(relation.getName())); + putRelationLabel(p, relation); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", column); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java index 5d50bedc24e..164bc75c807 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java @@ -76,6 +76,27 @@ public class RelationIntent { * {@code grid-column: span N}. Absent (the default) leaves it unset (the form falls back to half * width). Use a small span (e.g. 4) to pack several short dropdowns onto one row. */ + /** + * Optional display label for the PICKER this relation renders as, replacing the humanized relation + * name in every generated surface - the form control's caption, the list column header and the + * details row - and seeding its en-US catalog entry (issue #7650). + * + *

+ * The same key a field carries (#6424), for the same reason: the humanized name is derived from an + * identifier the author chose for the MODEL, and a picklist relation that cannot be called after + * the thing it picks (because a stored column already owns that name) renders as the identifier - + * {@code VatGroundPick} as "Vat Ground Pick". The generated en-US catalog is rewritten on every + * Generate, so there is nowhere else to author the English caption. + */ + private String label; + + /** + * Optional label variants keyed by ISO 3166-1 alpha-2 country code (issue #7650), exactly as + * on a field (#6424): what a picker is called may follow the tenant's country rather than the + * reader's language, so the variant overrides {@link #label} in EVERY language. + */ + private java.util.Map countryLabels = new java.util.LinkedHashMap<>(); + private Integer size; /** * Whether this to-one relation shows as a column in the entity's list / document-items table @@ -320,6 +341,22 @@ public void setSize(Integer size) { this.size = size; } + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public java.util.Map getCountryLabels() { + return countryLabels; + } + + public void setCountryLabels(java.util.Map countryLabels) { + this.countryLabels = countryLabels == null ? new java.util.LinkedHashMap<>() : countryLabels; + } + public boolean isMajor() { return major; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index b1619ff2295..565262ea652 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -4336,6 +4336,11 @@ private static Set validateEntities(IntentModel model, Set usesA // checks below (composition, cross-model, dependsOn, leafOnly, personal/partner). if ("subset".equals(relation.getKind())) { validateSubset(entity, relation, entityNames, byName, issues); + if (relation.getLabel() != null || !relation.getCountryLabels() + .isEmpty()) { + validateLabels("entity [" + entity.getName() + "] relation [" + relation.getName() + "]", relation.getLabel(), + relation.getCountryLabels(), issues); + } continue; } // ManyToManyExpander consumed every n:m before this ran, so a surviving manyToMany is one @@ -4402,6 +4407,19 @@ private static Set validateEntities(IntentModel model, Set usesA validateDependsOn(entity, subject, relation.getDependsOn(), relation, byName, issues); } } + // The picker's caption (#7650) - a to-one FK property or a subset's multiselect column. + // A collection relation emits no property at all (the FK lives on the child), so a label + // authored there would be carried nowhere: refused rather than silently dropped. + if (relation.getLabel() != null || !relation.getCountryLabels() + .isEmpty()) { + String labelSubject = "entity [" + entity.getName() + "] relation [" + relation.getName() + "]"; + if ("oneToMany".equals(relation.getKind())) { + issues.add(labelSubject + " declares a `label`, but a collection relation renders no control of its own -" + + " label the field or relation the generated page actually shows"); + } else { + validateLabels(labelSubject, relation.getLabel(), relation.getCountryLabels(), issues); + } + } if (relation.getWhere() != null) { validateWhere(entity, relation, byName, issues); } @@ -6879,12 +6897,21 @@ private static void validateFormat(String subject, FieldIntent field, List issues) { - if (field.getLabel() != null && field.getLabel() - .isBlank()) { - issues.add(subject + " declares a blank `label` - remove it to keep the humanized field name"); + validateLabels(subject, field.getLabel(), field.getCountryLabels(), issues); + } + + /** + * The same rule for a RELATION's picker caption (#7650): a relation renders a control of its own - + * the picker, its list column and its details row - and the humanized relation name is as wrong + * there as a humanized field name is, for the same reason (the identifier was chosen for the model, + * and a picklist that cannot be named after what it picks reads as the identifier). + */ + private static void validateLabels(String subject, String label, java.util.Map countryLabels, List issues) { + if (label != null && label.isBlank()) { + issues.add(subject + " declares a blank `label` - remove it to keep the humanized name"); } - for (java.util.Map.Entry variant : field.getCountryLabels() - .entrySet()) { + for (java.util.Map.Entry variant : (countryLabels == null ? java.util.Map.of() + : countryLabels).entrySet()) { String country = variant.getKey() == null ? "" : variant.getKey() .trim() diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java index d9904f36f8d..44d2db19266 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java @@ -268,6 +268,10 @@ private static EntityIntent linkEntity(String linkName, EntityIntent owner, Rela toTarget.setSize(relation.getSize()); toTarget.setMajor(relation.isMajor()); toTarget.setLeafOnly(relation.isLeafOnly()); + // The caption belongs to the control the author sees, which after the expansion is the LINK's + // target picker - the declaring side becomes a navigation-only collection that renders none. + toTarget.setLabel(relation.getLabel()); + toTarget.setCountryLabels(relation.getCountryLabels()); link.getRelations() .add(toOwner); link.getRelations() @@ -299,6 +303,8 @@ private static void rewriteToNavigation(RelationIntent relation, String linkName relation.setShow(null); relation.setSize(null); relation.setLeafOnly(false); + relation.setLabel(null); + relation.setCountryLabels(null); } private static boolean isBlank(String value) { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index da39c04adcf..34d4a555274 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -451,6 +451,13 @@ field may declare: still resolve. Not allowed on a composition parent (preset, never picked) or an `EntityStatus`. Canonical shape - a stock line's Product picker excluding services: `- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }` +- `label: ` / `countryLabels: { : }` (on a to-one relation or a + `subset`, #7650) - **the picker's caption**, exactly as the field keys above: the relation is named + for the model, so the chooser, its list column and its details row all read as that identifier + until it is labelled. It rides on the relation's own FK property, so it is translated and + country-resolved like any field label. A collection relation (`oneToMany`) renders no control of + its own and is refused - label the field or relation the generated page actually shows. + `- { name: issuer, kind: manyToOne, to: Company, label: Issuing company }` - `pickable: { when: [ != null, ...], else: mark|hide, message: }` on a manyToOne/oneToOne (#7496) - **a picker rule over the TARGET's rows**: this is how "a customer with incomplete registration data cannot be picked onto an invoice" is declared, so the clerk diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java index d317d398858..c1aed378e70 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmFieldLabelTest.java @@ -77,14 +77,59 @@ void theVariantsStayOutOfTheEdmXmlWhileTheLabelDoesNot() { assertFalse(xml.contains("widgetCountryLabels"), "a structured value is never a property attribute"); } + /** + * A to-one relation's caption rides on the FK property the picker is rendered from (#7650), which + * is the same property the catalog key and every surface already read their label off. + */ + private static final String INVOICING = """ + name: invoicing + entities: + - name: Company + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - name: issuer + kind: manyToOne + to: Company + label: Issuing company + countryLabels: + bg: Издател + - { name: payer, kind: manyToOne, to: Company } + """; + + @Test + @SuppressWarnings("unchecked") + void aRelationsLabelReachesItsForeignKeyProperty() { + Map property = property(INVOICING, "invoicing", "Issuer"); + + assertEquals("Issuing company", property.get("widgetLabel")); + assertEquals("Издател", ((Map) property.get("widgetCountryLabels")).get("BG"), + "the runtime compares against the configured country in upper case"); + } + + @Test + void aRelationWithoutALabelCarriesNoneAtAll() { + assertNull(property(INVOICING, "invoicing", "Payer").get("widgetLabel"), + "an unlabelled relation must render exactly as it did before"); + assertNull(property(INVOICING, "invoicing", "Payer").get("widgetCountryLabels")); + } + @SuppressWarnings("unchecked") private static Map variants(String yaml, String propertyName) { return (Map) property(yaml, propertyName).get("widgetCountryLabels"); } - @SuppressWarnings("unchecked") private static Map property(String yaml, String propertyName) { - Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "payroll"); + return property(yaml, "payroll", propertyName); + } + + @SuppressWarnings("unchecked") + private static Map property(String yaml, String module, String propertyName) { + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), module); List> entities = (List>) ((Map) model.get("model")).get("entities"); return entities.stream() .flatMap(entity -> ((List>) entity.get("properties")).stream()) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java index d0621cc12ba..71394eab25f 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/FieldLabelIntentTest.java @@ -17,6 +17,7 @@ import java.util.Map; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.junit.jupiter.api.Test; /** @@ -41,6 +42,36 @@ class FieldLabelIntentTest { - { name: name, type: string } """; + /** + * A picker's caption is the same problem one construct over (#7650): the relation is named for the + * model, and the generated chooser, its list column and its details row all read as that identifier + * until the relation itself is labelled. + */ + private static final String INVOICING = """ + name: invoicing + entities: + - name: Company + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - name: issuer + kind: manyToOne + to: Company + label: Issuing company + countryLabels: + BG: Издател + - { name: lines, kind: oneToMany, to: InvoiceLine } + - name: InvoiceLine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: invoice, kind: manyToOne, to: Invoice, composition: true } + """; + @Test void aFieldCarriesItsAuthoredLabel() { FieldIntent field = field(IntentParser.parse(PAYROLL), "nationalId"); @@ -108,6 +139,49 @@ void aBlankLabelIsRejected() { assertIssue(PAYROLL.replace("label: National ID", "label: \" \""), "declares a blank `label`"); } + @Test + void aToOneRelationCarriesItsAuthoredPickerCaption() { + RelationIntent relation = relation(IntentParser.parse(INVOICING), "issuer"); + + assertEquals("Issuing company", relation.getLabel()); + assertEquals("Издател", relation.getCountryLabels() + .get("BG")); + } + + @Test + void aRelationThatDeclaresNeitherIsUnaffected() { + RelationIntent relation = relation(IntentParser.parse(INVOICING), "lines"); + + assertNull(relation.getLabel()); + assertTrue(relation.getCountryLabels() + .isEmpty(), + "absent variants must be an empty map, not a null the generators trip over"); + } + + /** A collection renders no control of its own, so a caption authored there would go nowhere. */ + @Test + void aLabelOnACollectionRelationIsRejected() { + assertIssue( + INVOICING.replace(" - { name: lines, kind: oneToMany, to: InvoiceLine }", + " - { name: lines, kind: oneToMany, to: InvoiceLine, label: Line items }"), + "a collection relation renders no control of its own"); + } + + @Test + void aRelationVariantIsHeldToTheSameCountryRule() { + assertIssue(INVOICING.replace(" BG: Издател", " bulgaria: Издател"), "which is not an ISO 3166-1 alpha-2 country code"); + } + + private static RelationIntent relation(IntentModel model, String name) { + return model.getEntities() + .stream() + .flatMap(entity -> entity.getRelations() + .stream()) + .filter(relation -> name.equals(relation.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("no relation [" + name + "]")); + } + private static void assertIssue(String yaml, String expected) { IntentValidationException exception = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml));