fix(uns): enforce the UNS segment rule on every authoring path (#535)
v2-ci / build (push) Successful in 4m30s
v2-ci / unit-tests (push) Failing after 16m11s

An area or line could be named "Packaging" or "Line 1", save cleanly, and then
fail the NEXT deployment with UnsSegmentInvalid. Because a deployment snapshots
the whole database, one badly-named area blocked EVERY cluster's deploy - and
whoever hit that was usually not whoever typed the name. Only the equipment form
carried the rule; the deploy gate held its own private copy.

The rule now lives once, on Configuration.Validation.UnsSegment, and both ends
call it. DraftValidator no longer restates it.

Enforced in UnsTreeService, not just on the forms:
- Create/Update Area, Line and Equipment reject an invalid name before touching
  the row.
- The CSV import reports a bad name as an ERROR, not a skip - a skip means "you
  already have this row", which would tell the operator the equipment exists
  when it does not. Good rows in the same batch still insert.

A form attribute could never have covered the import, and with no bUnit in this
repo a rule only a form enforces is also a rule no test can reach. The forms
keep an attribute for the immediate message, but it now references the shared
pattern rather than restating it - a second copy is how these drifted apart.

One behaviour change beyond the fix: UnsSegment.AttributePattern admits
"_default", which the equipment form's hand-written literal rejected. The form
was stricter than the gate it was mirroring, so a _default row was uneditable
through the AdminUI.

Tests: 41, falsified with three production mutations - dropping the CreateArea
guard reds exactly the CreateArea rejections, turning the import error back into
a skip reds only the import test, and narrowing AttributePattern reds only the
attribute/IsValid agreement. Every rejection is paired with a name that must
still be accepted, or a guard that refused everything would pass.

Live-verified on docker-dev: the area modal now refuses "Packaging" in place
with the rule spelled out, "packaging-b" saves, and a CSV import of two rows
reports Inserted 1 / Skipped 0 / Errors 1 naming the offending row - the path no
form attribute reaches. Rig cleaned up and a further deployment sealed.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 11:53:58 -04:00
parent 9303289d05
commit 1a1dbc3f00
8 changed files with 418 additions and 20 deletions
+27
View File
@@ -51,6 +51,33 @@ area opens the Line modal with that area already selected). Build a branch
top-down: Area → Line → Equipment. Tags, virtual tags, and scripted alarms
are managed on the equipment page (see below).
### Naming: area, line and equipment names are UNS path segments
An area, line or equipment **name** becomes a level of the OPC UA NodeId
(`Area/Line/Equipment/EffectiveName`), so it must match
**`^[a-z0-9-]{1,32}$`** — lowercase letters, digits and dashes, 1–32
characters. The reserved `_default` is also accepted. `Packaging` and
`Line 1` are not valid; `packaging` and `line-1` are.
**Ids are a different thing and a different rule.** `UnsAreaId` /
`UnsLineId` are opaque handles that never reach a NodeId, so they take
`^[A-Za-z0-9_-]+$` — `SITEA-area1` is a fine id for an area named
`packaging`.
The rule is enforced in `UnsTreeService` on **every** authoring path —
create, rename, and the CSV import — and again at the deploy gate
(`UnsSegmentInvalid`). Both call the same `UnsSegment` helper, so they
cannot disagree.
⚠️ **Why it is enforced at authoring and not only at deploy (Gitea #535):**
until 2026-08-01 only the equipment form checked it, so an area or line
could be named `Packaging`, save cleanly, and fail the *next deployment* —
and because a deployment snapshots the **whole database**, one badly-named
area blocked **every** cluster's deploy, usually discovered by someone other
than whoever typed it. In the CSV import a bad name is reported as an
**error**, not a skip: a skip means "you already have this row", which would
say the equipment exists when it does not.
## Equipment page (`/uns/equipment/{id}`)
Clicking **Open** on an Equipment row (or **+ Equipment** on a Line) navigates