diff --git a/CONTEXT.md b/CONTEXT.md index fcc8182..730c10f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -169,6 +169,14 @@ _Avoid_: input, widget, picker To show the user what a postcode points to before the app stores it. _Avoid_: verify, validate (for this step) +**Form value**: +The text that a field gives its form when the form is sent: the canonical form of a postcode, the 6 digits of a legacy postcode, or the text without white space at the start and the end. +_Avoid_: submitted value, output + +**Message catalogue**: +A data file that holds the text of a UI in one language, one message for each key. +_Avoid_: strings file, translations, locale file + **Tell**: A habit that makes code look machine-written, such as very long lines. _Avoid_: smell (smells are design problems), anti-pattern diff --git a/README.md b/README.md index a9a7094..ba6e5f8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Every SDK runs the cases in [`vectors/`](vectors). |---|---| | [`grammar.md`](grammar.md) | how every SDK reads and writes postcodes, and the public interface | | [`client.md`](client.md) | how every client calls NIPOST's gateway: results, errors, retries and the queue | +| [`field.md`](field.md) | how every postcode field behaves: settings, events, states, accessibility and privacy | +| [`messages/`](messages) | the text of the field, one message catalogue for each language | | [`openapi/`](openapi) | NIPOST's gateway, completed with the responses that its own file leaves out | | [`fixtures/`](fixtures) | synthetic gateway responses and keys for the mock server | | [`contract/`](contract) | shared client test cases that every client must pass against the mock server | diff --git a/REUSE.toml b/REUSE.toml index a074a60..77c74f9 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,7 +1,9 @@ version = 1 [[annotations]] -path = ["**/*.md", "**/*.json", "**/*.yml", "VERSION", "NOTICE", "templates/NOTICE"] +path = [ + "**/*.md", "**/*.json", "**/*.yml", "messages/*.arb", "VERSION", "NOTICE", "templates/NOTICE", +] precedence = "aggregate" SPDX-FileCopyrightText = "2026 The Gatepost authors" SPDX-License-Identifier = "Apache-2.0" diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/field.md b/field.md new file mode 100644 index 0000000..0911213 --- /dev/null +++ b/field.md @@ -0,0 +1,205 @@ +# Field + +This file defines the postcode field of every Gatepost UI: the web field now, and the field of each later UI framework. A field reads the text with the core, and it sends each request through the client. `messages/field_en.arb` holds the text that the field shows. If this file and a field's tests disagree, this file wins. + +## Interface + +A field exposes the symbols in this table and the types that they need. A language file gives the idiomatic form of each name, as for the core. + +| Symbol | Kind | Meaning | +|---|---|---| +| `name`, `value`, `required`, `disabled` | setting | the standard settings of a form control. `value` gives the first text | +| `label` | setting | the visible label. The default is the message `label` | +| `apiKey` | setting | a publishable key. An empty key counts as no key. Without a key, the field sends no request | +| `baseUrl` | setting | the gateway's address, as in the client options | +| `confirm` | setting | `none`, `level1` or `level2`. The default is `level1` | +| `gps` | setting | true shows the location button. The default is false | +| `legacy` | setting | `accept` or `reject`. The default is `accept` | +| `messages` | setting | text that replaces messages of the catalogue, by key | +| `change` | event | the form value changed through the user | +| `confirm` | event | the gateway knows the postcode | +| `error` | event | a failure that the table in Failures gives an event code | +| `SPEC_VERSION` | constant | the spec version, as text | + +### Events + +| Event | Detail | +|---|---| +| `change` | `value`: the form value. `postcode`: the postcode when the text parses as a whole postcode, or null. `source`: `typed`, `pasted`, `suggestion` or `gps`. `accuracyM`: the accuracy of the location in metres when `source` is `gps`, or null | +| `confirm` | `lookup`: the lookup result of the client | +| `error` | `code`: an error code of the client, or `secret_key`, `gps_denied` or `gps_unavailable`. The table in Failures gives the code of each failure | + +### On the web + +The web field is the custom element `gatepost-postcode-field`. It is a form-associated element, so a plain HTML form submits its form value. + +- Each setting is an attribute in kebab-case: `api-key`, `base-url`, `confirm`, `gps`, `legacy`, `label`, `name`, `value`, `required` and `disabled`. The setting `messages` is a property only, because it holds an object. +- The attribute `value` gives the first text. The property `value` sets the current text, and reading it gives the form value. +- An invalid field gives its message in `validationMessage`, as a native input does. +- Each event has the prefix `gatepost-`: `gatepost-change`, `gatepost-confirm` and `gatepost-error`. The event holds its detail in `detail`, and it bubbles, so a page can listen on the form. +- The module defines the element only when `customElements` exists, so a server can render a page that holds it. + +## Form value and validity + +- The form value is the canonical form when the text parses as a whole postcode. +- With `legacy` set to `accept`, the 6 digits of a legacy postcode are a form value too. +- For any other text, the form value is the text without white space at the start and the end. A server can then show the user what they typed. +- The field is invalid when `required` is set and the text is empty. It is also invalid when the text is not empty, and its form value is neither of the first two kinds above. So a legacy postcode is invalid when `legacy` is `reject`. +- An invalid field reports the text of its message as its validation message: `empty`, the message of the parse error, or `legacy_rejected`. It reports this text even before the field shows the error. +- A lookup never changes the validity. A postcode that the gateway does not know, and a failed request, leave the field valid. The gateway's data is new, so a wrong answer must not stop a user. The app's server checks the postcode again. +- The setting `value` gives the first text. A form reset restores this text, and the field again waits before it shows an error. +- The field raises `change` only when the user changes the form value: by typing, by pasting, with the suggestion button or with the location button. Text that the app sets raises no `change`. + +## Text + +- The field reads its text with `parse`. Partial postcodes do not pass. +- The count of a text is the number of code points in its normalised form. Spaces and hyphens do not count. +- Above `maxInputCodePoints` code points (64 in `data/format.json`), the field never calls `normalize`, because NFKC can take seconds on a long text. The count is then the number of code points of the raw text. +- An empty text is never a parse error. It gives the state `idle`, except in a required field that shows errors. +- The field shows errors after the user first leaves the input, or after a validity check fails, such as on a form submit. It stops after a form reset. +- A parse error shows when the field shows errors, or when the count of the text is 11 or more. So a text above the input limit shows its error at once. +- A user who is still typing a short text sees no error. +- A legacy postcode shows its message at once. A new postcode starts with letters, so 6 digits are never the start of one. +- When the user leaves the input, a text that parses shows in the display form. The form value and the state stay the same, so the field raises no `change` and sends no new lookup. +- For an error with a suggestion, the field shows the suggestion and a button that uses it. The field never applies a suggestion by itself. +- The message of the parse error and the message `suggestion` are two separate texts, never one joined string (UI-2). +- The suggestion button puts the suggestion in the input, in the display form. It raises `change` with the source `suggestion`, and it moves the focus to the input. +- Enter submits the form, as in a native text input. + +## Lookups + +- The field sends a request only when it has a key. Without a key, it checks the format offline, and a text that parses ends in the state `valid`. +- With a key, and `confirm` set to `level1` or `level2`, the field asks the gateway about each whole postcode, with a lookup at that level. It sends no lookup for a legacy postcode. +- The field remembers a lookup that ended with an answer from the gateway: its canonical postcode, its level and its key. The answer gives the state `confirmed` or `not found`. +- When the text or a setting changes, and the three values still match the remembered lookup, the field keeps that lookup and its state. It sends no new request. +- A lookup in progress also stays while the three values still match it. +- When one of the three values changes, the field cancels the lookup in progress. It forgets the remembered lookup and its state. It then starts a new lookup if the text parses as a whole postcode. +- For a text that is not a whole postcode, the field remembers no lookup, so the same postcode typed again gets a new lookup. +- The field never remembers a lookup that failed or was cancelled. So the next change of the text or of a setting tries the same postcode again. +- The state `error` lasts until the text or a setting changes. +- A change of `baseUrl` alone starts no lookup and cancels none. The next request uses the new address. +- A press of the location button cancels the lookup in progress, and the field forgets the remembered lookup. +- The field ignores the answer to a cancelled request. It changes nothing on screen, and it raises no event. +- The field refuses a secret key, which starts with `nipost_test_` or `nipost_live_`. It sends no request with that key, and it shows no location button. Location says what happens to a location request in progress when the key changes. +- The field logs one console error for the developer each time it reads a secret key. It reads the key when it starts, and when the key or `baseUrl` changes. The error never holds the key (ERR-3). +- In place of each lookup that it would start, the field shows the state `error` and raises the event `error` with the code `secret_key`. So an app that adds its listener late still hears of it. With `confirm` set to `none`, the field raises no event. +- At level 2, the confirmation names the locality, the LGA and the state from the administrative address, when the lookup gives all three. Otherwise, it names the state of the postcode with `stateName`. +- The event `confirm` gives the app the whole lookup result. Privacy says what the field itself never shows. + +## Location + +- With `gps` set and a key that the field accepts, the field shows a button that uses the device's location. The field asks for the location only when the user presses the button. +- The field asks the platform for one fix, with high accuracy, no saved fix, and a time limit of 15 seconds. +- A platform with no location API gives the failure `gps_unavailable` at once. +- The field sends the coordinate to the gateway with `reverse`. The radius is the accuracy in whole metres, rounded up, at least 25 and at most 250. +- From the result, the field takes the unit's postcode. When the result has no unit, it takes the area, and then the district, if that text parses as a partial postcode. It never takes the state alone. +- `precisionForAccuracy` gives the most precise segment that the accuracy supports. The field truncates the postcode to that precision, unless the postcode already stops before it. +- So a fix worse than 50 m keeps the state and the LGA only, from a unit, an area or a district. +- When the result holds no postcode, the field shows the message `gps_not_found`. +- Otherwise, the field replaces the text with the postcode in the display form. It raises `change` when the form value changes, as for typing. The source is `gps`, and `accuracyM` is the accuracy of the fix. +- A whole postcode then follows the rules for a typed one. It gets a lookup, or the state `valid` when `confirm` is `none`. +- A partial postcode ends in a space, so the user can type the next segment at once. The input takes the focus, and the field shows the state `GPS coarse`. +- A partial postcode leaves the field invalid until the text is a whole postcode. +- The user can always type. A change of the text cancels a location request in progress. +- A new press of the button and the removal of the field also cancel it. A change of a setting never cancels it, not even a change of the key. +- A request that the field sends after a change of the key follows the rules for the new key. When the position arrives, the field sends `reverse` with the key that it holds then. +- With a secret key, it sends nothing, shows the state `error` and raises the event `error` with the code `secret_key`. With no key, it sends nothing and shows the state that its text gives. +- A `reverse` call that the field sent before the change stays, and its answer counts. +- While a location request is in progress, the field starts no lookup. A change of a setting then changes only the validity. +- The states `GPS coarse` and `GPS denied` last until the text changes. + +## Removal from the page + +- When the field is removed from the page, it cancels any request in progress. It forgets the remembered lookup and the state of each request. It then shows the state that its text gives. +- When the field is added again, it starts from that state, as at the first start. So a whole postcode gets a new lookup. + +## Failures + +Each failure gives one state and one message. The last column gives the code of the event `error`, or "no event". A parse error raises no event either. The field shows it in the state `invalid format`. + +| Failure | State | Message | Event code | +|---|---|---|---| +| the gateway does not know the postcode | not found | `not_found` | no event | +| a lookup fails with an error of the client | error | `check_failed` | the code of the client's error, such as `network_error` or `rate_limited` | +| a lookup throws an error that is not an error of the client, such as for a base address with no scheme | error | `check_failed` | `network_error` | +| a lookup is due, and the key is a secret key | error | `secret_key` | `secret_key` | +| the user or the platform refuses the location | GPS denied | `gps_denied` | `gps_denied` | +| the platform has no location API | GPS denied | `gps_unavailable` | `gps_unavailable` | +| the device reports that it has no location | GPS denied | `gps_unavailable` | `gps_unavailable` | +| the device gives no location within the time limit | GPS denied | `gps_unavailable` | `gps_unavailable` | +| `reverse` fails with an error of the client | GPS denied | `gps_unavailable` | the code of the client's error | +| `reverse` throws an error that is not an error of the client | GPS denied | `gps_unavailable` | `network_error` | +| `reverse` gives no postcode that parses | GPS denied | `gps_not_found` | no event | +| `reverse` is due, and the key is now a secret key | error | `secret_key` | `secret_key` | + +## States + +The field shows the first state in this table whose condition holds. The first seven states come from a request. Lookups, Location and Removal from the page say when each of them ends. + +| State | When | Messages | +|---|---|---| +| GPS locating | a location request is in progress | `locating` | +| checking | a lookup is in progress | `checking` | +| confirmed | the remembered lookup found the postcode | `confirmed` or `confirmed_place` | +| not found | the remembered lookup says that the gateway does not know the postcode | `not_found` | +| error | the last lookup failed. Or a lookup or a `reverse` call was due, and the key is a secret key | `check_failed` or `secret_key` | +| GPS coarse | the last location request gave a partial postcode, and the text has not changed since | `gps_coarse` | +| GPS denied | the last location request failed, and the text has not changed since | `gps_denied`, `gps_unavailable` or `gps_not_found` | +| legacy code | the text is a legacy postcode | `legacy_accepted` or `legacy_rejected` | +| valid | the text parses as a whole postcode | `valid` | +| invalid format | the text is not empty and does not parse, and its parse error shows (see Text). Or the text is empty, `required` is set and the field shows errors | the message of the parse error and `suggestion`, or `empty` | +| typing | the text is not empty and does not parse, and its parse error does not show yet | none | +| idle | the text is empty, and the row `invalid format` does not apply | none | + +## Messages + +`messages/field_en.arb` holds each message in British English, in the ARB format that Flutter also reads. Each message has a description. A name in braces, such as `{count}`, is a placeholder, and the field gives its value. A translation is a file `messages/field_.arb` with the same keys and placeholders. A field shows the catalogue text for a state or an error code, never the message of an error object (UI-2). + +### Message keys + +| Key | Placeholders | Shown | +|---|---|---| +| `label` | none | as the label, when the page gives no `label` | +| `hint` | none | under the label, at all times | +| `empty` | none | for the parse error `empty`, when `required` is set | +| `bad_character` | none | for the parse error `bad_character` | +| `bad_length` | `{count}` | for the parse error `bad_length`. `{count}` is the count of the text | +| `unknown_state` | none | for the parse error `unknown_state` | +| `bad_lga` | none | for the parse error `bad_segment` in the LGA | +| `bad_area` | none | for the parse error `bad_segment` in the area | +| `bad_unit` | none | for the parse error `bad_segment` in the unit | +| `suggestion` | `{postcode}` | after a parse error that has a suggestion. `{postcode}` is the suggestion in the display form | +| `use_suggestion` | none | as the text of the button that uses the suggestion | +| `legacy_accepted` | none | for a legacy postcode, when `legacy` is `accept` | +| `legacy_rejected` | none | for a legacy postcode, when `legacy` is `reject` | +| `valid` | `{state}` | in the state `valid`. `{state}` is the name that `stateName` gives | +| `checking` | none | in the state `checking` | +| `not_found` | none | in the state `not found` | +| `confirmed` | `{state}` | in the state `confirmed`. `{state}` is the name that `stateName` gives | +| `confirmed_place` | `{locality}`, `{lga}`, `{state}` | in the state `confirmed`, when the lookup gives the locality, the LGA and the state names | +| `check_failed` | none | in the state `error`, after a failed lookup | +| `secret_key` | none | in the state `error`, for a secret key | +| `use_location` | none | as the text of the location button | +| `locating` | none | in the state `GPS locating` | +| `gps_coarse` | none | in the state `GPS coarse`. A precise fix with no unit also gives a partial postcode, so the text names no cause and no count of segments | +| `gps_not_found` | none | in the state `GPS denied`, when `reverse` found no postcode | +| `gps_denied` | none | in the state `GPS denied`, when the user or the platform refused the location | +| `gps_unavailable` | none | in the state `GPS denied`, when the platform has no location API, the device gave no location, or `reverse` failed | + +The district segment accepts any letters and digits, so no parse error names it. + +## Accessibility + +- The field meets WCAG 2.2 AA (UI-1). +- The label names the input. The hint and the current message describe it. +- A screen reader hears each new message once, through a polite live region. +- The field marks the input invalid only in the state `invalid format`, and in the state `legacy code` when `legacy` is `reject`. +- Each control works with the keyboard alone. The tab order is the input, the suggestion button, then the location button. +- The field follows the user's reduced-motion setting and text size (UI-4). + +## Privacy + +- The field stores nothing, sets no cookie and sends no telemetry (SEC-2). +- The field sends a request only for a lookup of a whole postcode, and for `reverse` after the user presses the location button. +- The field never shows a house address, neither the `recentHouseAddress` of a lookup nor the `address` of a reverse unit. A user can type any postcode, and the unit nearest to a location is often a neighbour's house. Either address can belong to someone else. +- The event `confirm` still gives the app the whole lookup result. The app decides what it shows. diff --git a/grammar.md b/grammar.md index a87b6c9..57b2d68 100644 --- a/grammar.md +++ b/grammar.md @@ -1,6 +1,6 @@ # Postcode grammar -Spec version 0.2.0. This file defines how every Gatepost SDK reads and writes Nigeria's digital postcodes. The values live in `data/`. The vectors in `vectors/` test every rule, except the rules in the list below. If this file and a vector disagree, this file wins. Such a disagreement is a bug in the spec. +Spec version 0.3.0. This file defines how every Gatepost SDK reads and writes Nigeria's digital postcodes. The values live in `data/`. The vectors in `vectors/` test every rule, except the rules in the list below. If this file and a vector disagree, this file wins. Such a disagreement is a bug in the spec. No shared vector tests these rules. Each SDK tests them itself. diff --git a/messages/field_en.arb b/messages/field_en.arb new file mode 100644 index 0000000..936acc7 --- /dev/null +++ b/messages/field_en.arb @@ -0,0 +1,138 @@ +{ + "@@locale": "en", + "label": "Postcode", + "@label": { + "description": "The label of the input, when the page gives no label." + }, + "hint": "11 letters and numbers, for example EK 01 A03 FK 01", + "@hint": { + "description": "The hint under the label. The example is the one in NIPOST's docs." + }, + "empty": "Enter your postcode.", + "@empty": { + "description": "The input is required and empty." + }, + "bad_character": "Use only letters and numbers.", + "@bad_character": { + "description": "The text holds a character that a postcode never has, such as a comma." + }, + "bad_length": "A postcode has 11 letters and numbers. You entered {count}.", + "@bad_length": { + "description": "The text has too few or too many characters. Spaces and hyphens do not count.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "unknown_state": "The first part must be a state code, such as LA or FC.", + "@unknown_state": { + "description": "The first 2 letters are not one of the 37 state codes." + }, + "bad_lga": "The second part must be a number from 01 to 99.", + "@bad_lga": { + "description": "The LGA segment, the second part, is not a number from 01 to 99." + }, + "bad_area": "The fourth part must be 2 letters.", + "@bad_area": { + "description": "The area segment, the fourth part, holds a digit." + }, + "bad_unit": "The last part must be a number from 01 to 99.", + "@bad_unit": { + "description": "The unit segment, the last part, is not a number from 01 to 99." + }, + "suggestion": "Did you mean {postcode}?", + "@suggestion": { + "description": "A fix of look-alike characters, such as O for 0, gives this postcode.", + "placeholders": { + "postcode": { + "type": "String" + } + } + }, + "use_suggestion": "Use this postcode", + "@use_suggestion": { + "description": "The button that puts the suggested postcode in the input." + }, + "legacy_accepted": "This is an old 6-digit postcode. If you know your new one, enter it instead.", + "@legacy_accepted": { + "description": "The text is an old postcode, and the page accepts old postcodes." + }, + "legacy_rejected": "This is an old 6-digit postcode. Enter a new one, such as EK 01 A03 FK 01.", + "@legacy_rejected": { + "description": "The text is an old postcode, and the page needs a new one." + }, + "valid": "This postcode is in {state}.", + "@valid": { + "description": "The postcode has the right format. No check with NIPOST ran.", + "placeholders": { + "state": { + "type": "String" + } + } + }, + "checking": "Checking your postcode.", + "@checking": { + "description": "The field is asking NIPOST about the postcode." + }, + "not_found": "We could not find this postcode. Check each part, or continue if you are sure.", + "@not_found": { + "description": "NIPOST does not know the postcode. The user can still send the form." + }, + "confirmed": "We found this postcode in {state}.", + "@confirmed": { + "description": "NIPOST knows the postcode.", + "placeholders": { + "state": { + "type": "String" + } + } + }, + "confirmed_place": "We found this postcode: {locality}, {lga}, {state}.", + "@confirmed_place": { + "description": "NIPOST knows the postcode and gave the names of its place.", + "placeholders": { + "locality": { + "type": "String" + }, + "lga": { + "type": "String" + }, + "state": { + "type": "String" + } + } + }, + "check_failed": "We could not check this postcode just now. You can still continue.", + "@check_failed": { + "description": "The request to NIPOST failed, for example with no network." + }, + "secret_key": "We cannot check postcodes on this page. You can still continue.", + "@secret_key": { + "description": "The page holds a secret key, so the field sends no request." + }, + "use_location": "Use my location", + "@use_location": { + "description": "The button that finds the postcode of the user's location." + }, + "locating": "Finding your location.", + "@locating": { + "description": "The field waits for the location, or for NIPOST's answer about it." + }, + "gps_coarse": "We added part of your postcode from your location. Type the rest.", + "@gps_coarse": { + "description": "The location gave only part of a postcode, which the field put in the input." + }, + "gps_not_found": "We found no postcode at your location. Type your postcode.", + "@gps_not_found": { + "description": "NIPOST has no postcode near the location." + }, + "gps_denied": "We cannot use your location. Type your postcode.", + "@gps_denied": { + "description": "The user or the platform refused the location." + }, + "gps_unavailable": "We could not find your location. Type your postcode.", + "@gps_unavailable": { + "description": "The device gave no location, or the request to NIPOST failed." + } +} diff --git a/scripts/tests/test_field_doc.py b/scripts/tests/test_field_doc.py new file mode 100644 index 0000000..9938490 --- /dev/null +++ b/scripts/tests/test_field_doc.py @@ -0,0 +1,404 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for field.md and its message catalogue, and for the files that point to them.""" + +import json +import re +import unittest +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +FIELD = (ROOT / "field.md").read_text(encoding="utf-8") +CLIENT = (ROOT / "client.md").read_text(encoding="utf-8") +CATALOGUE = ROOT / "messages" / "field_en.arb" +STANDARDS = ROOT / "standards" / "CODING_STANDARDS.md" +TYPESCRIPT = ROOT / "standards" / "languages" / "typescript.md" +PLACEHOLDER = re.compile(r"\{(\w+)\}") +CODE = re.compile(r"`([a-z_]+)`") +FIELD_CODES = {"secret_key", "gps_denied", "gps_unavailable"} +STATES = [ + "GPS locating", + "checking", + "confirmed", + "not found", + "error", + "GPS coarse", + "GPS denied", + "legacy code", + "valid", + "invalid format", + "typing", + "idle", +] + + +def section(heading: str) -> str: + """Return the text of field.md under a heading, up to the next heading of any level.""" + return re.split(r"\n#+ ", FIELD.partition(f"\n{heading}\n")[2])[0] + + +def rows(heading: str) -> list[list[str]]: + """Return the cells of each row of the table under a heading, after its header row.""" + lines = [line for line in section(heading).splitlines() if line.startswith("| ")] + return [[cell.strip() for cell in line.strip().strip("|").split("|")] for line in lines[1:]] + + +def first_cells(heading: str) -> list[str]: + """Return the first cell of each row of the table under a heading, without backticks.""" + return [cells[0].strip("`") for cells in rows(heading)] + + +def catalogue() -> dict[str, Any]: + document: object = json.loads(CATALOGUE.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise TypeError("field_en.arb must hold a JSON object.") + return document + + +def messages() -> dict[str, str]: + return {key: text for key, text in catalogue().items() if not key.startswith("@")} + + +def client_codes() -> set[str]: + """Return the error codes of the client, from the table of codes in client.md.""" + lines = CLIENT.partition("\n## Errors\n")[2].partition("\n## ")[0].splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith("| Condition |")) + codes: set[str] = set() + for line in lines[start + 2 :]: + if not line.startswith("| "): + break + codes.update(CODE.findall(line.split("|")[2])) + return codes + + +def sentences(text: str) -> list[str]: + """Return the sentences of the prose and the table cells of a Markdown text.""" + found: list[str] = [] + for line in text.splitlines(): + if line.startswith("#") or set(line) <= set("|-"): + continue + for part in line.strip("|").split(" | "): + body = re.sub(r"^\s*- ", "", part).strip() + found.extend(x for x in re.split(r"(?<=[.?!])\s+", body) if x) + return found + + +class CatalogueTest(unittest.TestCase): + def test_the_catalogue_is_english(self) -> None: + self.assertEqual(catalogue()["@@locale"], "en") + + def test_field_md_lists_each_key_of_the_catalogue_once(self) -> None: + keys = first_cells("### Message keys") + self.assertEqual(len(keys), len(set(keys))) + self.assertEqual(sorted(keys), sorted(messages())) + + def test_field_md_names_the_placeholders_of_each_message(self) -> None: + for cells in rows("### Message keys"): + key = cells[0].strip("`") + with self.subTest(key=key): + named = set(re.findall(r"`\{(\w+)\}`", cells[1])) + self.assertEqual(cells[1] == "none", not named) + declared = catalogue()[f"@{key}"].get("placeholders", {}) + self.assertEqual(named, set(declared)) + + def test_each_message_has_a_description(self) -> None: + for key in messages(): + with self.subTest(key=key): + self.assertTrue(catalogue()[f"@{key}"]["description"]) + + def test_each_placeholder_in_a_text_is_declared_and_each_declared_one_is_used(self) -> None: + for key, text in messages().items(): + with self.subTest(key=key): + declared = catalogue()[f"@{key}"].get("placeholders", {}) + self.assertEqual(set(PLACEHOLDER.findall(text)), set(declared)) + + def test_each_line_of_the_catalogue_has_100_characters_or_fewer(self) -> None: + for number, line in enumerate(CATALOGUE.read_text(encoding="utf-8").splitlines(), 1): + with self.subTest(line=number): + self.assertLessEqual(len(line), 100) + + def test_the_coarse_message_fits_every_partial_postcode_from_a_location(self) -> None: + text = messages()["gps_coarse"] + self.assertEqual(PLACEHOLDER.findall(text), []) + for word in ("first", "accurate", "metres"): + self.assertNotIn(word, text) + + def test_each_state_names_only_keys_of_the_catalogue(self) -> None: + cells = [row[2] for row in rows("## States")] + self.assertEqual(len(cells), len(STATES)) + named = {key for cell in cells for key in CODE.findall(cell)} + self.assertLessEqual(named, set(messages())) + + +class InterfaceTest(unittest.TestCase): + def test_the_interface_lists_the_settings_events_and_constant(self) -> None: + self.assertEqual( + first_cells("## Interface"), + [ + "name`, `value`, `required`, `disabled", + "label", + "apiKey", + "baseUrl", + "confirm", + "gps", + "legacy", + "messages", + "change", + "confirm", + "error", + "SPEC_VERSION", + ], + ) + + def test_an_empty_key_counts_as_no_key(self) -> None: + api_key = next(x for x in rows("## Interface") if x[0] == "`apiKey`") + self.assertIn("An empty key counts as no key.", api_key[2]) + + def test_the_change_event_names_each_source(self) -> None: + change = next(x for x in section("### Events").splitlines() if x.startswith("| `change`")) + self.assertIn("`typed`, `pasted`, `suggestion` or `gps`", change) + + def test_the_twelve_states_of_the_design_are_listed_in_their_order(self) -> None: + self.assertEqual(first_cells("## States"), STATES) + lead = "The field shows the first state in this table whose condition holds." + self.assertIn(lead, section("## States")) + + def test_each_failure_gives_a_state_a_message_and_an_event_code(self) -> None: + client = "the code of the client's error" + self.assertEqual( + [(cells[1], cells[2], cells[3].partition(",")[0]) for cells in rows("## Failures")], + [ + ("not found", "`not_found`", "no event"), + ("error", "`check_failed`", client), + ("error", "`check_failed`", "`network_error`"), + ("error", "`secret_key`", "`secret_key`"), + ("GPS denied", "`gps_denied`", "`gps_denied`"), + ("GPS denied", "`gps_unavailable`", "`gps_unavailable`"), + ("GPS denied", "`gps_unavailable`", "`gps_unavailable`"), + ("GPS denied", "`gps_unavailable`", "`gps_unavailable`"), + ("GPS denied", "`gps_unavailable`", client), + ("GPS denied", "`gps_unavailable`", "`network_error`"), + ("GPS denied", "`gps_not_found`", "no event"), + ("error", "`secret_key`", "`secret_key`"), + ], + ) + + def test_each_event_code_of_a_failure_is_a_code_of_the_client_or_the_field(self) -> None: + error = next(x for x in rows("### Events") if x[0] == "`error`") + self.assertEqual(set(CODE.findall(error[1].partition(": ")[2])), FIELD_CODES) + for cells in rows("## Failures"): + with self.subTest(failure=cells[0]): + self.assertLessEqual(set(CODE.findall(cells[3])), client_codes() | FIELD_CODES) + self.assertIn(cells[2].strip("`"), messages()) + + def test_a_lookup_never_blocks_the_form(self) -> None: + self.assertIn("A lookup never changes the validity.", section("## Form value and validity")) + + def test_an_invalid_field_reports_its_message(self) -> None: + part = section("## Form value and validity") + self.assertIn("An invalid field reports the text of its message", part) + self.assertIn("The setting `value` gives the first text.", part) + + +class TextTest(unittest.TestCase): + def test_a_text_above_the_input_limit_is_never_normalised(self) -> None: + part = section("## Text") + self.assertIn("the field never calls `normalize`", part) + self.assertIn("The count is then the number of code points of the raw text.", part) + + def test_the_parse_error_and_the_suggestion_stay_two_texts(self) -> None: + self.assertIn("never one joined string (UI-2)", section("## Text")) + + def test_an_empty_text_is_never_a_parse_error(self) -> None: + part = section("## Text") + self.assertIn("An empty text is never a parse error.", part) + self.assertIn("The field shows errors after the user first leaves the input", part) + states = {row[0]: row[1] for row in rows("## States")} + for state in ("invalid format", "typing"): + self.assertIn("the text is not empty and does not parse", states[state]) + + def test_leaving_the_input_keeps_the_state(self) -> None: + self.assertIn("The form value and the state stay the same", section("## Text")) + + def test_the_display_form_sends_no_new_lookup(self) -> None: + part = section("## Lookups") + self.assertIn("the field keeps that lookup and its state", part) + + def test_the_console_error_never_holds_the_key(self) -> None: + self.assertIn("The error never holds the key (ERR-3).", section("## Lookups")) + + +class RequestTest(unittest.TestCase): + lookups = section("## Lookups") + + def test_the_error_event_follows_the_table_of_failures(self) -> None: + error = next(x for x in rows("## Interface") if x[0] == "`error`") + self.assertIn("a failure that the table in Failures gives an event code", error[2]) + + def test_the_field_remembers_only_a_lookup_that_the_gateway_answered(self) -> None: + self.assertIn("The field remembers a lookup that ended with an answer", self.lookups) + self.assertIn("never remembers a lookup that failed or was cancelled", self.lookups) + + def test_a_new_gateway_address_alone_sends_no_lookup(self) -> None: + self.assertIn( + "A change of `baseUrl` alone starts no lookup and cancels none.", self.lookups + ) + + def test_a_setting_never_cancels_a_location_request(self) -> None: + part = section("## Location") + self.assertIn("A change of a setting never cancels it, not even a change of the key.", part) + self.assertIn("While a location request is in progress, the field starts no lookup.", part) + + def test_a_location_fill_raises_change_only_for_a_new_form_value(self) -> None: + self.assertIn("It raises `change` when the form value changes", section("## Location")) + + def test_a_removed_field_starts_again_from_its_text(self) -> None: + part = section("## Removal from the page") + self.assertIn("it cancels any request in progress", part) + self.assertIn("forgets the remembered lookup", part) + self.assertIn("When the field is added again", part) + + +class PinTest(unittest.TestCase): + """Each rule here is a sentence that a later edit could drop without another test failing.""" + + def test_each_rule_of_a_lookup_is_in_lookups(self) -> None: + part = section("## Lookups") + for rule in ( + "A lookup in progress also stays while the three values still match it.", + "When one of the three values changes, the field cancels the lookup in progress." + " It forgets the remembered lookup and its state.", + "For a text that is not a whole postcode, the field remembers no lookup," + " so the same postcode typed again gets a new lookup.", + "The state `error` lasts until the text or a setting changes.", + "A press of the location button cancels the lookup in progress, and the field" + " forgets the remembered lookup.", + "It sends no request with that key, and it shows no location button.", + ): + with self.subTest(rule=rule): + self.assertIn(rule, part) + + def test_each_rule_of_a_location_request_is_in_location(self) -> None: + part = section("## Location") + for rule in ( + "A new press of the button and the removal of the field also cancel it.", + "A change of a setting never cancels it, not even a change of the key.", + "A request that the field sends after a change of the key follows the rules for" + " the new key.", + "When the position arrives, the field sends `reverse` with the key that it holds then.", + "With a secret key, it sends nothing, shows the state `error` and raises the event" + " `error` with the code `secret_key`.", + "With no key, it sends nothing and shows the state that its text gives.", + "The states `GPS coarse` and `GPS denied` last until the text changes.", + ): + with self.subTest(rule=rule): + self.assertIn(rule, part) + + def test_a_parse_error_shows_at_once_for_a_long_text(self) -> None: + self.assertIn( + "A parse error shows when the field shows errors, or when the count of the text is" + " 11 or more.", + section("## Text"), + ) + + def test_a_required_empty_field_that_shows_errors_is_invalid(self) -> None: + states = {row[0]: row[1] for row in rows("## States")} + self.assertIn( + "Or the text is empty, `required` is set and the field shows errors", + states["invalid format"], + ) + self.assertIn("a secret key", states["error"]) + self.assertIn("`reverse`", states["error"]) + + def test_the_catalogue_says_when_the_device_gives_no_location(self) -> None: + description = catalogue()["@gps_unavailable"]["description"] + self.assertIn("The device gave no location", description) + + +class LocationTest(unittest.TestCase): + part = section("## Location") + + def test_a_fallback_is_truncated_to_the_precision_of_the_fix(self) -> None: + self.assertIn("The field truncates the postcode to that precision", self.part) + self.assertIn("a fix worse than 50 m keeps the state and the LGA only", self.part) + + def test_the_fill_ends_in_a_space_and_takes_the_focus(self) -> None: + self.assertIn("A partial postcode ends in a space", self.part) + self.assertIn("The input takes the focus", self.part) + self.assertIn("The source is `gps`", self.part) + + def test_a_platform_with_no_location_api_gives_gps_unavailable(self) -> None: + self.assertIn( + "A platform with no location API gives the failure `gps_unavailable`", self.part + ) + + +class PrivacyTest(unittest.TestCase): + def test_the_field_never_shows_a_house_address(self) -> None: + self.assertIn( + "The field never shows a house address, neither the `recentHouseAddress` of a lookup" + " nor the `address` of a reverse unit.", + section("## Privacy"), + ) + + +class ProseTest(unittest.TestCase): + def test_each_sentence_of_field_md_has_25_words_or_fewer(self) -> None: + for sentence in sentences(FIELD): + with self.subTest(sentence=sentence): + self.assertLessEqual(len(sentence.split()), 25) + + def test_field_md_and_the_catalogue_use_no_phrasal_verb_that_a_review_found(self) -> None: + text = FIELD + CATALOGUE.read_text(encoding="utf-8") + for verb in ("looks up", "look up", "fills in", "fill in", "filled in"): + self.assertNotIn(verb, text) + + +class PointerTest(unittest.TestCase): + def test_the_standards_send_the_reader_to_field_md(self) -> None: + lines = STANDARDS.read_text(encoding="utf-8").splitlines() + ranking = next(x for x in lines if "rank above this document" in x) + api_1 = next(x for x in lines if x.startswith("- **API-1")) + self.assertIn("`spec/field.md`", ranking) + self.assertIn("`spec/messages/`", ranking) + self.assertIn("`spec/field.md`", api_1) + + def test_the_agents_template_names_each_interface_section(self) -> None: + template = (ROOT / "templates" / "AGENTS.md").read_text(encoding="utf-8") + rule = next(x for x in template.splitlines() if "public symbol" in x) + for name in ("`spec/grammar.md`", "`spec/client.md`", "`spec/field.md`"): + self.assertIn(name, rule) + + def test_the_typescript_file_names_the_additions_of_the_field(self) -> None: + text = TYPESCRIPT.read_text(encoding="utf-8") + additions = text.partition("### Idiomatic additions (API-1)")[2].partition("\n## ")[0] + names = ( + "PostcodeFieldElement", + "ChangeDetail", + "PostcodeFieldProps", + "Messages", + "formAssociated", + "observedAttributes", + "connectedCallback()", + "disconnectedCallback()", + "attributeChangedCallback()", + "formResetCallback()", + "formDisabledCallback()", + "formStateRestoreCallback()", + ) + for name in names: + with self.subTest(name=name): + self.assertIn(f"`{name}`", additions) + self.assertNotIn("monocart", text) + + def test_the_glossary_and_field_md_give_one_form_value(self) -> None: + context = (ROOT / "CONTEXT.md").read_text(encoding="utf-8") + entry = context.partition("**Form value**:\n")[2].partition("\n")[0] + self.assertIn("without white space at the start and the end", entry) + self.assertIn("without white space at the start and the end", FIELD) + + def test_the_readme_lists_field_md_and_the_catalogues(self) -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("| [`field.md`](field.md) |", readme) + self.assertIn("| [`messages/`](messages) |", readme) diff --git a/standards/CODING_STANDARDS.md b/standards/CODING_STANDARDS.md index 882e753..e017a1b 100644 --- a/standards/CODING_STANDARDS.md +++ b/standards/CODING_STANDARDS.md @@ -11,7 +11,7 @@ These standards apply to every Gatepost repo, in every language. Each repo also - A **SHOULD** rule can be skipped only with a written reason in the pull request. - A rule marked **(tool)** is enforced by CI. Reviewers do not check it by hand. - A language file can change a SHOULD rule for its language. It cannot change a MUST rule. -- `spec/grammar.md` (with its Interface section), `spec/client.md`, `spec/data/`, `spec/vectors/`, `spec/fixtures/` and the contract scenarios in `spec/contract/` rank above this document. `spec/grammar.md` wins over a vector, and `spec/client.md` wins over a scenario. This document ranks above the language files. If two of them disagree, the higher one wins. Then fix the lower one. +- `spec/grammar.md` (with its Interface section), `spec/client.md`, `spec/field.md`, `spec/messages/`, `spec/data/`, `spec/vectors/`, `spec/fixtures/` and the contract scenarios in `spec/contract/` rank above this document. `spec/grammar.md` wins over a vector, and `spec/client.md` wins over a scenario. This document ranks above the language files. If two of them disagree, the higher one wins. Then fix the lower one. - To change a rule, open a pull request on the `spec` repo. Increase the version at the top of this file. ## Principles @@ -28,7 +28,7 @@ The rules come from these seven principles. If no rule covers a case, apply the ## Public interface -- **API-1 MUST.** Every public symbol maps to a concept in the Interface section of `spec/grammar.md` or `spec/client.md`. A dev tool that no SDK user imports, such as the mock server, is outside this rule. A language file lists the idiomatic additions that its language needs, such as options types, async variants and callback types. To add, rename or remove a concept, change the spec first. To add an idiomatic addition, change the language file first. +- **API-1 MUST.** Every public symbol maps to a concept in the Interface section of `spec/grammar.md`, `spec/client.md` or `spec/field.md`. A dev tool that no SDK user imports, such as the mock server, is outside this rule. A language file lists the idiomatic additions that its language needs, such as options types, async variants and callback types. To add, rename or remove a concept, change the spec first. To add an idiomatic addition, change the language file first. - **API-2 MUST.** Names and call shapes follow the cross-language map below. - **API-3 MUST.** Parse, don't validate. A function that reads a postcode returns a typed result, not a boolean. A boolean helper can exist only as a thin wrapper over `parse`. - **API-4 MUST.** Expected failures are values. `parse` returns its errors. Only I/O failures, API failures and programmer errors raise or throw. diff --git a/standards/languages/typescript.md b/standards/languages/typescript.md index 1296ae0..8a0e6e2 100644 --- a/standards/languages/typescript.md +++ b/standards/languages/typescript.md @@ -1,6 +1,6 @@ # TypeScript standards -Version 1.1, 3 Oct 2026. +Version 1.2, 3 Oct 2026. This file and `standards/CODING_STANDARDS.md` together are the standard for TypeScript code. They apply to every package and app in the `js` repo: core, client, field, React wrapper, docs site and demos. Later TypeScript work follows them too, such as the MCP server, the n8n node and the spreadsheet add-ins. @@ -25,9 +25,9 @@ This file and `standards/CODING_STANDARDS.md` together are the standard for Type | Vitest with V8 coverage | unit tests | coverage floors from T-7 | | fast-check | property tests | `parse`, `normalize` and the hierarchy functions | | Playwright with `@axe-core/playwright` | browser and accessibility tests | field and docs site | -| monocart-coverage-reports | merged coverage | merges Vitest and Playwright V8 coverage for the field, so T-7 counts browser tests | +| Vitest browser mode with `@vitest/browser-playwright` | component tests in Chromium | field and React wrapper. The V8 coverage of these tests counts for T-7 | | StrykerJS | mutation tests | `@gatepost/core`, weekly scheduled job | -| size-limit | size limits | core 4 KB, client 6 KB, field 20 KB, compressed, as size-limit measures them | +| size-limit | size limits | core 4 KB, client 6 KB, field 20 KB, React wrapper 1 KB without React and the field, compressed, as size-limit measures them | | publint and `@arethetypeswrong/cli` | package checks | every published package | | API Extractor | API report | `etc/.api.md`, checked in CI | | Changesets | versions and changelogs | one change file per user-visible change | @@ -106,7 +106,7 @@ Prettier's `printWidth: 100` matches TELL-1. Prettier does not split long string - **TS-22 MUST.** Write an options object type inline as `Readonly<{ name?: Type }>`, and document it with one `@param options` line. The literal `readonly` form fails `eslint-plugin-jsdoc`, and a dotted `@param` name fails the TSDoc parser that API Extractor uses. - **TS-10 MUST.** An error class extends `Error`, sets `name` and passes `cause`. - **TS-11 MUST.** A `catch` block treats its value as `unknown` and narrows it. **(tool)** -- **TS-12 MUST.** Packages do not call `console`. **(tool)** +- **TS-12 MUST.** Packages do not call `console`. The one exception is the console error of the field for a secret key, which `spec/field.md` asks for. **(tool)** - **TS-13 MUST.** Each `eslint-disable` comment gives a reason. **(tool)** - **TS-14 MUST.** Each package ships ESM with an `exports` map and type declarations. **(tool)** - **TS-15 MUST.** Browser code does not read `window`, `document` or `customElements` at import time. The field's guarded registration is the one exception. @@ -119,7 +119,17 @@ Prettier's `printWidth: 100` matches TELL-1. Prettier does not split long string ### Idiomatic additions (API-1) -The TypeScript SDK follows the Interface section of `spec/grammar.md` exactly. It adds no idiomatic symbols. +The TypeScript packages follow the Interface sections of `spec/grammar.md`, `spec/client.md` and `spec/field.md`. They add these idiomatic symbols: + +- `@gatepost/client`: the options type `ClientOptions`, and a type for each result and each set of codes, such as `LookupResult` and `PostcodeErrorCode`. +- `@gatepost/field`: the element class `PostcodeFieldElement`, with the members that the platform needs: + - the static members `formAssociated` and `observedAttributes`. + - the lifecycle callbacks `connectedCallback()`, `disconnectedCallback()` and `attributeChangedCallback()`. + - the form callbacks `formResetCallback()`, `formDisabledCallback()` and `formStateRestoreCallback()`. + - the form-control members `form`, `validity`, `validationMessage`, `willValidate`, `checkValidity()` and `reportValidity()`. + - the properties `value` and `messages`, which are the settings of the same names. +- `@gatepost/field` also adds the detail types `ChangeDetail`, `ConfirmDetail` and `ErrorDetail`, and the unions `ChangeSource` and `FieldErrorCode`. It adds `Messages`, the type of a catalogue, and `MessageKey`, the type of its keys. +- `@gatepost/react`: the component `PostcodeField` and its props type `PostcodeFieldProps`. Each setting of the field is a prop in camelCase, except `value`, which is `defaultValue`. The events are `onChange`, `onConfirm` and `onError`. ## Names diff --git a/templates/AGENTS.md b/templates/AGENTS.md index c3a4aef..8ce9348 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -11,7 +11,7 @@ This repo is part of Gatepost, unofficial open-source developer tools for Nigeri ## Rules that no config file shows -- If the task needs a public symbol that the Interface section of `spec/grammar.md` does not list, add nothing, not even an internal helper. Stop, and say that a spec change must come first (API-1, CS-8). +- If the task needs a public symbol that no Interface section of the spec lists, in `spec/grammar.md`, `spec/client.md` or `spec/field.md`, add nothing, not even an internal helper. Stop, and say that a spec change must come first (API-1, CS-8). - Call only the documented NIPOST gateway endpoints. - Commit only NIPOST's published test codes and synthetic values, such as `FC-01-Z99-ZZ-01`. - Sign off each commit with `git commit -s`. The person who opens the pull request is the only author. Leave out co-author lines for AI tools.