patchground 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from stockimport import ImportFailed, ImportResult, Item, RowError, import_inventory
|
|
4
|
+
|
|
5
|
+
HEADER = "sku,quantity,unit_price"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def doc(*lines):
|
|
9
|
+
return "\n".join((HEADER, *lines)) + "\n"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_strict_returns_items_in_file_order():
|
|
13
|
+
items = import_inventory(doc("A-1,5,19.99", "B-1,2,0.29"))
|
|
14
|
+
assert items == [Item("A-1", 5, 1999), Item("B-1", 2, 29)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_strict_default_raises_the_first_error():
|
|
18
|
+
with pytest.raises(ImportFailed) as caught:
|
|
19
|
+
import_inventory(doc("A-1,5,19.99", "A-2,1,x", "A-3,y,1.00"))
|
|
20
|
+
assert (caught.value.error.line, caught.value.error.code) == (3, "invalid_price")
|
|
21
|
+
assert isinstance(caught.value, ValueError)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_result_types_have_the_documented_fields():
|
|
25
|
+
error = RowError(2, "sku", "invalid_sku", "bad")
|
|
26
|
+
result = ImportResult([Item("A", 1, 100)], [error])
|
|
27
|
+
assert result.items == [Item("A", 1, 100)]
|
|
28
|
+
assert (error.line, error.column, error.code) == (2, "sku", "invalid_sku")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_non_strict_returns_valid_items_and_row_errors():
|
|
32
|
+
result = import_inventory(doc("A-1,5,19.99", "A-2,x,1.00", "B-1,2,0.29"), strict=False)
|
|
33
|
+
assert result.items == [Item("A-1", 5, 1999), Item("B-1", 2, 29)]
|
|
34
|
+
assert [(e.line, e.column, e.code) for e in result.errors] == [(3, "quantity", "invalid_quantity")]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
- Which rules in the ticket did you decide were already satisfied by the strict code, and which did you have to change? How did you check?
|
|
2
|
+
- Where does an invalid row reserve its sku in the strict code, and why did that not matter before non-strict mode existed?
|
|
3
|
+
- How did you decide what non-strict mode does with a malformed CSV, and what did you leave unchanged?
|
|
4
|
+
- Which of your tests would fail if line numbers counted records instead of physical lines?
|
|
5
|
+
- If an AI assistant wrote part of the change, what did you verify yourself before accepting it?
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
diff --git a/src/stockimport/importer.py b/src/stockimport/importer.py
|
|
2
|
+
index 60e061c..3698620 100644
|
|
3
|
+
--- a/src/stockimport/importer.py
|
|
4
|
+
+++ b/src/stockimport/importer.py
|
|
5
|
+
@@ -6,8 +6,9 @@ import csv
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
+from typing import Literal, overload
|
|
10
|
+
|
|
11
|
+
-from .models import ImportFailed, Item, RowError
|
|
12
|
+
+from .models import ImportFailed, ImportResult, Item, RowError
|
|
13
|
+
|
|
14
|
+
HEADER = ["sku", "quantity", "unit_price"]
|
|
15
|
+
|
|
16
|
+
@@ -20,25 +21,40 @@ def _fail(line: int, column: str | None, code: str, message: str) -> ImportFaile
|
|
17
|
+
return ImportFailed(RowError(line, column, code, message))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
-def import_inventory(text: str) -> list[Item]:
|
|
21
|
+
+@overload
|
|
22
|
+
+def import_inventory(text: str) -> list[Item]: ...
|
|
23
|
+
+@overload
|
|
24
|
+
+def import_inventory(text: str, *, strict: Literal[True]) -> list[Item]: ...
|
|
25
|
+
+@overload
|
|
26
|
+
+def import_inventory(text: str, *, strict: Literal[False]) -> ImportResult: ...
|
|
27
|
+
+def import_inventory(text: str, *, strict: bool = True) -> list[Item] | ImportResult:
|
|
28
|
+
"""Parse ``text`` and return its items in file order.
|
|
29
|
+
|
|
30
|
+
- The first problem found stops the import with ``ImportFailed``.
|
|
31
|
+
+ Strict (default): the first row problem stops the import with ``ImportFailed``.
|
|
32
|
+
+ ``strict=False``: row problems are collected and returned with the accepted items.
|
|
33
|
+
+ Header and CSV syntax problems raise ``ImportFailed`` in both modes.
|
|
34
|
+
"""
|
|
35
|
+
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
|
|
36
|
+
_read_header(reader)
|
|
37
|
+
items: list[Item] = []
|
|
38
|
+
+ errors: list[RowError] = []
|
|
39
|
+
seen: set[str] = set()
|
|
40
|
+
while True:
|
|
41
|
+
line = reader.line_num + 1 # physical line where the next record starts
|
|
42
|
+
try:
|
|
43
|
+
record = next(reader)
|
|
44
|
+
except StopIteration:
|
|
45
|
+
- return items
|
|
46
|
+
+ return items if strict else ImportResult(items, errors)
|
|
47
|
+
except csv.Error as exc:
|
|
48
|
+
raise _fail(line, None, "malformed_csv", str(exc)) from None
|
|
49
|
+
- if record: # a physical line with no characters yields []
|
|
50
|
+
+ if not record: # a physical line with no characters yields []
|
|
51
|
+
+ continue
|
|
52
|
+
+ try:
|
|
53
|
+
items.append(_parse_record(record, line, seen))
|
|
54
|
+
+ except ImportFailed as exc:
|
|
55
|
+
+ if strict:
|
|
56
|
+
+ raise
|
|
57
|
+
+ errors.append(exc.error)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_header(reader: Iterator[list[str]]) -> None:
|
|
61
|
+
@@ -58,7 +74,6 @@ def _parse_record(record: list[str], line: int, seen: set[str]) -> Item:
|
|
62
|
+
raise _fail(line, "sku", "invalid_sku", f"invalid sku {sku!r}")
|
|
63
|
+
if sku in seen:
|
|
64
|
+
raise _fail(line, "sku", "duplicate_sku", f"duplicate sku {sku!r}")
|
|
65
|
+
- seen.add(sku)
|
|
66
|
+
if not _QUANTITY.fullmatch(quantity):
|
|
67
|
+
raise _fail(line, "quantity", "invalid_quantity", f"invalid quantity {quantity!r}")
|
|
68
|
+
match = _PRICE.fullmatch(price)
|
|
69
|
+
@@ -66,4 +81,5 @@ def _parse_record(record: list[str], line: int, seen: set[str]) -> Item:
|
|
70
|
+
raise _fail(line, "unit_price", "invalid_price", f"invalid unit_price {price!r}")
|
|
71
|
+
dollars, fraction = match.groups()
|
|
72
|
+
cents = int(dollars) * 100 + int((fraction or "").ljust(2, "0"))
|
|
73
|
+
+ seen.add(sku) # only fully valid rows reserve their sku
|
|
74
|
+
return Item(sku, int(quantity), cents)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Strict CSV import for the warehouse stock feed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
|
|
10
|
+
from .models import ImportFailed, Item, RowError
|
|
11
|
+
|
|
12
|
+
HEADER = ["sku", "quantity", "unit_price"]
|
|
13
|
+
|
|
14
|
+
_SKU = re.compile(r"[A-Z0-9-]+")
|
|
15
|
+
_QUANTITY = re.compile(r"[0-9]+")
|
|
16
|
+
_PRICE = re.compile(r"([0-9]+)(?:\.([0-9]{1,2}))?")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fail(line: int, column: str | None, code: str, message: str) -> ImportFailed:
|
|
20
|
+
return ImportFailed(RowError(line, column, code, message))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def import_inventory(text: str) -> list[Item]:
|
|
24
|
+
"""Parse ``text`` and return its items in file order.
|
|
25
|
+
|
|
26
|
+
The first problem found stops the import with ``ImportFailed``.
|
|
27
|
+
"""
|
|
28
|
+
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
|
|
29
|
+
_read_header(reader)
|
|
30
|
+
items: list[Item] = []
|
|
31
|
+
seen: set[str] = set()
|
|
32
|
+
while True:
|
|
33
|
+
line = reader.line_num + 1 # physical line where the next record starts
|
|
34
|
+
try:
|
|
35
|
+
record = next(reader)
|
|
36
|
+
except StopIteration:
|
|
37
|
+
return items
|
|
38
|
+
except csv.Error as exc:
|
|
39
|
+
raise _fail(line, None, "malformed_csv", str(exc)) from None
|
|
40
|
+
if record: # a physical line with no characters yields []
|
|
41
|
+
items.append(_parse_record(record, line, seen))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_header(reader: Iterator[list[str]]) -> None:
|
|
45
|
+
try:
|
|
46
|
+
header = next(reader)
|
|
47
|
+
except (StopIteration, csv.Error):
|
|
48
|
+
header = None
|
|
49
|
+
if header != HEADER:
|
|
50
|
+
raise _fail(1, None, "bad_header", "header must be exactly sku,quantity,unit_price")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_record(record: list[str], line: int, seen: set[str]) -> Item:
|
|
54
|
+
if len(record) != 3:
|
|
55
|
+
raise _fail(line, None, "wrong_field_count", f"expected 3 fields, got {len(record)}")
|
|
56
|
+
sku, quantity, price = record
|
|
57
|
+
if not _SKU.fullmatch(sku):
|
|
58
|
+
raise _fail(line, "sku", "invalid_sku", f"invalid sku {sku!r}")
|
|
59
|
+
if sku in seen:
|
|
60
|
+
raise _fail(line, "sku", "duplicate_sku", f"duplicate sku {sku!r}")
|
|
61
|
+
seen.add(sku)
|
|
62
|
+
if not _QUANTITY.fullmatch(quantity):
|
|
63
|
+
raise _fail(line, "quantity", "invalid_quantity", f"invalid quantity {quantity!r}")
|
|
64
|
+
match = _PRICE.fullmatch(price)
|
|
65
|
+
if match is None:
|
|
66
|
+
raise _fail(line, "unit_price", "invalid_price", f"invalid unit_price {price!r}")
|
|
67
|
+
dollars, fraction = match.groups()
|
|
68
|
+
cents = int(dollars) * 100 + int((fraction or "").ljust(2, "0"))
|
|
69
|
+
return Item(sku, int(quantity), cents)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Item:
|
|
8
|
+
sku: str
|
|
9
|
+
quantity: int
|
|
10
|
+
unit_price_cents: int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RowError:
|
|
15
|
+
line: int
|
|
16
|
+
column: str | None
|
|
17
|
+
code: str
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ImportResult:
|
|
23
|
+
items: list[Item]
|
|
24
|
+
errors: list[RowError]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ImportFailed(ValueError):
|
|
28
|
+
"""Raised when an import stops; ``error`` says why."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, error: RowError) -> None:
|
|
31
|
+
super().__init__(f"line {error.line}: {error.message}")
|
|
32
|
+
self.error = error
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Duplicate charges after a payment retry
|
|
2
|
+
|
|
3
|
+
Support reproduced a checkout charging twice when the provider accepted a payment
|
|
4
|
+
but its response timed out. The next transport attempt returned success, so the
|
|
5
|
+
customer saw one successful checkout and two ledger entries.
|
|
6
|
+
|
|
7
|
+
Fix the payment flow and add a regression check. The public entry point is
|
|
8
|
+
`PaymentService(gateway, max_attempts=3).pay(request_id, amount_cents)` in
|
|
9
|
+
`src/payments/service.py`. Use the offline gateway in `src/payments/gateway.py`;
|
|
10
|
+
its scripted events reproduce outages without network access or sleeps.
|
|
11
|
+
|
|
12
|
+
Required behavior:
|
|
13
|
+
- One logical request must create at most one charge, including after a lost
|
|
14
|
+
success response and when submitted again through a new service instance
|
|
15
|
+
sharing the same gateway. Successful repeats return the same receipt.
|
|
16
|
+
- Different request IDs represent different payments even for equal amounts.
|
|
17
|
+
Reusing an ID with a different amount raises `RequestConflict`.
|
|
18
|
+
- Retry only `GatewayTimeout`, at most `max_attempts` total calls per invocation.
|
|
19
|
+
If all attempts time out, expose the final timeout. Propagate `PaymentDeclined`
|
|
20
|
+
and invalid input immediately. Amounts are positive integer cents.
|
|
21
|
+
- Preserve the `Receipt(charge_id, amount_cents)` API and gateway semantics.
|
|
22
|
+
|
|
23
|
+
The simulator persists accepted requests only for the lifetime of its gateway
|
|
24
|
+
instance. Cross-process persistence, concurrent workers, real providers, backoff,
|
|
25
|
+
credentials and payment security are outside this exercise. Work in application
|
|
26
|
+
code and conventional pytest checks; keep verification configuration intact.
|
|
27
|
+
|
|
28
|
+
Run checks using Patchground's container commands. No real payments are made.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
spec_version: "0.1"
|
|
2
|
+
id: duplicate-charges-on-retry
|
|
3
|
+
version: 1.0.1
|
|
4
|
+
title: Duplicate charges after a payment retry
|
|
5
|
+
type: debug
|
|
6
|
+
summary: A successful charge followed by a timeout causes a retry to charge twice.
|
|
7
|
+
languages: [python]
|
|
8
|
+
tags: [idempotency, retries, payments]
|
|
9
|
+
difficulty: intermediate
|
|
10
|
+
timebox_minutes: 45
|
|
11
|
+
ai_policy: allowed
|
|
12
|
+
environment:
|
|
13
|
+
dockerfile: environment/Dockerfile
|
|
14
|
+
services: []
|
|
15
|
+
test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
|
|
16
|
+
checks:
|
|
17
|
+
visible: checks/visible
|
|
18
|
+
hidden: checks/hidden
|
|
19
|
+
solution: solution/reference.patch
|
|
20
|
+
expected_paths: [src/payments/, tests/]
|
|
21
|
+
hints:
|
|
22
|
+
- A timeout says nothing about whether the provider committed the charge.
|
|
23
|
+
- Compare the identity of a logical payment with the identity of a transport attempt.
|
|
24
|
+
debrief:
|
|
25
|
+
questions: debrief/questions.md
|
|
26
|
+
focus: [debugging_method, test_quality, scope_judgment]
|
|
27
|
+
author: {name: Patchground contributors}
|
|
28
|
+
content_license: CC-BY-4.0
|
|
29
|
+
provenance: generated
|
|
30
|
+
generated_by: {model: "Codex configured default (model unverified)", prompt_version: launch-payment-v1}
|
patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from payments.gateway import OfflineGateway
|
|
3
|
+
from payments.models import GatewayTimeout, RequestConflict
|
|
4
|
+
from payments.service import PaymentService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_ambiguous_acceptance_has_one_ledger_entry():
|
|
8
|
+
gateway = OfflineGateway(("timeout_before", "timeout_after", "ok"))
|
|
9
|
+
receipt = PaymentService(gateway).pay("invoice-71", 8317)
|
|
10
|
+
assert len(gateway.charges) == 1
|
|
11
|
+
assert list(gateway.charges.values()) == [receipt]
|
|
12
|
+
assert len(gateway.calls) == 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_replay_from_another_service_returns_original_receipt():
|
|
16
|
+
gateway = OfflineGateway(("timeout_after", "ok"))
|
|
17
|
+
first = PaymentService(gateway).pay("invoice-72", 1800)
|
|
18
|
+
second = PaymentService(gateway).pay("invoice-72", 1800)
|
|
19
|
+
assert first == second
|
|
20
|
+
assert len(gateway.charges) == 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_equal_amounts_with_different_ids_are_distinct():
|
|
24
|
+
gateway = OfflineGateway()
|
|
25
|
+
service = PaymentService(gateway)
|
|
26
|
+
first = service.pay("invoice-73", 999)
|
|
27
|
+
second = service.pay("invoice-74", 999)
|
|
28
|
+
assert first.charge_id != second.charge_id
|
|
29
|
+
assert len(gateway.charges) == 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_changed_amount_after_ambiguous_acceptance_is_rejected():
|
|
33
|
+
gateway = OfflineGateway(("timeout_after", "ok"))
|
|
34
|
+
service = PaymentService(gateway)
|
|
35
|
+
service.pay("invoice-75", 4100)
|
|
36
|
+
before = len(gateway.calls)
|
|
37
|
+
with pytest.raises(RequestConflict):
|
|
38
|
+
service.pay("invoice-75", 4200)
|
|
39
|
+
assert len(gateway.calls) == before + 1
|
|
40
|
+
assert len(gateway.charges) == 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_later_invocation_recovers_accepted_charge_after_exhaustion():
|
|
44
|
+
gateway = OfflineGateway(("timeout_after",))
|
|
45
|
+
with pytest.raises(GatewayTimeout):
|
|
46
|
+
PaymentService(gateway, max_attempts=1).pay("invoice-76", 300)
|
|
47
|
+
receipt = PaymentService(gateway).pay("invoice-76", 300)
|
|
48
|
+
assert list(gateway.charges.values()) == [receipt]
|
|
49
|
+
assert len(gateway.calls) == 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_before_acceptance_retry_recovers():
|
|
53
|
+
gateway = OfflineGateway(("timeout_before", "ok"))
|
|
54
|
+
receipt = PaymentService(gateway).pay("invoice-77", 700)
|
|
55
|
+
assert list(gateway.charges.values()) == [receipt]
|
|
56
|
+
assert len(gateway.calls) == 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_final_timeout_is_exposed_without_extra_attempts():
|
|
60
|
+
gateway = OfflineGateway(("timeout_before",) * 5)
|
|
61
|
+
with pytest.raises(GatewayTimeout, match="before acceptance"):
|
|
62
|
+
PaymentService(gateway, max_attempts=3).pay("invoice-78", 2200)
|
|
63
|
+
assert len(gateway.calls) == 3
|
|
64
|
+
assert not gateway.charges
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("amount", [0, -1, True, 1.5, "100"])
|
|
68
|
+
def test_invalid_amount_does_not_create_charge(amount):
|
|
69
|
+
gateway = OfflineGateway()
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
PaymentService(gateway).pay("invoice-79", amount)
|
|
72
|
+
assert not gateway.charges
|
|
73
|
+
assert not gateway.calls
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("request_id", ["", " ", None])
|
|
77
|
+
def test_invalid_request_identity_does_not_reach_provider(request_id):
|
|
78
|
+
gateway = OfflineGateway()
|
|
79
|
+
with pytest.raises(ValueError):
|
|
80
|
+
PaymentService(gateway).pay(request_id, 100)
|
|
81
|
+
assert not gateway.calls
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("limit", [0, -2, True, 1.5])
|
|
85
|
+
def test_invalid_retry_limit_is_rejected(limit):
|
|
86
|
+
with pytest.raises(ValueError):
|
|
87
|
+
PaymentService(OfflineGateway(), max_attempts=limit)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from payments.gateway import OfflineGateway
|
|
3
|
+
from payments.models import GatewayTimeout, PaymentDeclined
|
|
4
|
+
from payments.service import PaymentService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_successful_checkout():
|
|
8
|
+
gateway = OfflineGateway()
|
|
9
|
+
receipt = PaymentService(gateway).pay("order-a", 1250)
|
|
10
|
+
assert receipt.amount_cents == 1250
|
|
11
|
+
assert len(gateway.charges) == 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_lost_success_response_does_not_charge_twice():
|
|
15
|
+
gateway = OfflineGateway(("timeout_after", "ok"))
|
|
16
|
+
receipt = PaymentService(gateway).pay("order-b", 2500)
|
|
17
|
+
assert receipt.amount_cents == 2500
|
|
18
|
+
assert len(gateway.charges) == 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_decline_is_terminal():
|
|
22
|
+
gateway = OfflineGateway(("declined", "ok"))
|
|
23
|
+
with pytest.raises(PaymentDeclined):
|
|
24
|
+
PaymentService(gateway).pay("order-c", 500)
|
|
25
|
+
assert len(gateway.calls) == 1
|
|
26
|
+
assert not gateway.charges
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_transport_retries_are_bounded():
|
|
30
|
+
gateway = OfflineGateway(("timeout_before",) * 4)
|
|
31
|
+
with pytest.raises(GatewayTimeout):
|
|
32
|
+
PaymentService(gateway, max_attempts=2).pay("order-d", 500)
|
|
33
|
+
assert len(gateway.calls) == 2
|
|
34
|
+
assert not gateway.charges
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Debrief
|
|
2
|
+
|
|
3
|
+
- Which observation distinguishes a transport failure from a rejected payment?
|
|
4
|
+
- What lifetime should a logical request identity have?
|
|
5
|
+
- Which regression check catches a duplicate even if checkout returns success?
|
|
6
|
+
- What would a real provider need to guarantee for concurrent calls and restarts?
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
--- a/src/payments/service.py
|
|
2
|
+
+++ b/src/payments/service.py
|
|
3
|
+
@@ -14,7 +14,7 @@
|
|
4
|
+
if not isinstance(request_id, str) or not request_id.strip():
|
|
5
|
+
raise ValueError("request_id must be nonempty")
|
|
6
|
+
for attempt in range(self.max_attempts):
|
|
7
|
+
- provider_key = f"{request_id}:attempt-{attempt}"
|
|
8
|
+
+ provider_key = request_id
|
|
9
|
+
try:
|
|
10
|
+
return self.gateway.charge(provider_key, amount_cents)
|
|
11
|
+
except GatewayTimeout:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Offline checkout payment example."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
|
|
3
|
+
from payments.models import GatewayTimeout, PaymentDeclined, Receipt, RequestConflict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OfflineGateway:
|
|
7
|
+
"""Deterministic provider simulator; state lasts for this instance only.
|
|
8
|
+
|
|
9
|
+
Events: ok, timeout_before, timeout_after, declined. An already committed
|
|
10
|
+
key returns its receipt before consuming any event. No network or credentials.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, events: tuple[str, ...] = ()) -> None:
|
|
14
|
+
self._events = deque(events)
|
|
15
|
+
self.charges: dict[str, Receipt] = {}
|
|
16
|
+
self.calls: list[tuple[str, int]] = []
|
|
17
|
+
|
|
18
|
+
def charge(self, key: str, amount_cents: int) -> Receipt:
|
|
19
|
+
if not key or type(amount_cents) is not int or amount_cents <= 0:
|
|
20
|
+
raise ValueError("key and positive integer amount_cents required")
|
|
21
|
+
self.calls.append((key, amount_cents))
|
|
22
|
+
if key in self.charges:
|
|
23
|
+
receipt = self.charges[key]
|
|
24
|
+
if receipt.amount_cents != amount_cents:
|
|
25
|
+
raise RequestConflict("request already used for another amount")
|
|
26
|
+
return receipt
|
|
27
|
+
event = self._events.popleft() if self._events else "ok"
|
|
28
|
+
if event == "timeout_before":
|
|
29
|
+
raise GatewayTimeout("connection lost before acceptance")
|
|
30
|
+
if event == "declined":
|
|
31
|
+
raise PaymentDeclined("payment declined")
|
|
32
|
+
if event not in {"ok", "timeout_after"}:
|
|
33
|
+
raise ValueError("unknown simulator event")
|
|
34
|
+
receipt = Receipt(f"charge-{len(self.charges) + 1:04d}", amount_cents)
|
|
35
|
+
self.charges[key] = receipt
|
|
36
|
+
if event == "timeout_after":
|
|
37
|
+
raise GatewayTimeout("response lost after acceptance")
|
|
38
|
+
return receipt
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GatewayTimeout(TimeoutError):
|
|
5
|
+
"""Transport outcome is unknown; retry is allowed."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PaymentDeclined(Exception):
|
|
9
|
+
"""Terminal rejection; do not retry."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestConflict(Exception):
|
|
13
|
+
"""A request identity was reused with different payment details."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Receipt:
|
|
18
|
+
charge_id: str
|
|
19
|
+
amount_cents: int
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from payments.gateway import OfflineGateway
|
|
2
|
+
from payments.models import GatewayTimeout, Receipt
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PaymentService:
|
|
6
|
+
def __init__(self, gateway: OfflineGateway, max_attempts: int = 3) -> None:
|
|
7
|
+
if type(max_attempts) is not int or max_attempts < 1:
|
|
8
|
+
raise ValueError("max_attempts must be a positive integer")
|
|
9
|
+
self.gateway = gateway
|
|
10
|
+
self.max_attempts = max_attempts
|
|
11
|
+
|
|
12
|
+
def pay(self, request_id: str, amount_cents: int) -> Receipt:
|
|
13
|
+
"""Return a receipt, retrying only ambiguous transport failures."""
|
|
14
|
+
if not isinstance(request_id, str) or not request_id.strip():
|
|
15
|
+
raise ValueError("request_id must be nonempty")
|
|
16
|
+
for attempt in range(self.max_attempts):
|
|
17
|
+
provider_key = f"{request_id}:attempt-{attempt}"
|
|
18
|
+
try:
|
|
19
|
+
return self.gateway.charge(provider_key, amount_cents)
|
|
20
|
+
except GatewayTimeout:
|
|
21
|
+
if attempt + 1 == self.max_attempts:
|
|
22
|
+
raise
|
|
23
|
+
raise AssertionError("unreachable")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Let subscribers unsubscribe and listen once on the event bus
|
|
2
|
+
|
|
3
|
+
**Reported by:** CLI tooling team
|
|
4
|
+
**Component:** `localbus` (`src/bus.ts`)
|
|
5
|
+
|
|
6
|
+
## Background
|
|
7
|
+
|
|
8
|
+
`EventBus` is a small synchronous in-process bus. Today `on(topic, handler)` registers a handler and
|
|
9
|
+
`emit(topic, payload)` calls the registered handlers in order and returns how many it called. There is
|
|
10
|
+
no way to take a subscription back, and no way to listen for a single event. Two stubs exist so callers
|
|
11
|
+
can already import the final API: `on` returns an unsubscribe function that does nothing, and `once`
|
|
12
|
+
throws "not implemented".
|
|
13
|
+
|
|
14
|
+
## Request
|
|
15
|
+
|
|
16
|
+
Make `on` return a working unsubscribe function, implement `once`, and make `emit` safe when handlers
|
|
17
|
+
subscribe, unsubscribe, re-enter or throw. All other behavior must stay as it is.
|
|
18
|
+
|
|
19
|
+
### Public API
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
type Handler = (payload: unknown) => void;
|
|
23
|
+
type Unsubscribe = () => void;
|
|
24
|
+
|
|
25
|
+
class EventBus {
|
|
26
|
+
on(topic: string, handler: Handler): Unsubscribe;
|
|
27
|
+
once(topic: string, handler: Handler): Unsubscribe;
|
|
28
|
+
emit(topic: string, payload?: unknown): number;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The checks run the code at runtime with Vitest. They do not run the TypeScript compiler, so they make
|
|
33
|
+
no claim about type correctness.
|
|
34
|
+
|
|
35
|
+
### Scope
|
|
36
|
+
|
|
37
|
+
Everything is synchronous. There are no wildcards or patterns, no priorities and no compatibility
|
|
38
|
+
with Node's `EventEmitter`. A handler's return value is ignored: if it returns a promise, the bus does
|
|
39
|
+
not wait for it and does not look at it.
|
|
40
|
+
|
|
41
|
+
### Clauses
|
|
42
|
+
|
|
43
|
+
**C1 Topics.** A topic is any string, compared exactly and case-sensitively; the empty string is a
|
|
44
|
+
valid topic. `*`, `.` and similar characters have no special meaning, so `emit("user.created")` never
|
|
45
|
+
reaches a handler registered for `"user.*"` or `"*"`. Separate `EventBus` instances share nothing.
|
|
46
|
+
|
|
47
|
+
**C2 Order and duplicates.** Handlers for a topic run in registration order, whether they were
|
|
48
|
+
registered with `on` or `once`. Registering the same function again, with `on` or `once`, creates an
|
|
49
|
+
independent subscription; it runs once per subscription per emit and has its own unsubscribe function.
|
|
50
|
+
|
|
51
|
+
**C3 Payload.** Each handler is called with exactly one argument: the `payload` given to `emit`, the
|
|
52
|
+
same value and reference (no copy). If `emit` is called without a payload, handlers receive
|
|
53
|
+
`undefined`. Falsy payloads (`0`, `""`, `false`, `null`) are delivered as they are.
|
|
54
|
+
|
|
55
|
+
**C4 Return value.** `emit` returns the number of handler invocations it made: `0` if the topic has no
|
|
56
|
+
subscribers. A `once` subscription that was skipped (C7) is not counted. If any handler throws, `emit`
|
|
57
|
+
throws (C8) and returns nothing.
|
|
58
|
+
|
|
59
|
+
**C5 Unsubscribe.** `on` and `once` return an unsubscribe function for that one subscription. Calling
|
|
60
|
+
it removes that subscription from later emits. Calling it again does nothing, and never removes
|
|
61
|
+
another subscription, even one that uses the same handler function. Unsubscribing a `once`
|
|
62
|
+
subscription that has already run does nothing. Order of the remaining subscriptions is unchanged.
|
|
63
|
+
|
|
64
|
+
**C6 Snapshot.** When `emit` starts it captures the subscriptions of that topic, in registration
|
|
65
|
+
order, and calls exactly those. Subscriptions added during the emit run from the next emit on.
|
|
66
|
+
Unsubscribing during the emit takes effect for later emits only: a captured subscription still gets its
|
|
67
|
+
turn in the emit that captured it, whether it was registered with `on` or `once`. A nested `emit`
|
|
68
|
+
(called from a handler) captures its own snapshot from the subscriptions that exist at that moment.
|
|
69
|
+
|
|
70
|
+
**C7 Once.** A `once` subscription runs at most once. It is consumed immediately before its handler
|
|
71
|
+
is called, so it stays consumed if the handler throws, and a handler that emits its own topic is not
|
|
72
|
+
called again. A captured `once` subscription whose turn comes after it was already consumed, for
|
|
73
|
+
example by a nested `emit`, is skipped and not counted. Being unsubscribed does not consume it (C6).
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const bus = new EventBus();
|
|
79
|
+
const log: string[] = [];
|
|
80
|
+
let nested = false;
|
|
81
|
+
bus.on("tick", () => {
|
|
82
|
+
log.push("first");
|
|
83
|
+
if (!nested) {
|
|
84
|
+
nested = true;
|
|
85
|
+
bus.emit("tick"); // returns 3: first, once, last
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
bus.once("tick", () => log.push("once"));
|
|
89
|
+
bus.on("tick", () => log.push("last"));
|
|
90
|
+
bus.emit("tick"); // returns 2: first, last (the once handler was consumed by the nested emit)
|
|
91
|
+
// log: first, first, once, last, last
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**C8 Errors.** A handler that throws does not stop later handlers of the same snapshot. After the last
|
|
95
|
+
captured subscription's turn, if any handler threw, `emit` throws an `AggregateError` whose `errors`
|
|
96
|
+
lists the thrown values in invocation order, unchanged (not wrapped, and not only `Error` objects).
|
|
97
|
+
One failure still produces an `AggregateError` with one entry. If a handler's own nested `emit` throws
|
|
98
|
+
an `AggregateError`, that `AggregateError` is a single entry. The message text is not specified. The
|
|
99
|
+
bus stays fully usable: consumed `once` subscriptions stay consumed, other subscriptions stay
|
|
100
|
+
registered, and changes made by handlers before or after a failure still apply.
|
|
101
|
+
|
|
102
|
+
## Notes
|
|
103
|
+
|
|
104
|
+
- Keep `src/bus.ts` as the module that exports `EventBus`, `Handler` and `Unsubscribe`.
|
|
105
|
+
- Run `pgnd check` from the session workspace to check your changes in Docker.
|
|
106
|
+
- Add tests for the behavior you implement and keep the existing behavior unchanged.
|
|
107
|
+
- You may use your own editor and AI tools. The goal is to build and explain a precise change, not
|
|
108
|
+
only to make the listed cases pass.
|