From 6f78021b25598854694354d99f62ef8de7ce1777 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:08:57 +0100 Subject: [PATCH 1/5] feat: define the postcode field and its message catalogue The web field, and each UI field after it, needs one written contract for its settings, events, states and text, so that every framework behaves the same. The text lives here as a data file in the ARB format, which Flutter reads as it is, so one translation serves each field. A lookup never blocks the form, because the gateway's data is new and a wrong answer must not stop an order. The standards and the agents template now name the new interface file. Signed-off-by: MobileMage --- CONTEXT.md | 8 ++ README.md | 2 + REUSE.toml | 4 +- VERSION | 2 +- field.md | 138 ++++++++++++++++++++++++++++ grammar.md | 2 +- messages/field_en.arb | 143 ++++++++++++++++++++++++++++++ scripts/tests/test_field_doc.py | 143 ++++++++++++++++++++++++++++++ standards/CODING_STANDARDS.md | 6 +- standards/languages/typescript.md | 14 +-- templates/AGENTS.md | 2 +- 11 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 field.md create mode 100644 messages/field_en.arb create mode 100644 scripts/tests/test_field_doc.py diff --git a/CONTEXT.md b/CONTEXT.md index fcc8182..804a825 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, a legacy postcode, or the text as the user typed it. +_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 8e1a4fa..1102f53 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ The shared contract for every Gatepost SDK: the postcode grammar, the data files |---|---| | `grammar.md` | how every SDK reads and writes postcodes, and the public interface | | `client.md` | how every client calls NIPOST's gateway: results, errors, retries and the queue | +| `field.md` | how every postcode field behaves: settings, events, states, accessibility and privacy | +| `messages/` | the text of the field, one message catalogue for each language | | `openapi/` | NIPOST's gateway, completed with the responses that its own file leaves out | | `fixtures/` | synthetic gateway responses and keys for the mock server | | `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..6e69ee4 --- /dev/null +++ b/field.md @@ -0,0 +1,138 @@ +# 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 | +| `label` | setting | the visible label. The default is the message `label` | +| `apiKey` | setting | a publishable 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 | +| `confirm` | event | the gateway knows the postcode | +| `error` | event | a request failed, the location failed, or the key is a secret key | +| `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` | + +### 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. +- 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`. +- 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. + +## Text + +- The field reads its text with `parse`. Partial postcodes do not pass. +- The field shows a parse error after the user leaves the input, after a form submit, or when the normalised text has 11 characters or more. A user who is still typing a short text sees no error. +- When the user leaves the input, a text that parses shows in the display form. +- 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. +- 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 looks up each text that parses as a whole postcode, at that level. It sends no lookup for a legacy postcode. +- A new text cancels the lookup of the previous text. +- The field refuses a secret key, which starts with `nipost_test_` or `nipost_live_`, and sends no request. It logs one console error for the developer when it reads the key. In place of each lookup, it shows the state `error` and raises the event `error` with the code `secret_key`, so an app that listens later still hears of it. +- At level 2, the confirmation names the locality, the LGA and the state from the administrative address. Otherwise, it names the state with `stateName`. It never shows `recentHouseAddress`, because a user can type any postcode, and that address can belong to someone else. The event `confirm` gives the app the whole lookup result. + +## Location + +- With `gps` set and a key, 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 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. +- `precisionForAccuracy` gives the most precise segment that the location supports. The field fills in the unit's postcode, truncated to that precision. When the result has no unit, the field uses the area, then the district, if that text parses as a partial postcode. +- A partial postcode stays as text for the user to complete. The field then shows the state `GPS coarse`. +- The user can always type. Typing cancels a location request in progress. + +## States + +| State | When | Messages | +|---|---|---| +| idle | the text is empty, and the field shows no error | none | +| typing | the text does not parse, and the field shows no error yet | none | +| invalid format | the field shows a parse error | the message of the parse error, and `suggestion` | +| legacy code | the text is a legacy postcode | `legacy_accepted` or `legacy_rejected` | +| checking | a lookup is in progress | `checking` | +| valid | the text parses, and no lookup runs | `valid` | +| not found | the gateway does not know the postcode | `not_found` | +| confirmed | the gateway knows the postcode | `confirmed` or `confirmed_place` | +| error | a lookup failed, or the key is a secret key | `check_failed` or `secret_key` | +| GPS locating | the field waits for the location or for `reverse` | `locating` | +| GPS coarse | the location supports only a partial postcode | `gps_coarse` | +| GPS denied | the field has no location, or no postcode at the location | `gps_denied`, `gps_unavailable` or `gps_not_found` | + +## 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 | Shown | +|---|---| +| `label` | as the label, when the page gives no `label` | +| `hint` | under the label, at all times | +| `empty` | for the parse error `empty`, when `required` is set | +| `bad_character` | for the parse error `bad_character` | +| `bad_length` | for the parse error `bad_length`, with the count of normalised characters | +| `unknown_state` | for the parse error `unknown_state` | +| `bad_lga` | for the parse error `bad_segment` in the LGA | +| `bad_area` | for the parse error `bad_segment` in the area | +| `bad_unit` | for the parse error `bad_segment` in the unit | +| `suggestion` | after a parse error that has a suggestion, with the suggestion in the display form | +| `use_suggestion` | as the text of the button that uses the suggestion | +| `legacy_accepted` | for a legacy postcode, when `legacy` is `accept` | +| `legacy_rejected` | for a legacy postcode, when `legacy` is `reject` | +| `valid` | in the state `valid`, with the state name | +| `checking` | in the state `checking` | +| `not_found` | in the state `not found` | +| `confirmed` | in the state `confirmed`, with the state name | +| `confirmed_place` | in the state `confirmed`, when the lookup gives the locality, the LGA and the state names | +| `check_failed` | in the state `error`, after a failed lookup | +| `secret_key` | in the state `error`, for a secret key | +| `use_location` | as the text of the location button | +| `locating` | in the state `GPS locating` | +| `gps_coarse` | in the state `GPS coarse`, with the accuracy in whole metres | +| `gps_not_found` | in the state `GPS denied`, when `reverse` found no postcode | +| `gps_denied` | in the state `GPS denied`, when the user refused the location | +| `gps_unavailable` | in the state `GPS denied`, when 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 while it shows an error. +- 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. 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..0c10e9d --- /dev/null +++ b/messages/field_en.arb @@ -0,0 +1,143 @@ +{ + "@@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 postcode, 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 your new postcode, for example 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": "Your location is accurate to about {metres} metres, so we filled in the first part only. Type the rest.", + "@gps_coarse": { + "description": "The location is not precise enough for a whole postcode.", + "placeholders": { + "metres": { + "type": "int" + } + } + }, + "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 browser refused the location." + }, + "gps_unavailable": "We could not find your location. Type your postcode.", + "@gps_unavailable": { + "description": "The device gave no location in time, 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..2069b55 --- /dev/null +++ b/scripts/tests/test_field_doc.py @@ -0,0 +1,143 @@ +# 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") +CATALOGUE = ROOT / "messages" / "field_en.arb" +STANDARDS = ROOT / "standards" / "CODING_STANDARDS.md" +TYPESCRIPT = ROOT / "standards" / "languages" / "typescript.md" +PLACEHOLDER = re.compile(r"\{(\w+)\}") + + +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 first_cells(heading: str) -> list[str]: + """Return the first cell of each row of the table under a heading, without backticks.""" + rows = [line for line in section(heading).splitlines() if line.startswith("| ")] + return [row.split("|")[1].strip().strip("`") for row in rows[1:]] + + +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("@")} + + +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_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_state_names_only_keys_of_the_catalogue(self) -> None: + rows = [line for line in section("## States").splitlines() if line.startswith("| ")] + cells = [row.split("|")[3] for row in rows[2:]] + named = {key for cell in cells for key in re.findall(r"`([a-z_]+)`", 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_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(self) -> None: + self.assertEqual( + first_cells("## States"), + [ + "idle", + "typing", + "invalid format", + "legacy code", + "checking", + "valid", + "not found", + "confirmed", + "error", + "GPS locating", + "GPS coarse", + "GPS denied", + ], + ) + + 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_the_field_never_shows_the_house_address(self) -> None: + self.assertIn("It never shows `recentHouseAddress`", section("## Lookups")) + + +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] + for name in ("PostcodeFieldElement", "ChangeDetail", "PostcodeFieldProps", "Messages"): + self.assertIn(f"`{name}`", additions) + self.assertNotIn("monocart", text) + + 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` |", readme) + self.assertIn("| `messages/` |", readme) diff --git a/standards/CODING_STANDARDS.md b/standards/CODING_STANDARDS.md index dd933bd..b8636c0 100644 --- a/standards/CODING_STANDARDS.md +++ b/standards/CODING_STANDARDS.md @@ -1,6 +1,6 @@ # Gatepost coding standards -Version 1.1, 3 Oct 2026. +Version 1.2, 3 Oct 2026. These standards apply to every Gatepost repo, in every language. Each repo also follows its language file in `standards/languages/`. Together, the two files are the standard for that repo. @@ -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 65ee968..9374dc8 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 5 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,11 @@ 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`, and its form-control members `form`, `validity`, `validationMessage`, `willValidate`, `checkValidity()` and `reportValidity()`. The detail types `ChangeDetail`, `ConfirmDetail` and `ErrorDetail`, the unions `ChangeSource` and `FieldErrorCode`, and `Messages`, the type of a catalogue, with its keys in `MessageKey`. +- `@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. From 48074cf9562208d89d600d102f20588d439779a0 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:22:03 +0100 Subject: [PATCH 2/5] fix: make the field contract exact enough for a second field A review found that two fields built from field.md could differ. The states overlapped, the error event had no rule for a failed location, and the location fill did not say how far to truncate a fallback. So a coarse fix with no unit could fill a whole area. The file now orders the states, adds a table of failures, and spells out the fill, the input limit, an empty key and when a lookup starts again. The coarse location message counted the parts and gave the accuracy as the cause. A precise fix with no unit also gives part of a postcode, so the message now names no cause. The privacy section now forbids the address of a reverse unit too, because the nearest unit is often a neighbour's house. Two legacy messages are shorter, so each catalogue line fits in 100 characters. The client size budget stays at 6 KB, the limit that the client package already uses. Its build sits close to 5 KB, so the old budget left no room for the next feature. Signed-off-by: MobileMage --- CONTEXT.md | 2 +- field.md | 153 ++++++++++++++-------- messages/field_en.arb | 17 +-- scripts/tests/test_field_doc.py | 205 ++++++++++++++++++++++++++---- standards/languages/typescript.md | 8 +- 5 files changed, 295 insertions(+), 90 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 804a825..730c10f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -170,7 +170,7 @@ 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, a legacy postcode, or the text as the user typed it. +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**: diff --git a/field.md b/field.md index 6e69ee4..2f130ca 100644 --- a/field.md +++ b/field.md @@ -8,15 +8,15 @@ A field exposes the symbols in this table and the types that they need. A langua | Symbol | Kind | Meaning | |---|---|---| -| `name`, `value`, `required`, `disabled` | setting | the standard settings of a form control | +| `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. Without a key, the field sends no request | +| `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 | +| `change` | event | the form value changed through the user | | `confirm` | event | the gateway knows the postcode | | `error` | event | a request failed, the location failed, or the key is a secret key | | `SPEC_VERSION` | constant | the spec version, as text | @@ -27,13 +27,15 @@ A field exposes the symbols in this table and the types that they need. A langua |---|---| | `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` | +| `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. @@ -43,48 +45,89 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - 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 field shows a parse error after the user leaves the input, after a form submit, or when the normalised text has 11 characters or more. A user who is still typing a short text sees no error. -- When the user leaves the input, a text that parses shows in the display form. +- 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. +- The field shows a parse error when the count is 11 or more. So a text above the input limit shows its error at once. +- The field also shows a parse error after the user leaves the input, and after a validity check that fails, such as a form submit. From then on, it shows each error at once, until the form resets. +- 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 stays 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 looks up each text that parses as a whole postcode, at that level. It sends no lookup for a legacy postcode. -- A new text cancels the lookup of the previous text. -- The field refuses a secret key, which starts with `nipost_test_` or `nipost_live_`, and sends no request. It logs one console error for the developer when it reads the key. In place of each lookup, it shows the state `error` and raises the event `error` with the code `secret_key`, so an app that listens later still hears of it. -- At level 2, the confirmation names the locality, the LGA and the state from the administrative address. Otherwise, it names the state with `stateName`. It never shows `recentHouseAddress`, because a user can type any postcode, and that address can belong to someone else. The event `confirm` gives the app the whole lookup result. +- 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 the canonical postcode, the level and the key of its last lookup. When the text or a setting changes, and these three stay the same, the field keeps that lookup and its outcome. It sends no new request. +- When one of the three changes, the field cancels the lookup in progress and forgets its outcome. It then starts a new lookup if the text parses as a whole postcode. +- A press of the location button also cancels the lookup in progress, and the field forgets the last 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. +- 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, the field shows a button that uses the device's location. The field asks for the location only when the user presses the button. +- 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. -- `precisionForAccuracy` gives the most precise segment that the location supports. The field fills in the unit's postcode, truncated to that precision. When the result has no unit, the field uses the area, then the district, if that text parses as a partial postcode. -- A partial postcode stays as text for the user to complete. The field then shows the state `GPS coarse`. -- The user can always type. Typing cancels a location request in progress. +- 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` with the source `gps`, and `accuracyM` set to 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. + +## 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 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 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` gives no postcode that parses | GPS denied | `gps_not_found` | no event | ## States +The field shows the first state in this table whose condition holds. The first seven states come from a request. A change of the text ends the states of a location request. Lookups says when the states of a lookup end. + | State | When | Messages | |---|---|---| -| idle | the text is empty, and the field shows no error | none | -| typing | the text does not parse, and the field shows no error yet | none | -| invalid format | the field shows a parse error | the message of the parse error, and `suggestion` | -| legacy code | the text is a legacy postcode | `legacy_accepted` or `legacy_rejected` | +| GPS locating | a location request is in progress | `locating` | | checking | a lookup is in progress | `checking` | -| valid | the text parses, and no lookup runs | `valid` | -| not found | the gateway does not know the postcode | `not_found` | -| confirmed | the gateway knows the postcode | `confirmed` or `confirmed_place` | -| error | a lookup failed, or the key is a secret key | `check_failed` or `secret_key` | -| GPS locating | the field waits for the location or for `reverse` | `locating` | -| GPS coarse | the location supports only a partial postcode | `gps_coarse` | -| GPS denied | the field has no location, or no postcode at the location | `gps_denied`, `gps_unavailable` or `gps_not_found` | +| confirmed | the last lookup found the postcode | `confirmed` or `confirmed_place` | +| not found | the last lookup says that the gateway does not know the postcode | `not_found` | +| error | the last lookup failed, or it 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 field shows errors, and the text does not parse, or the text is empty and `required` is set | the message of the parse error and `suggestion`, or `empty` | +| typing | the text does not parse, and the field shows no error yet | none | +| idle | the text is empty | none | ## Messages @@ -92,34 +135,34 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso ### Message keys -| Key | Shown | -|---|---| -| `label` | as the label, when the page gives no `label` | -| `hint` | under the label, at all times | -| `empty` | for the parse error `empty`, when `required` is set | -| `bad_character` | for the parse error `bad_character` | -| `bad_length` | for the parse error `bad_length`, with the count of normalised characters | -| `unknown_state` | for the parse error `unknown_state` | -| `bad_lga` | for the parse error `bad_segment` in the LGA | -| `bad_area` | for the parse error `bad_segment` in the area | -| `bad_unit` | for the parse error `bad_segment` in the unit | -| `suggestion` | after a parse error that has a suggestion, with the suggestion in the display form | -| `use_suggestion` | as the text of the button that uses the suggestion | -| `legacy_accepted` | for a legacy postcode, when `legacy` is `accept` | -| `legacy_rejected` | for a legacy postcode, when `legacy` is `reject` | -| `valid` | in the state `valid`, with the state name | -| `checking` | in the state `checking` | -| `not_found` | in the state `not found` | -| `confirmed` | in the state `confirmed`, with the state name | -| `confirmed_place` | in the state `confirmed`, when the lookup gives the locality, the LGA and the state names | -| `check_failed` | in the state `error`, after a failed lookup | -| `secret_key` | in the state `error`, for a secret key | -| `use_location` | as the text of the location button | -| `locating` | in the state `GPS locating` | -| `gps_coarse` | in the state `GPS coarse`, with the accuracy in whole metres | -| `gps_not_found` | in the state `GPS denied`, when `reverse` found no postcode | -| `gps_denied` | in the state `GPS denied`, when the user refused the location | -| `gps_unavailable` | in the state `GPS denied`, when the device gave no location, or `reverse` failed | +| 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. @@ -128,7 +171,7 @@ The district segment accepts any letters and digits, so no parse error names it. - 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 while it shows an error. +- 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). @@ -136,3 +179,5 @@ The district segment accepts any letters and digits, so no parse error names it. - 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/messages/field_en.arb b/messages/field_en.arb index 0c10e9d..936acc7 100644 --- a/messages/field_en.arb +++ b/messages/field_en.arb @@ -54,11 +54,11 @@ "@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 postcode, enter it instead.", + "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 your new postcode, for example EK 01 A03 FK 01.", + "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." }, @@ -119,14 +119,9 @@ "@locating": { "description": "The field waits for the location, or for NIPOST's answer about it." }, - "gps_coarse": "Your location is accurate to about {metres} metres, so we filled in the first part only. Type the rest.", + "gps_coarse": "We added part of your postcode from your location. Type the rest.", "@gps_coarse": { - "description": "The location is not precise enough for a whole postcode.", - "placeholders": { - "metres": { - "type": "int" - } - } + "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": { @@ -134,10 +129,10 @@ }, "gps_denied": "We cannot use your location. Type your postcode.", "@gps_denied": { - "description": "The user or the browser refused the location." + "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 in time, or the request to NIPOST failed." + "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 index 2069b55..b62075c 100644 --- a/scripts/tests/test_field_doc.py +++ b/scripts/tests/test_field_doc.py @@ -10,10 +10,27 @@ 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: @@ -21,10 +38,15 @@ def section(heading: str) -> str: 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.""" - rows = [line for line in section(heading).splitlines() if line.startswith("| ")] - return [row.split("|")[1].strip().strip("`") for row in rows[1:]] + return [cells[0].strip("`") for cells in rows(heading)] def catalogue() -> dict[str, Any]: @@ -38,6 +60,30 @@ 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") @@ -47,6 +93,15 @@ def test_field_md_lists_each_key_of_the_catalogue_once(self) -> None: 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): @@ -58,10 +113,21 @@ def test_each_placeholder_in_a_text_is_declared_and_each_declared_one_is_used(se 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: - rows = [line for line in section("## States").splitlines() if line.startswith("| ")] - cells = [row.split("|")[3] for row in rows[2:]] - named = {key for cell in cells for key in re.findall(r"`([a-z_]+)`", cell)} + 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())) @@ -85,34 +151,106 @@ def test_the_interface_lists_the_settings_events_and_constant(self) -> None: ], ) + 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(self) -> None: + 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( - first_cells("## States"), + [(cells[1], cells[2], cells[3].partition(",")[0]) for cells in rows("## Failures")], [ - "idle", - "typing", - "invalid format", - "legacy code", - "checking", - "valid", - "not found", - "confirmed", - "error", - "GPS locating", - "GPS coarse", - "GPS denied", + ("not found", "`not_found`", "no event"), + ("error", "`check_failed`", client), + ("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`", client), + ("GPS denied", "`gps_not_found`", "no event"), ], ) + 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_the_field_never_shows_the_house_address(self) -> None: - self.assertIn("It never shows `recentHouseAddress`", section("## Lookups")) + 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_the_display_form_sends_no_new_lookup(self) -> None: + part = section("## Lookups") + self.assertIn("the field keeps that lookup and its outcome", 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 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("It raises `change` with the source `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): @@ -133,10 +271,31 @@ def test_the_agents_template_names_each_interface_section(self) -> None: 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] - for name in ("PostcodeFieldElement", "ChangeDetail", "PostcodeFieldProps", "Messages"): - self.assertIn(f"`{name}`", additions) + 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` |", readme) diff --git a/standards/languages/typescript.md b/standards/languages/typescript.md index 9374dc8..8a0e6e2 100644 --- a/standards/languages/typescript.md +++ b/standards/languages/typescript.md @@ -122,7 +122,13 @@ Prettier's `printWidth: 100` matches TELL-1. Prettier does not split long string 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`, and its form-control members `form`, `validity`, `validationMessage`, `willValidate`, `checkValidity()` and `reportValidity()`. The detail types `ChangeDetail`, `ConfirmDetail` and `ErrorDetail`, the unions `ChangeSource` and `FieldErrorCode`, and `Messages`, the type of a catalogue, with its keys in `MessageKey`. +- `@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 From 3b3af4eb9ded917d1f845e66197e0da0eba34560 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:31:26 +0100 Subject: [PATCH 3/5] fix: settle when the field remembers a lookup and ends a request A second review found rules that two fields could still read in two ways. The ordered states let an empty text match the row for typing, so the idle state could never show. An empty text is now never a parse error, and the file defines once when the field shows errors. The rule against a repeat lookup did not say what the field forgets. The field now remembers only a lookup that the gateway answered, so a failed or cancelled lookup can run again. A change of a setting no longer cancels a location request, and a change of the gateway address alone starts no lookup. A field that leaves the page cancels its requests and starts again from its text when it returns. A location fill raises a change event only when the form value changes, as typing does. The table of failures gains the case of a device that reports no location, and the error event now points to that table. Signed-off-by: MobileMage --- field.md | 43 ++++++++++++++++++++---------- scripts/tests/test_field_doc.py | 47 +++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/field.md b/field.md index 2f130ca..27d7710 100644 --- a/field.md +++ b/field.md @@ -18,7 +18,7 @@ A field exposes the symbols in this table and the types that they need. A langua | `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 request failed, the location failed, or the key is a secret key | +| `error` | event | a failure that the table in Failures gives an event code | | `SPEC_VERSION` | constant | the spec version, as text | ### Events @@ -55,11 +55,12 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - 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. -- The field shows a parse error when the count is 11 or more. So a text above the input limit shows its error at once. -- The field also shows a parse error after the user leaves the input, and after a validity check that fails, such as a form submit. From then on, it shows each error at once, until the form resets. +- 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 stays the same, so the field raises no `change` and sends no new lookup. +- 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. @@ -69,9 +70,14 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - 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 the canonical postcode, the level and the key of its last lookup. When the text or a setting changes, and these three stay the same, the field keeps that lookup and its outcome. It sends no new request. -- When one of the three changes, the field cancels the lookup in progress and forgets its outcome. It then starts a new lookup if the text parses as a whole postcode. -- A press of the location button also cancels the lookup in progress, and the field forgets the last lookup. +- 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. +- 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. - 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). @@ -89,11 +95,19 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - `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` with the source `gps`, and `accuracyM` set to the accuracy of the fix. +- 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. +- 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 @@ -106,28 +120,29 @@ Each failure gives one state and one message. The last column gives the code of | 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` gives no postcode that parses | GPS denied | `gps_not_found` | no event | ## States -The field shows the first state in this table whose condition holds. The first seven states come from a request. A change of the text ends the states of a location request. Lookups says when the states of a lookup end. +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 last lookup found the postcode | `confirmed` or `confirmed_place` | -| not found | the last lookup says that the gateway does not know the postcode | `not_found` | +| 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 it 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 field shows errors, and the text does not parse, or the text is empty and `required` is set | the message of the parse error and `suggestion`, or `empty` | -| typing | the text does not parse, and the field shows no error yet | none | -| idle | the text is empty | none | +| 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 diff --git a/scripts/tests/test_field_doc.py b/scripts/tests/test_field_doc.py index b62075c..37417c9 100644 --- a/scripts/tests/test_field_doc.py +++ b/scripts/tests/test_field_doc.py @@ -175,6 +175,7 @@ def test_each_failure_gives_a_state_a_message_and_an_event_code(self) -> None: ("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_not_found`", "no event"), ], @@ -206,14 +207,56 @@ def test_a_text_above_the_input_limit_is_never_normalised(self) -> None: 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 outcome", part) + 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.", 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 LocationTest(unittest.TestCase): part = section("## Location") @@ -224,7 +267,7 @@ def test_a_fallback_is_truncated_to_the_precision_of_the_fix(self) -> None: 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("It raises `change` with the source `gps`", 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( From 394544c61903b27559e73b34294f6d220390edc0 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:36:49 +0100 Subject: [PATCH 4/5] fix: say what a key change does to a location request A third review found two rules that two fields could still read in two ways. A change of the key during a location request could either stop the request or send the reverse call with a secret key. A change of a setting still never cancels the request, but a call that the field sends after the change now follows the rules for the new key. So the field never sends a secret key, and the table of failures gains that case. The file also says that a text that is not a whole postcode keeps no lookup, so the same postcode typed again gets a new one. New doc tests pin the rules that a later edit could drop without a failing test. Signed-off-by: MobileMage --- field.md | 11 ++++-- scripts/tests/test_field_doc.py | 59 ++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/field.md b/field.md index 27d7710..a33f8b4 100644 --- a/field.md +++ b/field.md @@ -74,12 +74,13 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - 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. +- 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`. @@ -100,7 +101,10 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - 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. +- 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. @@ -124,6 +128,7 @@ Each failure gives one state and one message. The last column gives the code of | 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` 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 @@ -135,7 +140,7 @@ The field shows the first state in this table whose condition holds. The first s | 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 it was due and the key is a secret key | `check_failed` or `secret_key` | +| 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` | diff --git a/scripts/tests/test_field_doc.py b/scripts/tests/test_field_doc.py index 37417c9..ac8c61b 100644 --- a/scripts/tests/test_field_doc.py +++ b/scripts/tests/test_field_doc.py @@ -178,6 +178,7 @@ def test_each_failure_gives_a_state_a_message_and_an_event_code(self) -> None: ("GPS denied", "`gps_unavailable`", "`gps_unavailable`"), ("GPS denied", "`gps_unavailable`", client), ("GPS denied", "`gps_not_found`", "no event"), + ("error", "`secret_key`", "`secret_key`"), ], ) @@ -244,7 +245,7 @@ def test_a_new_gateway_address_alone_sends_no_lookup(self) -> None: def test_a_setting_never_cancels_a_location_request(self) -> None: part = section("## Location") - self.assertIn("A change of a setting never cancels it.", part) + 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: @@ -257,6 +258,62 @@ def test_a_removed_field_starts_again_from_its_text(self) -> None: 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") From 02ee9a9644adb3461880bbd59e8902eb379e8021 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 15:46:20 +0100 Subject: [PATCH 5/5] fix: give an event code when a client call throws an unexpected error A lookup or a reverse call can throw an error that the client does not wrap, for example when the base address has no scheme. The failure table had no row for it, so a page would hear nothing. Both cases now end as a failed request and raise the error event with network_error. Signed-off-by: MobileMage --- field.md | 2 ++ scripts/tests/test_field_doc.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/field.md b/field.md index a33f8b4..0911213 100644 --- a/field.md +++ b/field.md @@ -121,12 +121,14 @@ Each failure gives one state and one message. The last column gives the code of |---|---|---|---| | 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` | diff --git a/scripts/tests/test_field_doc.py b/scripts/tests/test_field_doc.py index ac8c61b..f86f305 100644 --- a/scripts/tests/test_field_doc.py +++ b/scripts/tests/test_field_doc.py @@ -171,12 +171,14 @@ def test_each_failure_gives_a_state_a_message_and_an_event_code(self) -> None: [ ("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`"), ],