From de285b3951b0afbd8b25fd4b01981d845c4bb273 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Mon, 8 Jun 2026 22:51:36 +0930 Subject: [PATCH 01/23] docs: add Chip-on-Board packaging design spec Design for issue #259: editable boolean Project.chip_on_board, a snapshot field on ManufacturabilityCheck, --cob precheck wiring (the image already supports it), and a service-layer re-check on toggle (TriggerReason.COB_CHANGE). Co-Authored-By: Claude Opus 4.8 --- ...26-06-08-chip-on-board-packaging-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md new file mode 100644 index 00000000..f0f67e85 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -0,0 +1,134 @@ +# Chip-on-Board (CoB) Packaging Support — Design + +- **Date:** 2026-06-08 +- **Issue:** [#259](https://github.com/wafer-space/platform.wafer.space/issues/259) +- **Status:** Approved design, pending spec review + +## Summary + +Let users request **Chip-on-Board (CoB) packaging** for a project. When CoB is +requested, the manufacturability precheck runs with the precheck's CoB option so +the design is validated against the extra CoB compatibility checks. The result +flows through the existing manufacturability pipeline. + +## Background: the precheck interface (verified) + +The CoB rules live in the separate precheck image repo +`wafer-space/gf180mcu-precheck` (`precheck.py`), **not** in this repo. Verified +against that repo on 2026-06-08: + +- `precheck.py` already accepts **`--cob`** — an argparse `store_true` flag: + `parser.add_argument("--cob", action="store_true", help="Use the CoB + (Chip-On-Board) packaging option (extra checks).")` +- It threads a boolean config var **`WS_COB`** through the librelane flow, + sitting alongside `WS_ID` (→ platform `--id`) and `WS_SLOT` (→ platform + `--slot`). +- CoB enables **extra checks within the same flow**, so CoB failures appear as + ordinary manufacturability errors — no new result format to parse. + +> Note: issue #259 referred to a `--chip-on-board` flag; the real flag is +> `--cob`. This spec is authoritative. The issue should be updated to match. + +The platform already builds the precheck command in +`wafer_space/projects/tasks_checks.py` (`do_starting`, ~line 1070) as an argument +list including `--slot ` and `--id `. Adding `--cob` is exactly +parallel. + +## Design decisions (agreed) + +1. **Boolean, not a packaging enum.** The precheck models CoB as on/off + (`WS_COB: bool`). A multi-value `packaging` choice field would be premature + (YAGNI). Use a boolean `chip_on_board`. +2. **Editable with auto re-check.** `chip_on_board` is NOT a `CORE_FIELD`. A + user may toggle it; toggling invalidates the current manufacturability check + and re-runs it with/without `--cob`. +3. **CoB is orthogonal to slot/shuttle.** The precheck treats `WS_COB` + independently of `WS_SLOT`. No added slot-size or shuttle eligibility + constraints. + +## Components + +### 1. Data model (one migration) + +- `Project.chip_on_board: BooleanField(default=False)` — editable; excluded from + `CORE_FIELDS`. Mirrors `WS_COB`. +- `ManufacturabilityCheck.chip_on_board: BooleanField(default=False)` — a + **snapshot** of the value the check actually ran with. Parallels the existing + slot/DRC-version context recorded on checks; makes a finished result + unambiguous and lets us detect when a toggle has made the latest result stale. + +### 2. Precheck command wiring + +In `tasks_checks.py` `do_starting`, where the command list is built, append +`"--cob"` iff the check's `chip_on_board` is True. `store_true` → no value. The +snapshot is read from the `ManufacturabilityCheck` so the command matches what +the check records. + +### 3. Re-check on toggle (service layer) + +Queuing a task must not happen in `models.py` (layering rule: models never +import tasks). A service method owns this: + +- `ProjectService.set_chip_on_board(project, *, value, user)` (or equivalent in + the existing project service package). +- On an actual change of value: + - If the active file has a **cancellable** check, `check.mark_cancelling( + reason="Chip-on-Board option changed")` (mirrors + `file_service._handle_file_replacement`). + - Queue a fresh manufacturability check carrying a new + `ManufacturabilityCheck.TriggerReason.COB_CHANGE` and the new snapshot. + - If the project is still DRAFT (no check yet), just persist the flag. + +Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to +`ManufacturabilityCheck.TriggerReason`. + +### 4. UI + +- A "Request Chip-on-Board (CoB) packaging" checkbox on the project **create** + and **edit** forms, with help text explaining it runs extra CoB compatibility + checks. The view's form-save calls the service when the value changes so the + re-check fires. +- Project detail: a badge indicating CoB requested (yes/no), reusing existing + badge components. The manufacturability result already reflects the extra + checks. + +## Data flow + +1. User checks "Request CoB" on create/edit → view → `set_chip_on_board`. +2. Service persists `Project.chip_on_board`, cancels any in-flight check, and + queues a manufacturability check (`COB_CHANGE`) snapshotting `chip_on_board`. +3. `do_starting` builds the precheck command with `--cob` from the snapshot. +4. The precheck runs the extra CoB checks; results flow through the existing + pipeline; the project detail page shows the CoB badge + manufacturability + result. + +## Error handling + +- Toggling when no check is cancellable / project is DRAFT: persist only, no + cancel (guarded by `is_cancellable` and `InvalidStateTransitionError`, as in + `_handle_file_replacement`). +- The precheck `ValueError`/failure paths are unchanged; CoB failures are + ordinary manufacturability errors. + +## Testing (TDD) + +- **Model:** `chip_on_board` defaults False; editable (not blocked by + `CORE_FIELDS` immutability); snapshot field on `ManufacturabilityCheck`. +- **Service:** toggling CoB on a project with a running/finished check cancels + the check and queues a new `COB_CHANGE` check with the correct snapshot; + toggling on a DRAFT only persists. +- **Command builder:** `--cob` appended iff the check's `chip_on_board` is True; + absent otherwise; placed alongside `--slot`/`--id`. +- **View/form:** the checkbox renders on create + edit; POSTing it sets the flag + and invokes the service. + +## Out of scope + +- The CoB compatibility **rules** (already implemented in the precheck image). +- A CoB-specific result breakdown UI — CoB failures surface as normal + manufacturability errors; a dedicated breakdown can be a follow-up. +- Per-shuttle or per-slot CoB eligibility constraints. + +## Follow-ups + +- Update issue #259 to reference the real `--cob` flag. From 40667ecab22bd11773f3932126c26b889cbc3048 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Mon, 8 Jun 2026 22:55:27 +0930 Subject: [PATCH 02/23] docs: address spec review - read --cob live, drop snapshot field Spec reviewer flagged a false 'slot/DRC-version snapshot' precedent: do_starting reads slot_size/full_id live from check.project, not a snapshot. Drop the proposed ManufacturabilityCheck.chip_on_board snapshot field and read --cob live from check.project (consistent with --slot/--id); fix service class reference. Co-Authored-By: Claude Opus 4.8 --- ...26-06-08-chip-on-board-packaging-design.md | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md index f0f67e85..646f1dc1 100644 --- a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -51,26 +51,35 @@ parallel. ### 1. Data model (one migration) - `Project.chip_on_board: BooleanField(default=False)` — editable; excluded from - `CORE_FIELDS`. Mirrors `WS_COB`. -- `ManufacturabilityCheck.chip_on_board: BooleanField(default=False)` — a - **snapshot** of the value the check actually ran with. Parallels the existing - slot/DRC-version context recorded on checks; makes a finished result - unambiguous and lets us detect when a toggle has made the latest result stale. + `CORE_FIELDS`. Mirrors the precheck's `WS_COB`. + +No new field on `ManufacturabilityCheck`. The precheck command reads +`chip_on_board` **live from `check.project`** (see §2), matching how +`--slot`/`--id` already read `check.project.slot_size`/`full_id` +(`tasks_checks.py:1057-1058`). Because toggling CoB cancels any in-flight check +and queues a new one (§3), a running check's project value is stable, so a live +read always reflects what the check was queued with — no snapshot is needed. (A +per-check snapshot could be added later if historical CoB labelling of finished +checks becomes necessary; out of scope here.) ### 2. Precheck command wiring -In `tasks_checks.py` `do_starting`, where the command list is built, append -`"--cob"` iff the check's `chip_on_board` is True. `store_true` → no value. The -snapshot is read from the `ManufacturabilityCheck` so the command matches what -the check records. +In `tasks_checks.py` `do_starting`, where the command list is built (the same +place `--slot`/`--id` are appended from `check.project.slot_size`/`full_id`), +append `"--cob"` iff `check.project.chip_on_board` is True. `store_true` → no +value. ### 3. Re-check on toggle (service layer) Queuing a task must not happen in `models.py` (layering rule: models never import tasks). A service method owns this: -- `ProjectService.set_chip_on_board(project, *, value, user)` (or equivalent in - the existing project service package). +- A new `ProjectService.set_chip_on_board(project, *, value, user)` in + `wafer_space/projects/services/project_service.py`. (The existing + `ProjectFileService` in `services/file_service.py` owns the file-replacement + variant of this cancel+re-check logic; the shared cancel step — + `check.is_cancellable` → `check.mark_cancelling(...)` guarded by + `InvalidStateTransitionError` — should be reused rather than duplicated.) - On an actual change of value: - If the active file has a **cancellable** check, `check.mark_cancelling( reason="Chip-on-Board option changed")` (mirrors @@ -96,8 +105,9 @@ Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to 1. User checks "Request CoB" on create/edit → view → `set_chip_on_board`. 2. Service persists `Project.chip_on_board`, cancels any in-flight check, and - queues a manufacturability check (`COB_CHANGE`) snapshotting `chip_on_board`. -3. `do_starting` builds the precheck command with `--cob` from the snapshot. + queues a manufacturability check (`COB_CHANGE`). +3. `do_starting` builds the precheck command with `--cob` read live from + `check.project.chip_on_board`. 4. The precheck runs the extra CoB checks; results flow through the existing pipeline; the project detail page shows the CoB badge + manufacturability result. @@ -113,12 +123,12 @@ Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to ## Testing (TDD) - **Model:** `chip_on_board` defaults False; editable (not blocked by - `CORE_FIELDS` immutability); snapshot field on `ManufacturabilityCheck`. + `CORE_FIELDS` immutability). - **Service:** toggling CoB on a project with a running/finished check cancels - the check and queues a new `COB_CHANGE` check with the correct snapshot; - toggling on a DRAFT only persists. -- **Command builder:** `--cob` appended iff the check's `chip_on_board` is True; - absent otherwise; placed alongside `--slot`/`--id`. + the check and queues a new `COB_CHANGE` check; toggling on a DRAFT only + persists; no re-check when the submitted value is unchanged. +- **Command builder:** `--cob` appended iff `check.project.chip_on_board` is + True; absent otherwise; placed alongside `--slot`/`--id`. - **View/form:** the checkbox renders on create + edit; POSTing it sets the flag and invokes the service. From f30fc64e76e2d8636bd191c9fc9cddd875e1512a Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Mon, 8 Jun 2026 23:31:08 +0930 Subject: [PATCH 03/23] docs: model CoB re-check on the precheck-version-change pattern Per review feedback, CoB toggling now mirrors the DRC_UPDATE/precheck-version mechanism instead of the file-replacement cancel path: a new model method create_check_cob_change() (parallel to create_check_drc_update) creates a new PENDING check with parent_check linkage and TriggerReason.COB_CHANGE; the existing _cancel_superseded_checks() auto-cancels any in-progress older check. Pure ORM, so no service/task-import layer is needed. Co-Authored-By: Claude Opus 4.8 --- ...26-06-08-chip-on-board-packaging-design.md | 91 +++++++++++-------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md index 646f1dc1..7490a4ca 100644 --- a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -56,9 +56,10 @@ parallel. No new field on `ManufacturabilityCheck`. The precheck command reads `chip_on_board` **live from `check.project`** (see §2), matching how `--slot`/`--id` already read `check.project.slot_size`/`full_id` -(`tasks_checks.py:1057-1058`). Because toggling CoB cancels any in-flight check -and queues a new one (§3), a running check's project value is stable, so a live -read always reflects what the check was queued with — no snapshot is needed. (A +(`tasks_checks.py:1057-1058`). Because toggling CoB creates a new check that +supersedes any in-flight one (§3), a running check's project value is stable, so +a live read always reflects what the check was queued with — no snapshot is +needed. (A per-check snapshot could be added later if historical CoB labelling of finished checks becomes necessary; out of scope here.) @@ -69,68 +70,86 @@ place `--slot`/`--id` are appended from `check.project.slot_size`/`full_id`), append `"--cob"` iff `check.project.chip_on_board` is True. `store_true` → no value. -### 3. Re-check on toggle (service layer) - -Queuing a task must not happen in `models.py` (layering rule: models never -import tasks). A service method owns this: - -- A new `ProjectService.set_chip_on_board(project, *, value, user)` in - `wafer_space/projects/services/project_service.py`. (The existing - `ProjectFileService` in `services/file_service.py` owns the file-replacement - variant of this cancel+re-check logic; the shared cancel step — - `check.is_cancellable` → `check.mark_cancelling(...)` guarded by - `InvalidStateTransitionError` — should be reused rather than duplicated.) -- On an actual change of value: - - If the active file has a **cancellable** check, `check.mark_cancelling( - reason="Chip-on-Board option changed")` (mirrors - `file_service._handle_file_replacement`). - - Queue a fresh manufacturability check carrying a new - `ManufacturabilityCheck.TriggerReason.COB_CHANGE` and the new snapshot. - - If the project is still DRAFT (no check yet), just persist the flag. +### 3. Re-check on toggle (precheck-version-change pattern) + +CoB toggling reuses the existing **precheck-version-change** mechanism, not the +file-replacement cancel path: + +- Add a model method `ManufacturabilityCheck.create_check_cob_change()`, + parallel to the existing `create_check_drc_update()` (`models.py:2269`). It + validates that `self` is the latest check for its `project_file`, then creates + a **new PENDING** `ManufacturabilityCheck` with + `trigger_reason=TriggerReason.COB_CHANGE` and `parent_check=self` (chaining via + `parent_check`/`root_check`, like DRC updates and retries). It is a plain ORM + create — no task import and no manual cancellation. (Unlike + `create_check_drc_update`, there are no docker-digest/version guards; the + trigger is the user toggling, and it is valid whether the latest check is + finished or still in progress.) +- The new pending check is dispatched by the normal check-queue processor. Any + still-in-progress older check is auto-cancelled by the existing + `_cancel_superseded_checks()` logic (`tasks_checks.py:1922`), which cancels an + in-progress check once a newer check exists for the same file — exactly what + DRC updates rely on. +- The toggle itself lives in the project edit view's form handling: persist the + changed `Project.chip_on_board`; if the active file has a latest check, call + `latest_check.create_check_cob_change()`. If the project has no check yet + (DRAFT), just persist the flag — the first check reads `chip_on_board` live. Add `COB_CHANGE = "cob_change", "Chip-on-Board Option Changed"` to `ManufacturabilityCheck.TriggerReason`. +Because pending-check creation is pure ORM (the queue processor does the +dispatching), there is no models-import-tasks layering concern and no separate +service is required. + ### 4. UI - A "Request Chip-on-Board (CoB) packaging" checkbox on the project **create** and **edit** forms, with help text explaining it runs extra CoB compatibility - checks. The view's form-save calls the service when the value changes so the - re-check fires. + checks. On save, the view creates the re-check (§3) when the value changes. - Project detail: a badge indicating CoB requested (yes/no), reusing existing badge components. The manufacturability result already reflects the extra checks. ## Data flow -1. User checks "Request CoB" on create/edit → view → `set_chip_on_board`. -2. Service persists `Project.chip_on_board`, cancels any in-flight check, and - queues a manufacturability check (`COB_CHANGE`). -3. `do_starting` builds the precheck command with `--cob` read live from +1. User checks "Request CoB" on create/edit → project edit view. +2. View persists `Project.chip_on_board`; if the active file has a latest check, + calls `create_check_cob_change()` → a new PENDING `COB_CHANGE` check. +3. The check-queue processor dispatches the pending check; any in-progress older + check is auto-cancelled by `_cancel_superseded_checks()`. +4. `do_starting` builds the precheck command with `--cob` read live from `check.project.chip_on_board`. -4. The precheck runs the extra CoB checks; results flow through the existing +5. The precheck runs the extra CoB checks; results flow through the existing pipeline; the project detail page shows the CoB badge + manufacturability result. ## Error handling -- Toggling when no check is cancellable / project is DRAFT: persist only, no - cancel (guarded by `is_cancellable` and `InvalidStateTransitionError`, as in - `_handle_file_replacement`). +- Toggling on a DRAFT project (no check yet): persist only; the first check + reads `chip_on_board` live. +- `create_check_cob_change()` raises if called on a non-latest check (mirrors + `create_check_drc_update`'s latest-check guard); the view only calls it on the + active file's latest check. +- Superseding an in-progress check is handled by the existing + `_cancel_superseded_checks()` cleanup (guarded by `InvalidStateTransitionError`), + not by new code. - The precheck `ValueError`/failure paths are unchanged; CoB failures are ordinary manufacturability errors. ## Testing (TDD) - **Model:** `chip_on_board` defaults False; editable (not blocked by - `CORE_FIELDS` immutability). -- **Service:** toggling CoB on a project with a running/finished check cancels - the check and queues a new `COB_CHANGE` check; toggling on a DRAFT only - persists; no re-check when the submitted value is unchanged. + `CORE_FIELDS` immutability). `create_check_cob_change()` creates a PENDING + check with `trigger_reason=COB_CHANGE` and `parent_check` set to the source + check; raises when called on a non-latest check. +- **Toggle (view):** changing CoB on a project with a latest check creates + exactly one new pending `COB_CHANGE` check; toggling on a DRAFT only persists; + submitting the form with the value unchanged creates no new check. - **Command builder:** `--cob` appended iff `check.project.chip_on_board` is True; absent otherwise; placed alongside `--slot`/`--id`. - **View/form:** the checkbox renders on create + edit; POSTing it sets the flag - and invokes the service. + and (when changed) creates the re-check. ## Out of scope From 57443a99670ff66a605c425dea667a36301b0438 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Tue, 9 Jun 2026 00:31:05 +0930 Subject: [PATCH 04/23] docs: fix CoB re-check to cancel in-progress checks explicitly Spec review found _cancel_superseded_checks() is only called from checks_cleanup(), which is NOT in CELERY_BEAT_SCHEDULE, so the relied-upon auto-cancel never runs. DRC updates never hit this because they only re-check FINISHED checks. Since a CoB toggle can happen mid-check, create_check_cob_change() now marks an in-progress latest check as CANCELLING explicitly (mark_cancelling), relying only on the scheduled checks_pending/checks_cancelling pollers. Co-Authored-By: Claude Opus 4.8 --- ...26-06-08-chip-on-board-packaging-design.md | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md index 7490a4ca..a5952944 100644 --- a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -56,12 +56,12 @@ parallel. No new field on `ManufacturabilityCheck`. The precheck command reads `chip_on_board` **live from `check.project`** (see §2), matching how `--slot`/`--id` already read `check.project.slot_size`/`full_id` -(`tasks_checks.py:1057-1058`). Because toggling CoB creates a new check that -supersedes any in-flight one (§3), a running check's project value is stable, so -a live read always reflects what the check was queued with — no snapshot is -needed. (A -per-check snapshot could be added later if historical CoB labelling of finished -checks becomes necessary; out of scope here.) +(`tasks_checks.py:1057-1058`). Because toggling CoB cancels any in-flight check +and creates a fresh one (§3), at most one non-cancelled check is ever active for +a file, so a live read of `chip_on_board` reflects what that check was created +with — no snapshot is needed. (A per-check snapshot could be added later if +historical CoB labelling of finished checks becomes necessary; out of scope +here.) ### 2. Precheck command wiring @@ -77,19 +77,27 @@ file-replacement cancel path: - Add a model method `ManufacturabilityCheck.create_check_cob_change()`, parallel to the existing `create_check_drc_update()` (`models.py:2269`). It - validates that `self` is the latest check for its `project_file`, then creates - a **new PENDING** `ManufacturabilityCheck` with + validates that `self` is the latest check for its `project_file`; if + `self.is_cancellable` (the latest check is still in progress) it calls + `self.mark_cancelling(reason="Chip-on-Board option changed")`; then it creates + and returns a **new PENDING** `ManufacturabilityCheck` with `trigger_reason=TriggerReason.COB_CHANGE` and `parent_check=self` (chaining via - `parent_check`/`root_check`, like DRC updates and retries). It is a plain ORM - create — no task import and no manual cancellation. (Unlike - `create_check_drc_update`, there are no docker-digest/version guards; the - trigger is the user toggling, and it is valid whether the latest check is - finished or still in progress.) -- The new pending check is dispatched by the normal check-queue processor. Any - still-in-progress older check is auto-cancelled by the existing - `_cancel_superseded_checks()` logic (`tasks_checks.py:1922`), which cancels an - in-progress check once a newer check exists for the same file — exactly what - DRC updates rely on. + `parent_check`/`root_check`, like DRC updates and retries). This is pure + model/ORM logic — no task import. (`mark_cancelling` is a state transition; the + scheduled `checks_cancelling` task performs the container teardown, exactly as + the file-replacement path relies on it. Unlike `create_check_drc_update` there + are no docker-digest/version guards — the trigger is the user toggling.) +- **Why the explicit cancel:** DRC updates only ever re-check a *finished* check + (`checks_drc_update_requeue` skips in-progress ones), so they never needed to + cancel a running check. A CoB toggle can happen while a check is RUNNING, so + `create_check_cob_change()` cancels it directly. We deliberately do **not** + rely on `_cancel_superseded_checks()` (`tasks_checks.py:1922`): though it would + cancel an in-progress check that has a newer sibling, it runs only inside + `checks_cleanup()`, which is **not** in `CELERY_BEAT_SCHEDULE` and therefore + never executes. +- The new PENDING check is dispatched by the scheduled `checks_pending` task + (15s); the cancelled in-progress check, if any, is torn down by the scheduled + `checks_cancelling` task (15s). Both are existing, scheduled pollers. - The toggle itself lives in the project edit view's form handling: persist the changed `Project.chip_on_board`; if the active file has a latest check, call `latest_check.create_check_cob_change()`. If the project has no check yet @@ -116,8 +124,9 @@ service is required. 1. User checks "Request CoB" on create/edit → project edit view. 2. View persists `Project.chip_on_board`; if the active file has a latest check, calls `create_check_cob_change()` → a new PENDING `COB_CHANGE` check. -3. The check-queue processor dispatches the pending check; any in-progress older - check is auto-cancelled by `_cancel_superseded_checks()`. +3. `create_check_cob_change()` has already marked any in-progress check as + CANCELLING; the scheduled `checks_cancelling` task tears it down and + `checks_pending` dispatches the new PENDING check. 4. `do_starting` builds the precheck command with `--cob` read live from `check.project.chip_on_board`. 5. The precheck runs the extra CoB checks; results flow through the existing @@ -131,9 +140,11 @@ service is required. - `create_check_cob_change()` raises if called on a non-latest check (mirrors `create_check_drc_update`'s latest-check guard); the view only calls it on the active file's latest check. -- Superseding an in-progress check is handled by the existing - `_cancel_superseded_checks()` cleanup (guarded by `InvalidStateTransitionError`), - not by new code. +- An in-progress latest check is cancelled explicitly inside + `create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`, + which also avoids `InvalidStateTransitionError`); teardown is done by the + scheduled `checks_cancelling` task. The unscheduled + `_cancel_superseded_checks()` is deliberately not relied upon. - The precheck `ValueError`/failure paths are unchanged; CoB failures are ordinary manufacturability errors. @@ -142,7 +153,9 @@ service is required. - **Model:** `chip_on_board` defaults False; editable (not blocked by `CORE_FIELDS` immutability). `create_check_cob_change()` creates a PENDING check with `trigger_reason=COB_CHANGE` and `parent_check` set to the source - check; raises when called on a non-latest check. + check; when the source check is in progress it is marked CANCELLING; when the + source check is already finished no cancel occurs; raises when called on a + non-latest check. - **Toggle (view):** changing CoB on a project with a latest check creates exactly one new pending `COB_CHANGE` check; toggling on a DRAFT only persists; submitting the form with the value unchanged creates no new check. From 10e2bfb395fed7cb99e2ae5571a1c3eb885fdc4c Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Tue, 9 Jun 2026 00:33:10 +0930 Subject: [PATCH 05/23] docs: note stale create_check_drc_update docstring as impl follow-up Spec reviewer (approved) flagged that the existing create_check_drc_update docstring repeats the unscheduled-cleanup belief; capture as a follow-up so it is not copied into create_check_cob_change. Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-08-chip-on-board-packaging-design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md index a5952944..8be49db7 100644 --- a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -174,3 +174,9 @@ service is required. ## Follow-ups - Update issue #259 to reference the real `--cob` flag. +- When implementing, correct the misleading `create_check_drc_update` docstring + (`models.py:2272-2273`), which claims in-progress checks are "automatically + cancelled by the existing superseded check cleanup logic" — that cleanup + (`checks_cleanup` / `_cancel_superseded_checks`) is not scheduled. It is + harmless for DRC (which only re-checks FINISHED checks), but the comment must + not be copied into `create_check_cob_change`. From c025392278688a5908a86d652de70c202d02ca8f Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Wed, 10 Jun 2026 17:22:12 +0930 Subject: [PATCH 06/23] docs: revise CoB spec for scheduled superseded cleanup (#261/PR #262) Assuming PR #262 lands, checks_cleanup_superseded runs on a 60s beat and _cancel_superseded_checks/checks_cleanup no longer exist. Update the spec's rationale: the explicit cancel in create_check_cob_change() is kept for immediacy (a superseded check must not finish with a stale CoB flag), audit-precise cancel reason, and test determinism; the scheduled cleanup is demoted to a defence-in-depth backstop. Drop the now-obsolete docstring follow-up (fixed by #262) and note the rebase-after-#262 dependency. Co-Authored-By: Claude Fable 5 --- ...26-06-08-chip-on-board-packaging-design.md | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md index 8be49db7..a8681281 100644 --- a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -1,8 +1,16 @@ # Chip-on-Board (CoB) Packaging Support — Design -- **Date:** 2026-06-08 +- **Date:** 2026-06-08 (revised 2026-06-10 for + [#261](https://github.com/wafer-space/platform.wafer.space/issues/261) / + [PR #262](https://github.com/wafer-space/platform.wafer.space/pull/262)) - **Issue:** [#259](https://github.com/wafer-space/platform.wafer.space/issues/259) - **Status:** Approved design, pending spec review +- **Assumes:** PR #262 (fixes #261) lands before implementation. It replaces + the never-scheduled `checks_cleanup()` / `_cancel_superseded_checks()` with a + scheduled `checks_cleanup_superseded` beat task (60s) and fixes the + `create_check_drc_update` docstring. This spec is written against that + post-#262 state. The design also works without #262 — it never *relies* on + the superseded-cleanup task — only the backstop notes below would not apply. ## Summary @@ -87,14 +95,24 @@ file-replacement cancel path: scheduled `checks_cancelling` task performs the container teardown, exactly as the file-replacement path relies on it. Unlike `create_check_drc_update` there are no docker-digest/version guards — the trigger is the user toggling.) -- **Why the explicit cancel:** DRC updates only ever re-check a *finished* check - (`checks_drc_update_requeue` skips in-progress ones), so they never needed to - cancel a running check. A CoB toggle can happen while a check is RUNNING, so - `create_check_cob_change()` cancels it directly. We deliberately do **not** - rely on `_cancel_superseded_checks()` (`tasks_checks.py:1922`): though it would - cancel an in-progress check that has a newer sibling, it runs only inside - `checks_cleanup()`, which is **not** in `CELERY_BEAT_SCHEDULE` and therefore - never executes. +- **Why the explicit cancel:** the scheduled DRC-update requeue only re-checks + *finished* checks (`checks_drc_update_requeue` skips in-progress ones), but a + CoB toggle can happen while a check is RUNNING, so + `create_check_cob_change()` cancels it directly. Post-#262 the scheduled + `checks_cleanup_superseded` task (60s) would *eventually* cancel a superseded + in-progress check, but we still cancel explicitly because: + 1. **Immediacy/correctness** — relying on the 60s backstop leaves a window in + which the superseded check keeps running and could even FINISH, recording + a result computed with the *old* CoB setting. That would break §1's + live-read invariant ("at most one non-cancelled check is ever active"). + Explicit cancel closes the window at toggle time. + 2. **Audit precision** — `mark_cancelling(reason="Chip-on-Board option + changed")` records why, instead of the generic superseded-cleanup reason. + 3. **Determinism** — the method's behaviour is self-contained and testable + without depending on beat timing. + + `checks_cleanup_superseded` remains a defence-in-depth backstop (it would + mop up if the explicit cancel were ever skipped), not the mechanism. - The new PENDING check is dispatched by the scheduled `checks_pending` task (15s); the cancelled in-progress check, if any, is torn down by the scheduled `checks_cancelling` task (15s). Both are existing, scheduled pollers. @@ -143,8 +161,9 @@ service is required. - An in-progress latest check is cancelled explicitly inside `create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`, which also avoids `InvalidStateTransitionError`); teardown is done by the - scheduled `checks_cancelling` task. The unscheduled - `_cancel_superseded_checks()` is deliberately not relied upon. + scheduled `checks_cancelling` task. The scheduled `checks_cleanup_superseded` + task (post-#262, 60s) is a defence-in-depth backstop only — the explicit + cancel is the primary mechanism (see §3 for why). - The precheck `ValueError`/failure paths are unchanged; CoB failures are ordinary manufacturability errors. @@ -174,9 +193,10 @@ service is required. ## Follow-ups - Update issue #259 to reference the real `--cob` flag. -- When implementing, correct the misleading `create_check_drc_update` docstring - (`models.py:2272-2273`), which claims in-progress checks are "automatically - cancelled by the existing superseded check cleanup logic" — that cleanup - (`checks_cleanup` / `_cancel_superseded_checks`) is not scheduled. It is - harmless for DRC (which only re-checks FINISHED checks), but the comment must - not be copied into `create_check_cob_change`. +- Before implementation: rebase this branch onto `main` once PR #262 merges — + #262 touches `tasks_checks.py` and the `create_check_drc_update` area of + `models.py`, both adjacent to this feature's edit sites. (#262 also fixes the + previously misleading `create_check_drc_update` docstring, so no docstring + follow-up remains here; `create_check_cob_change`'s own docstring should + state that it cancels in-progress checks *itself*, unlike + `create_check_drc_update` which defers to `checks_cleanup_superseded`.) From 02abaaab37c10b69993dc064d207bb0df99e558f Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Wed, 10 Jun 2026 17:27:49 +0930 Subject: [PATCH 07/23] docs: fix CoB spec teardown sequence and tighten stale-FINISH rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec review findings: checks_cancelling only transitions CANCELLING->CANCELLED; container removal is done by checks_cleanup_orphaned_docker (60s) once the check is CANCELLED — correct all four passages that attributed teardown to checks_cancelling. Recast the explicit-cancel justification in terms of legal state transitions (ANALYZING->FINISHED vs CANCELLING->CANCELLED only) instead of the imprecise 'active' invariant, and add the detail-page CoB badge to the test plan. Co-Authored-By: Claude Fable 5 --- ...26-06-08-chip-on-board-packaging-design.md | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md index a8681281..abb61823 100644 --- a/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -91,10 +91,12 @@ file-replacement cancel path: and returns a **new PENDING** `ManufacturabilityCheck` with `trigger_reason=TriggerReason.COB_CHANGE` and `parent_check=self` (chaining via `parent_check`/`root_check`, like DRC updates and retries). This is pure - model/ORM logic — no task import. (`mark_cancelling` is a state transition; the - scheduled `checks_cancelling` task performs the container teardown, exactly as - the file-replacement path relies on it. Unlike `create_check_drc_update` there - are no docker-digest/version guards — the trigger is the user toggling.) + model/ORM logic — no task import. (`mark_cancelling` is a state transition; + the scheduled `checks_cancelling` task (15s) then completes + CANCELLING→CANCELLED, and `checks_cleanup_orphaned_docker` (60s) removes the + container once the check is CANCELLED — the same machinery the + file-replacement path relies on. Unlike `create_check_drc_update` there are + no docker-digest/version guards — the trigger is the user toggling.) - **Why the explicit cancel:** the scheduled DRC-update requeue only re-checks *finished* checks (`checks_drc_update_requeue` skips in-progress ones), but a CoB toggle can happen while a check is RUNNING, so @@ -102,10 +104,11 @@ file-replacement cancel path: `checks_cleanup_superseded` task (60s) would *eventually* cancel a superseded in-progress check, but we still cancel explicitly because: 1. **Immediacy/correctness** — relying on the 60s backstop leaves a window in - which the superseded check keeps running and could even FINISH, recording - a result computed with the *old* CoB setting. That would break §1's - live-read invariant ("at most one non-cancelled check is ever active"). - Explicit cancel closes the window at toggle time. + which the superseded check keeps running and could even FINISH + (ANALYZING→FINISHED is a legal transition, whereas CANCELLING permits only + →CANCELLED), recording a result computed with the *old* CoB setting that + §1's live read would then mislabel with the new value. Explicit cancel + closes that window at toggle time — a stale FINISH becomes impossible. 2. **Audit precision** — `mark_cancelling(reason="Chip-on-Board option changed")` records why, instead of the generic superseded-cleanup reason. 3. **Determinism** — the method's behaviour is self-contained and testable @@ -114,8 +117,9 @@ file-replacement cancel path: `checks_cleanup_superseded` remains a defence-in-depth backstop (it would mop up if the explicit cancel were ever skipped), not the mechanism. - The new PENDING check is dispatched by the scheduled `checks_pending` task - (15s); the cancelled in-progress check, if any, is torn down by the scheduled - `checks_cancelling` task (15s). Both are existing, scheduled pollers. + (15s); the cancelled in-progress check, if any, is marked CANCELLED by the + scheduled `checks_cancelling` task (15s), and its container is removed by + `checks_cleanup_orphaned_docker` (60s). All are existing, scheduled pollers. - The toggle itself lives in the project edit view's form handling: persist the changed `Project.chip_on_board`; if the active file has a latest check, call `latest_check.create_check_cob_change()`. If the project has no check yet @@ -143,7 +147,8 @@ service is required. 2. View persists `Project.chip_on_board`; if the active file has a latest check, calls `create_check_cob_change()` → a new PENDING `COB_CHANGE` check. 3. `create_check_cob_change()` has already marked any in-progress check as - CANCELLING; the scheduled `checks_cancelling` task tears it down and + CANCELLING; the scheduled `checks_cancelling` task marks it CANCELLED + (container removal follows via `checks_cleanup_orphaned_docker`) and `checks_pending` dispatches the new PENDING check. 4. `do_starting` builds the precheck command with `--cob` read live from `check.project.chip_on_board`. @@ -160,10 +165,12 @@ service is required. active file's latest check. - An in-progress latest check is cancelled explicitly inside `create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`, - which also avoids `InvalidStateTransitionError`); teardown is done by the - scheduled `checks_cancelling` task. The scheduled `checks_cleanup_superseded` - task (post-#262, 60s) is a defence-in-depth backstop only — the explicit - cancel is the primary mechanism (see §3 for why). + which also avoids `InvalidStateTransitionError`); the scheduled + `checks_cancelling` task completes the transition to CANCELLED, and + `checks_cleanup_orphaned_docker` removes the container. The scheduled + `checks_cleanup_superseded` task (post-#262, 60s) is a defence-in-depth + backstop only — the explicit cancel is the primary mechanism (see §3 for + why). - The precheck `ValueError`/failure paths are unchanged; CoB failures are ordinary manufacturability errors. @@ -181,7 +188,8 @@ service is required. - **Command builder:** `--cob` appended iff `check.project.chip_on_board` is True; absent otherwise; placed alongside `--slot`/`--id`. - **View/form:** the checkbox renders on create + edit; POSTing it sets the flag - and (when changed) creates the re-check. + and (when changed) creates the re-check; the project detail page shows the + CoB badge when the flag is set (and not when unset). ## Out of scope From 6a209dfab0db9037b820024fe4d3a7ecfc8901cd Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Wed, 10 Jun 2026 17:43:31 +0930 Subject: [PATCH 08/23] docs: add CoB packaging implementation plan 8 TDD tasks: model field + migration, COB_CHANGE trigger + migration, create_check_cob_change(), --cob command wiring, form + template, view re-check hook, detail badge, final verification. Co-Authored-By: Claude Fable 5 --- .../2026-06-10-chip-on-board-packaging.md | 661 ++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md diff --git a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md new file mode 100644 index 00000000..13d9e0e1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md @@ -0,0 +1,661 @@ +# Chip-on-Board (CoB) Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users request Chip-on-Board (CoB) packaging on a project; the manufacturability precheck then runs with `--cob`, and toggling the option re-runs the check. + +**Architecture:** One new editable boolean on `Project` (`chip_on_board`), read live by the precheck command builder (like `--slot`/`--id`). Toggling it creates a new PENDING `ManufacturabilityCheck` via a new model method `create_check_cob_change()` (modelled on `create_check_drc_update()`), which explicitly cancels an in-progress check. Existing scheduled pollers (`checks_pending`, `checks_cancelling`, `checks_cleanup_orphaned_docker`) do all the async work — no new tasks, no layering changes. + +**Tech Stack:** Django 5.2, pytest-django + factory-boy, Celery (PostgreSQL broker), crispy-forms templates, ruff/mypy/djlint. + +**Spec:** `docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md` — read it first. + +--- + +## Before you start + +- **Rebase gate:** the spec assumes PR #262 (`checks_cleanup_superseded`) lands first. Run `git fetch origin main` and check `git log --oneline origin/main | head -5`. If PR #262 has merged, `git rebase origin/main` before starting. If it has NOT merged yet, stop and ask the user whether to proceed anyway (the design works either way, but docstrings written in Task 3 reference the post-#262 world). +- Work in the worktree `.worktrees/feature/chip-on-board-packaging`, branch `feature/chip-on-board-packaging`. +- **Pre-commit gate for EVERY commit** (project rule, no shortcuts): `make lint-fix && make lint && make type-check && make test`. Baseline today: 1283 passed, 3 skipped. +- TDD throughout: write the failing test, watch it fail, implement, watch it pass. See @superpowers:test-driven-development. +- All new code needs type hints; never add `# noqa` / `# type: ignore`. + +## File map + +| File | Change | +|------|--------| +| `wafer_space/projects/models.py` | Add `Project.chip_on_board` field + `USER_FIELDS` entry; add `TriggerReason.COB_CHANGE`; add `ManufacturabilityCheck.create_check_cob_change()` | +| `wafer_space/projects/migrations/0056_*.py`, `0057_*.py` | Generated migrations (field, then choices) | +| `wafer_space/projects/tasks_checks.py` | Append `--cob` in `do_starting` command builder | +| `wafer_space/projects/forms.py` | Add `chip_on_board` to `ProjectForm.Meta` (fields/widgets/help_texts) | +| `wafer_space/templates/projects/project_form.html` | Render the checkbox (fields are rendered explicitly — adding to `Meta.fields` alone does NOT display it) | +| `wafer_space/projects/views.py` | `ProjectUpdateView.form_valid`: create re-check when the flag changes | +| `wafer_space/templates/projects/project_detail.html` | CoB badge | +| Tests | `wafer_space/projects/tests/test_models.py`, `test_tasks.py`, `test_forms.py`, `test_views.py` | + +--- + +### Task 1: `Project.chip_on_board` field + migration + +**Files:** +- Modify: `wafer_space/projects/models.py` (field after `is_public` ~line 244; `USER_FIELDS` ~line 159) +- Create: `wafer_space/projects/migrations/0056_project_chip_on_board.py` (generated) +- Test: `wafer_space/projects/tests/test_models.py` + +- [ ] **Step 1: Write the failing tests** + +Add near the other Project model test classes in `test_models.py` (follow the file's existing import style — `Project`, `ProjectFactory` are already imported): + +```python +@pytest.mark.django_db +class TestProjectChipOnBoard: + """Tests for the Project.chip_on_board flag.""" + + def test_defaults_to_false(self): + """chip_on_board defaults to False.""" + project = ProjectFactory() + assert project.chip_on_board is False + + def test_is_editable_after_creation(self): + """chip_on_board is a user field, not blocked by core-field immutability.""" + project = ProjectFactory() + project.chip_on_board = True + project.full_clean() # core-field immutability is enforced in clean() + project.save() + project.refresh_from_db() + assert project.chip_on_board is True + + def test_is_a_user_field(self): + """chip_on_board is in USER_FIELDS and not in CORE_FIELDS.""" + assert "chip_on_board" in Project.USER_FIELDS + assert "chip_on_board" not in Project.CORE_FIELDS +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestProjectChipOnBoard -v` +Expected: 3 FAILED — `AttributeError` / `AssertionError` (no `chip_on_board` attribute). + +- [ ] **Step 3: Add the field and USER_FIELDS entry** + +In `models.py`, add `"chip_on_board"` to the `USER_FIELDS` frozenset (alongside `"is_public"`). Then add the field directly after the `is_public` field definition (~line 244): + +```python + # Chip-on-Board packaging (Issue #259) + chip_on_board = models.BooleanField( + default=False, + verbose_name="Request Chip-on-Board (CoB) packaging", + help_text=( + "Run extra Chip-on-Board (CoB) compatibility checks during the " + "manufacturability precheck." + ), + ) +``` + +- [ ] **Step 4: Generate the migration** + +Run: `uv run python manage.py makemigrations projects` +Expected: one new migration adding `chip_on_board` to `project` (latest existing migration is `0055_add_commit_info_to_precheck_revision.py`). Inspect the generated file — it must contain exactly one `AddField` for `project.chip_on_board`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestProjectChipOnBoard -v` +Expected: 3 PASSED. + +- [ ] **Step 6: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/projects/models.py wafer_space/projects/migrations/ wafer_space/projects/tests/test_models.py +git commit -m "feat: add Project.chip_on_board field (#259)" +``` + +--- + +### Task 2: `TriggerReason.COB_CHANGE` + migration + +**Files:** +- Modify: `wafer_space/projects/models.py:1549-1553` (`TriggerReason`) +- Create: `wafer_space/projects/migrations/0057_*.py` (generated `AlterField` for choices) +- Test: `wafer_space/projects/tests/test_models.py` + +- [ ] **Step 1: Write the failing test** + +```python +@pytest.mark.django_db +class TestCobChangeTriggerReason: + """Tests for the COB_CHANGE trigger reason.""" + + def test_cob_change_choice_exists(self): + """COB_CHANGE is a valid TriggerReason.""" + reason = ManufacturabilityCheck.TriggerReason.COB_CHANGE + assert reason.value == "cob_change" + assert reason.label == "Chip-on-Board Option Changed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCobChangeTriggerReason -v` +Expected: FAIL with `AttributeError: COB_CHANGE`. + +- [ ] **Step 3: Add the choice** + +In `models.py`, extend `ManufacturabilityCheck.TriggerReason` (after `RETRY`): + +```python + COB_CHANGE = "cob_change", "Chip-on-Board Option Changed" +``` + +- [ ] **Step 4: Generate the migration** + +Run: `uv run python manage.py makemigrations projects` +Expected: one migration with an `AlterField` on `manufacturabilitycheck.trigger_reason` (choices-only change; the project's migration history does this for status choices in `0053`). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCobChangeTriggerReason -v` +Expected: PASS. + +- [ ] **Step 6: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/projects/models.py wafer_space/projects/migrations/ wafer_space/projects/tests/test_models.py +git commit -m "feat: add COB_CHANGE manufacturability trigger reason (#259)" +``` + +--- + +### Task 3: `ManufacturabilityCheck.create_check_cob_change()` + +**Files:** +- Modify: `wafer_space/projects/models.py` (add method directly after `create_check_drc_update`, which ends ~line 2341) +- Test: `wafer_space/projects/tests/test_models.py` (add after `TestCreateCheckDrcUpdate`, ~line 3091, and reuse its imports: `ManufacturabilityCheckFactory`, `ProjectFileFactory`, `pytest`) + +- [ ] **Step 1: Write the failing tests** + +```python +@pytest.mark.django_db +class TestCreateCheckCobChange: + """Tests for ManufacturabilityCheck.create_check_cob_change().""" + + def test_creates_pending_cob_change_check(self): + """Creates a PENDING check with COB_CHANGE reason chained to the source.""" + old_check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.FINISHED, + ) + + new_check = old_check.create_check_cob_change() + + assert new_check.project == old_check.project + assert new_check.project_file == old_check.project_file + assert ( + new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.COB_CHANGE + ) + assert new_check.parent_check == old_check + assert new_check.status == ManufacturabilityCheck.Status.PENDING + + def test_finished_source_check_is_not_cancelled(self): + """A FINISHED source check keeps its status (nothing to cancel).""" + old_check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.FINISHED, + ) + + old_check.create_check_cob_change() + + old_check.refresh_from_db() + assert old_check.status == ManufacturabilityCheck.Status.FINISHED + + def test_in_progress_source_check_is_marked_cancelling(self): + """A RUNNING source check is explicitly marked CANCELLING.""" + running_check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.RUNNING, + ) + + new_check = running_check.create_check_cob_change() + + running_check.refresh_from_db() + assert running_check.status == ManufacturabilityCheck.Status.CANCELLING + assert "Chip-on-Board option changed" in running_check.processing_logs + assert new_check.status == ManufacturabilityCheck.Status.PENDING + + def test_raises_when_not_latest_check(self): + """Refuses to run on a check that is not the file's latest.""" + project_file = ProjectFileFactory() + old_check = ManufacturabilityCheckFactory( + project_file=project_file, + status=ManufacturabilityCheck.Status.FINISHED, + ) + ManufacturabilityCheckFactory( + project_file=project_file, + status=ManufacturabilityCheck.Status.FINISHED, + ) + + with pytest.raises(ValueError, match="latest check"): + old_check.create_check_cob_change() +``` + +Note: `ManufacturabilityCheckFactory(project_file=...)` does not link `project` to the file's project automatically — that's fine here; the not-latest guard only compares checks on the same `project_file` (the existing `TestCreateCheckDrcUpdate` tests do exactly this). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCreateCheckCobChange -v` +Expected: 4 FAILED with `AttributeError: ... has no attribute 'create_check_cob_change'`. + +- [ ] **Step 3: Implement the method** + +Add to `ManufacturabilityCheck` directly after `create_check_drc_update`: + +```python + def create_check_cob_change(self) -> "ManufacturabilityCheck": + """Create a new pending check after the project's CoB option changed. + + Unlike ``create_check_drc_update`` — which leaves an in-progress check + to the scheduled superseded-check cleanup — this cancels an in-progress + check itself, so the superseded check can never FINISH with a result + computed from the old CoB setting. + + Returns: + The newly created ManufacturabilityCheck. + + Raises: + ValueError: If this check is not the latest check for its file. + """ + latest = self.project_file.latest_manufacturability_check + if latest != self: + msg = "Can only create CoB change check from the latest check for a file" + raise ValueError(msg) + + if self.is_cancellable: + self.mark_cancelling(reason="Chip-on-Board option changed") + + return ManufacturabilityCheck.objects.create( + project=self.project, + project_file=self.project_file, + trigger_reason=self.TriggerReason.COB_CHANGE, + parent_check=self, + ) +``` + +Pure ORM — no task imports (models must never import tasks). No digest/version guards (the trigger is the user toggling, not a version change). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestCreateCheckCobChange -v` +Expected: 4 PASSED. + +- [ ] **Step 5: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/projects/models.py wafer_space/projects/tests/test_models.py +git commit -m "feat: add create_check_cob_change model method (#259)" +``` + +--- + +### Task 4: `--cob` in the precheck command (`do_starting`) + +**Files:** +- Modify: `wafer_space/projects/tasks_checks.py` (~line 1070, the `command = [...]` list in `do_starting`) +- Test: `wafer_space/projects/tests/test_tasks.py` (class `TestDoStarting`; copy the mocking pattern of `test_creates_and_starts_container`, ~line 1256) + +- [ ] **Step 1: Write the failing test** + +Add to `TestDoStarting`, mirroring `test_creates_and_starts_container`'s setup/mocks exactly (same `shuttle`, `project__project_id="ABCD"`, `tmp_path` file, `get_docker_client` / `create_tar_archive` / `Path` patches) but with `project__chip_on_board=True`, and assert only on the command: + +```python + @pytest.mark.django_db + def test_command_includes_cob_flag_when_requested(self, tmp_path, settings) -> None: + """--cob is appended after --id when project.chip_on_board is True.""" + # ... same setup/mocks as test_creates_and_starts_container, plus: + # project__chip_on_board=True on the factory call + ... + create_call = mock_client.containers.create.call_args + command = create_call.kwargs["command"] + assert command[-1] == "--cob" + assert command[:-1] == [ + "python3", + "precheck.py", + "--input", + "/input/design.gds", + "--output", + "/output/design.gds", + "--top", + "chip_top", + "--slot", + "1x1", + "--id", + "G850ABCD", + ] +``` + +The existing `test_creates_and_starts_container` already asserts the exact command list for the default project (`chip_on_board=False`), so it doubles as the "no `--cob` by default" regression test — do not modify it. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest wafer_space/projects/tests/test_tasks.py::TestDoStarting::test_command_includes_cob_flag_when_requested -v` +Expected: FAIL — `command[-1]` is `"G850ABCD"`, not `"--cob"`. + +- [ ] **Step 3: Implement** + +In `do_starting`, right after the `command = [...]` list is built (before `command_str = " ".join(command)`): + +```python + if check.project.chip_on_board: + command.append("--cob") +``` + +(`--cob` is an argparse `store_true` flag in the precheck image — no value. Read live from `check.project`, exactly like `slot_size`/`full_id` above it.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_tasks.py::TestDoStarting -v` +Expected: all PASS (including the unmodified default-command test). + +- [ ] **Step 5: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/projects/tasks_checks.py wafer_space/projects/tests/test_tasks.py +git commit -m "feat: pass --cob to precheck when chip_on_board is set (#259)" +``` + +--- + +### Task 5: Form field + template rendering + +**Files:** +- Modify: `wafer_space/projects/forms.py` (`ProjectForm.Meta`, ~lines 199-260) +- Modify: `wafer_space/templates/projects/project_form.html` (~line 65) +- Test: `wafer_space/projects/tests/test_forms.py` (class `TestProjectForm`, line 21) + +**IMPORTANT:** this codebase renders form fields explicitly in the template. Adding the field to `Meta.fields` alone will silently NOT display it — both edits are required. + +- [ ] **Step 1: Write the failing tests** + +Add to `TestProjectForm` in `test_forms.py` (match the class's existing style for constructing the form): + +```python + def test_chip_on_board_field_present_and_optional(self): + """chip_on_board is on the form, optional, and defaults to False.""" + form = ProjectForm(user=self.user) + assert "chip_on_board" in form.fields + assert form.fields["chip_on_board"].required is False + + def test_chip_on_board_editable_for_non_staff_on_existing_project(self): + """chip_on_board is a user field — never disabled on edit.""" + form = ProjectForm(user=self.user, instance=self.project) + assert form.fields["chip_on_board"].disabled is False +``` + +(If `TestProjectForm.setUp` lacks a `self.project`, create one the same way neighbouring tests do.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest wafer_space/projects/tests/test_forms.py::TestProjectForm -v -k chip_on_board` +Expected: 2 FAILED — `KeyError: 'chip_on_board'`. + +- [ ] **Step 3: Implement the form changes** + +In `ProjectForm.Meta`: +- `fields`: add `"chip_on_board"` right after `"is_public"`. +- `widgets`: add `"chip_on_board": forms.CheckboxInput(attrs={"class": "form-check-input"}),` +- `help_texts`: add `"chip_on_board": ("Run extra Chip-on-Board (CoB) compatibility checks during the manufacturability precheck"),` + +(The label comes from the model field's `verbose_name` set in Task 1.) + +- [ ] **Step 4: Render in the template** + +In `project_form.html`, after `{{ form.is_public|as_crispy_field }}` (line 65): + +```html + {{ form.chip_on_board|as_crispy_field }} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_forms.py::TestProjectForm -v` +Expected: PASS (all, including pre-existing tests). + +- [ ] **Step 6: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/projects/forms.py wafer_space/templates/projects/project_form.html wafer_space/projects/tests/test_forms.py +git commit -m "feat: add CoB checkbox to project form (#259)" +``` + +--- + +### Task 6: Re-check on toggle (`ProjectUpdateView.form_valid`) + +**Files:** +- Modify: `wafer_space/projects/views.py` (`ProjectUpdateView.form_valid`, ~line 252) +- Test: `wafer_space/projects/tests/test_views.py` (class `TestProjectUpdateView`, line 251 — reuse its `setUp`; import `ManufacturabilityCheck`, `ManufacturabilityCheckFactory`, `ProjectFileFactory` following the file's import style) + +- [ ] **Step 1: Write the failing tests** + +Add to `TestProjectUpdateView`. Base form data matches `test_owner_can_update_project_details` (line 284): + +```python + def _cob_form_data(self, *, chip_on_board: bool) -> dict: + """Valid update-form payload toggling only chip_on_board.""" + data = { + "name": "Test Project", + "description": "Test project", + "repository_url": "", + "license_type": "proprietary", + "other_license_spdx_id": "", + "proprietary_terms_url": "", + } + if chip_on_board: + data["chip_on_board"] = "on" + return data + + def _make_submitted_check(self, status): + """Attach a submitted file with a check to self.project.""" + project_file = ProjectFileFactory(project=self.project) + self.project.submitted_file = project_file + self.project.save() + return ManufacturabilityCheckFactory( + project=self.project, + project_file=project_file, + status=status, + ) + + def test_toggling_cob_creates_cob_change_check(self): + """Enabling CoB on a project with a check creates one COB_CHANGE check.""" + check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + response = self.client.post(url, self._cob_form_data(chip_on_board=True)) + + assert response.status_code == HTTP_FOUND + self.project.refresh_from_db() + assert self.project.chip_on_board is True + checks = ManufacturabilityCheck.objects.filter( + project_file=check.project_file + ).order_by("created_at") + assert checks.count() == len([check, "new"]) # exactly one new check + new_check = checks.last() + assert ( + new_check.trigger_reason + == ManufacturabilityCheck.TriggerReason.COB_CHANGE + ) + assert new_check.parent_check == check + + def test_toggling_cob_cancels_in_progress_check(self): + """Enabling CoB while a check is RUNNING marks it CANCELLING.""" + check = self._make_submitted_check(ManufacturabilityCheck.Status.RUNNING) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.post(url, self._cob_form_data(chip_on_board=True)) + + check.refresh_from_db() + assert check.status == ManufacturabilityCheck.Status.CANCELLING + + def test_toggling_cob_on_draft_only_persists(self): + """No submitted file/check: the flag is saved, no check is created.""" + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.post(url, self._cob_form_data(chip_on_board=True)) + + self.project.refresh_from_db() + assert self.project.chip_on_board is True + assert ManufacturabilityCheck.objects.filter(project=self.project).count() == 0 + + def test_unchanged_cob_creates_no_check(self): + """Submitting the form with CoB unchanged creates no new check.""" + check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.post(url, self._cob_form_data(chip_on_board=False)) + + assert ( + ManufacturabilityCheck.objects.filter( + project_file=check.project_file + ).count() + == 1 + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest wafer_space/projects/tests/test_views.py::TestProjectUpdateView -v -k cob` +Expected: `test_toggling_cob_creates_cob_change_check` and `test_toggling_cob_cancels_in_progress_check` FAIL (no new check / status unchanged); the draft and unchanged tests may already pass — that is fine, they are regression guards. + +- [ ] **Step 3: Implement in the view** + +Replace `ProjectUpdateView.form_valid` (views can import models; `create_check_cob_change` is pure ORM): + +```python + def form_valid(self, form): + """Save, then re-run the manufacturability check if CoB changed.""" + cob_changed = "chip_on_board" in form.changed_data + response = super().form_valid(form) + + if cob_changed: + latest_check = self.object.latest_manufacturability_check + if latest_check is not None: + latest_check.create_check_cob_change() + + messages.success( + self.request, + f"Project '{form.instance.name}' updated successfully!", + ) + return response +``` + +Notes: +- `form.changed_data` is computed from bound data vs. `initial`, so it stays valid after save; capturing it before `super().form_valid(form)` just keeps the intent obvious. +- `self.object.latest_manufacturability_check` (the `Project` property, `models.py:406`) returns the latest check on `submitted_file`, or `None` for drafts — which satisfies `create_check_cob_change`'s latest-check guard by construction. +- This replaces the old `form_valid` body; the success message moves after the re-check logic but is otherwise unchanged. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_views.py::TestProjectUpdateView -v` +Expected: all PASS (including the pre-existing update tests — the success message must still appear exactly once). + +- [ ] **Step 5: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/projects/views.py wafer_space/projects/tests/test_views.py +git commit -m "feat: re-run manufacturability check when CoB toggled (#259)" +``` + +--- + +### Task 7: CoB badge on the project detail page + +**Files:** +- Modify: `wafer_space/templates/projects/project_detail.html` (after the Visibility block, ~line 128) +- Test: `wafer_space/projects/tests/test_views.py` (detail-view test class) + +- [ ] **Step 1: Write the failing tests** + +Add to the existing project detail view test class in `test_views.py` (find it via `grep -n "class TestProjectDetail" wafer_space/projects/tests/test_views.py`; reuse its setUp/login pattern): + +```python + def test_detail_shows_cob_badge_when_requested(self): + """Detail page shows the CoB badge when chip_on_board is set.""" + self.project.chip_on_board = True + self.project.save() + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:detail", kwargs={"pk": self.project.pk}) + + response = self.client.get(url) + + assert response.status_code == HTTP_OK + assert "Chip-on-Board" in response.content.decode() + + def test_detail_shows_standard_packaging_when_not_requested(self): + """Detail page shows standard packaging when chip_on_board is unset.""" + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:detail", kwargs={"pk": self.project.pk}) + + response = self.client.get(url) + + assert response.status_code == HTTP_OK + assert "Chip-on-Board" not in response.content.decode() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob_badge or standard_packaging"` +Expected: `test_detail_shows_cob_badge_when_requested` FAILS ("Chip-on-Board" not in page); the negative test passes already (regression guard). + +- [ ] **Step 3: Add the badge** + +In `project_detail.html`, after the Visibility `

` (line ~128), matching the surrounding badge markup: + +```html +

+ Packaging: + {% if project.chip_on_board %} + Chip-on-Board (CoB) + {% else %} + Standard + {% endif %} +

+``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob_badge or standard_packaging"` +Expected: 2 PASSED. (`make lint` runs djlint over templates — fix any template lint it reports.) + +- [ ] **Step 5: Pre-commit gate + commit** + +```bash +make lint-fix && make lint && make type-check && make test +git add wafer_space/templates/projects/project_detail.html wafer_space/projects/tests/test_views.py +git commit -m "feat: show CoB packaging badge on project detail (#259)" +``` + +--- + +### Task 8: Final verification + follow-ups + +- [ ] **Step 1: Full quality gate** + +Run: `make check-all` +Expected: everything green. Then `make test` once more: expect baseline + ~17 new tests, 0 failures. + +- [ ] **Step 2: Verify migrations are consistent** + +Run: `uv run python manage.py makemigrations --check --dry-run` +Expected: `No changes detected`. + +- [ ] **Step 3: Update issue #259** + +The issue mentions a `--chip-on-board` flag; the real precheck flag is `--cob`. Post a comment on #259 noting the implemented flag name (use `gh issue comment 259 ...`). + +- [ ] **Step 4: Finish the branch** + +Use @superpowers:finishing-a-development-branch — push and open a PR referencing #259. PR body should call out: new migration(s), the explicit-cancel design (link the spec), and that no new Celery tasks/schedules were added. From 26658c8ce11da82d8d6f6f6240e5e9d303b8835d Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Wed, 10 Jun 2026 17:51:09 +0930 Subject: [PATCH 09/23] docs: address plan review - fix red-phase tests and line refs Make Task 1's editability test fetch fresh from the DB so it genuinely fails before the field exists; give Task 5's form tests their own user/project (TestProjectForm.setUp only creates a shuttle) and correct both tasks' expected-failure descriptions; fix the create_check_drc_update end-line reference. Co-Authored-By: Claude Fable 5 --- .../2026-06-10-chip-on-board-packaging.md | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md index 13d9e0e1..38040a14 100644 --- a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md +++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md @@ -62,8 +62,10 @@ class TestProjectChipOnBoard: project.chip_on_board = True project.full_clean() # core-field immutability is enforced in clean() project.save() - project.refresh_from_db() - assert project.chip_on_board is True + # Fetch fresh from the DB: refresh_from_db() would leave the stale + # in-memory attribute in place pre-implementation, hiding the RED. + reloaded = Project.objects.get(pk=project.pk) + assert reloaded.chip_on_board is True def test_is_a_user_field(self): """chip_on_board is in USER_FIELDS and not in CORE_FIELDS.""" @@ -74,7 +76,8 @@ class TestProjectChipOnBoard: - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest wafer_space/projects/tests/test_models.py::TestProjectChipOnBoard -v` -Expected: 3 FAILED — `AttributeError` / `AssertionError` (no `chip_on_board` attribute). +Expected: 3 FAILED — `AttributeError` (no `chip_on_board` attribute on the fresh +instances) / `AssertionError` (not in `USER_FIELDS`). - [ ] **Step 3: Add the field and USER_FIELDS entry** @@ -169,7 +172,7 @@ git commit -m "feat: add COB_CHANGE manufacturability trigger reason (#259)" ### Task 3: `ManufacturabilityCheck.create_check_cob_change()` **Files:** -- Modify: `wafer_space/projects/models.py` (add method directly after `create_check_drc_update`, which ends ~line 2341) +- Modify: `wafer_space/projects/models.py` (add method directly after `create_check_drc_update`, which ends ~line 2302, just before `queue_wait_seconds`) - Test: `wafer_space/projects/tests/test_models.py` (add after `TestCreateCheckDrcUpdate`, ~line 3091, and reuse its imports: `ManufacturabilityCheckFactory`, `ProjectFileFactory`, `pytest`) - [ ] **Step 1: Write the failing tests** @@ -374,27 +377,42 @@ git commit -m "feat: pass --cob to precheck when chip_on_board is set (#259)" - [ ] **Step 1: Write the failing tests** -Add to `TestProjectForm` in `test_forms.py` (match the class's existing style for constructing the form): +Add to `TestProjectForm` in `test_forms.py`. Note: its `setUp` only creates +`self.shuttle` — there is no `self.user` or `self.project`, so the second test +creates its own (all needed imports — `User`, `Project`, `TEST_PASSWORD` — +already exist at the top of the file): ```python def test_chip_on_board_field_present_and_optional(self): """chip_on_board is on the form, optional, and defaults to False.""" - form = ProjectForm(user=self.user) + form = ProjectForm() assert "chip_on_board" in form.fields assert form.fields["chip_on_board"].required is False def test_chip_on_board_editable_for_non_staff_on_existing_project(self): """chip_on_board is a user field — never disabled on edit.""" - form = ProjectForm(user=self.user, instance=self.project) + user = User.objects.create_user( + username="formuser", email="form@example.com", password=TEST_PASSWORD + ) + project = Project.objects.create( + user=user, + name="Form Project", + shuttle=self.shuttle, + project_id="FRMP", + ) + form = ProjectForm(user=user, instance=project) assert form.fields["chip_on_board"].disabled is False ``` -(If `TestProjectForm.setUp` lacks a `self.project`, create one the same way neighbouring tests do.) +(`ProjectForm()` without `user` is fine — the signature is +`__init__(*args, user=None, **kwargs)`, and the class's first test constructs +it the same way.) - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest wafer_space/projects/tests/test_forms.py::TestProjectForm -v -k chip_on_board` -Expected: 2 FAILED — `KeyError: 'chip_on_board'`. +Expected: 2 FAILED — `AssertionError` (field not in `form.fields`) / +`KeyError: 'chip_on_board'`. - [ ] **Step 3: Implement the form changes** From 316b9787c46dcdf9769c660bec83ea730d184311 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 00:23:38 +0930 Subject: [PATCH 10/23] feat: add Project.chip_on_board field (#259) Add boolean USER_FIELD for Chip-on-Board packaging opt-in. Defaults to False, always user-editable (not subject to core-field immutability). Migration adds the field to both project and historicalproject tables. Co-Authored-By: Claude Fable 5 --- ...istoricalproject_chip_on_board_and_more.py | 23 ++++++++++++++++ wafer_space/projects/models.py | 11 ++++++++ wafer_space/projects/tests/test_models.py | 26 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py diff --git a/wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py b/wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py new file mode 100644 index 00000000..04b500cc --- /dev/null +++ b/wafer_space/projects/migrations/0056_historicalproject_chip_on_board_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2026-06-10 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0055_add_commit_info_to_precheck_revision'), + ] + + operations = [ + migrations.AddField( + model_name='historicalproject', + name='chip_on_board', + field=models.BooleanField(default=False, help_text='Run extra Chip-on-Board (CoB) compatibility checks during the manufacturability precheck.', verbose_name='Request Chip-on-Board (CoB) packaging'), + ), + migrations.AddField( + model_name='project', + name='chip_on_board', + field=models.BooleanField(default=False, help_text='Run extra Chip-on-Board (CoB) compatibility checks during the manufacturability precheck.', verbose_name='Request Chip-on-Board (CoB) packaging'), + ), + ] diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py index 35211aa8..c0a3b5f4 100644 --- a/wafer_space/projects/models.py +++ b/wafer_space/projects/models.py @@ -160,6 +160,7 @@ class Status(models.TextChoices): "name", "description", "is_public", + "chip_on_board", "repository_url", "license_type", "other_license_spdx_id", @@ -243,6 +244,16 @@ class Status(models.TextChoices): help_text="Whether this design should be publicly visible on the platform", ) + # Chip-on-Board packaging (Issue #259) + chip_on_board = models.BooleanField( + default=False, + verbose_name="Request Chip-on-Board (CoB) packaging", + help_text=( + "Run extra Chip-on-Board (CoB) compatibility checks during the " + "manufacturability precheck." + ), + ) + # Repository URL (Issue #137) repository_url = models.URLField( blank=True, diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py index 4d7125b5..6cdbc902 100644 --- a/wafer_space/projects/tests/test_models.py +++ b/wafer_space/projects/tests/test_models.py @@ -3181,3 +3181,29 @@ def test_create_check_drc_update_works_for_running_check(self): assert ( new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.DRC_UPDATE ) + + +@pytest.mark.django_db +class TestProjectChipOnBoard: + """Tests for the Project.chip_on_board flag.""" + + def test_defaults_to_false(self): + """chip_on_board defaults to False.""" + project = ProjectFactory() + assert project.chip_on_board is False + + def test_is_editable_after_creation(self): + """chip_on_board is a user field, not blocked by core-field immutability.""" + project = ProjectFactory() + project.chip_on_board = True + project.full_clean() # core-field immutability is enforced in clean() + project.save() + # Fetch fresh from the DB: refresh_from_db() would leave the stale + # in-memory attribute in place pre-implementation, hiding the RED. + reloaded = Project.objects.get(pk=project.pk) + assert reloaded.chip_on_board is True + + def test_is_a_user_field(self): + """chip_on_board is in USER_FIELDS and not in CORE_FIELDS.""" + assert "chip_on_board" in Project.USER_FIELDS + assert "chip_on_board" not in Project.CORE_FIELDS From 98e9f875de05e95cb7bbfe4611361a5ecb03c7f0 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 00:34:54 +0930 Subject: [PATCH 11/23] feat: add COB_CHANGE manufacturability trigger reason (#259) Extends ManufacturabilityCheck.TriggerReason with COB_CHANGE ("cob_change") to support re-triggering checks when a project's chip_on_board option changes. Co-Authored-By: Claude Fable 5 --- ...er_manufacturabilitycheck_trigger_reason.py | 18 ++++++++++++++++++ wafer_space/projects/models.py | 1 + wafer_space/projects/tests/test_models.py | 11 +++++++++++ 3 files changed, 30 insertions(+) create mode 100644 wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py diff --git a/wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py b/wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py new file mode 100644 index 00000000..e9adc1cb --- /dev/null +++ b/wafer_space/projects/migrations/0057_alter_manufacturabilitycheck_trigger_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2026-06-10 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0056_historicalproject_chip_on_board_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='manufacturabilitycheck', + name='trigger_reason', + field=models.CharField(choices=[('initial', 'Initial Check'), ('drc_update', 'DRC Rules Updated'), ('admin_rerun', 'Admin Requested Re-run'), ('retry', 'Retry After Error'), ('cob_change', 'Chip-on-Board Option Changed')], default='initial', help_text='Why this check was triggered', max_length=20), + ), + ] diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py index c0a3b5f4..66b3475f 100644 --- a/wafer_space/projects/models.py +++ b/wafer_space/projects/models.py @@ -1562,6 +1562,7 @@ class TriggerReason(models.TextChoices): DRC_UPDATE = "drc_update", "DRC Rules Updated" ADMIN_RERUN = "admin_rerun", "Admin Requested Re-run" RETRY = "retry", "Retry After Error" + COB_CHANGE = "cob_change", "Chip-on-Board Option Changed" class FinishedStatus(models.TextChoices): """Sub-status for FINISHED checks indicating manufacturability result.""" diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py index 6cdbc902..6adbe0f2 100644 --- a/wafer_space/projects/tests/test_models.py +++ b/wafer_space/projects/tests/test_models.py @@ -2849,6 +2849,17 @@ def test_trigger_reason_can_be_set(self): assert check.trigger_reason == ManufacturabilityCheck.TriggerReason.DRC_UPDATE +@pytest.mark.django_db +class TestCobChangeTriggerReason: + """Tests for the COB_CHANGE trigger reason.""" + + def test_cob_change_choice_exists(self): + """COB_CHANGE is a valid TriggerReason.""" + reason = ManufacturabilityCheck.TriggerReason.COB_CHANGE + assert reason.value == "cob_change" + assert reason.label == "Chip-on-Board Option Changed" + + @pytest.mark.django_db class TestProjectCoreFieldImmutability: """Tests for Project core field immutability validation. From 7280b5ef6f6fc57619b1c8d4c4e6bcaf39e0fe56 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 00:44:22 +0930 Subject: [PATCH 12/23] docs: extend plan Task 7 to cover trigger-reason badge chains Task 2's quality review found that _file_display.html's trigger-reason badge chains have no else fallback (a COB_CHANGE check would render no badge) and manufacturability_check_status.html falls back to a generic label. Add cob_change branches to all four chains plus a check-history badge test. Co-Authored-By: Claude Fable 5 --- .../2026-06-10-chip-on-board-packaging.md | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md index 38040a14..147e7431 100644 --- a/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md +++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md @@ -589,10 +589,12 @@ git commit -m "feat: re-run manufacturability check when CoB toggled (#259)" --- -### Task 7: CoB badge on the project detail page +### Task 7: CoB badges (project detail + trigger-reason chains) **Files:** - Modify: `wafer_space/templates/projects/project_detail.html` (after the Visibility block, ~line 128) +- Modify: `wafer_space/templates/projects/_file_display.html` (trigger-reason badge chains at ~lines 154-160 and ~188-196 — these have NO `else`, so a COB_CHANGE check currently renders no badge) +- Modify: `wafer_space/templates/projects/manufacturability_check_status.html` (chains at ~lines 56-66 and ~116-126 — these fall back to a generic light badge) - Test: `wafer_space/projects/tests/test_views.py` (detail-view test class) - [ ] **Step 1: Write the failing tests** @@ -643,17 +645,44 @@ In `project_detail.html`, after the Visibility `

` (line ~128), matching the

``` -- [ ] **Step 4: Run tests to verify they pass** +- [ ] **Step 4: Add `cob_change` to the trigger-reason badge chains** -Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob_badge or standard_packaging"` -Expected: 2 PASSED. (`make lint` runs djlint over templates — fix any template lint it reports.) +A COB_CHANGE check must get a styled badge everywhere sibling trigger reasons +do (`bg-dark` is unused in these chains). Add to all four if/elif chains: -- [ ] **Step 5: Pre-commit gate + commit** +In `_file_display.html` — chain at ~154-160 (after the `admin_rerun` elif) and +chain at ~188-196 (same position; this second chain uses `hist_check`): + +```html + {% elif check.trigger_reason == 'cob_change' %} + CoB Change +``` + +In `manufacturability_check_status.html` — both chains (~56-66 and ~116-126), +before the `{% else %}` fallback: + +```html + {% elif check.trigger_reason == 'cob_change' %} + CoB Change +``` + +Then extend the detail-page test class with a check-history badge test +(create the check via `_make_submitted_check`-style setup or factories with +`trigger_reason=ManufacturabilityCheck.TriggerReason.COB_CHANGE` on the +project's submitted file, GET the detail page, assert "CoB Change" in the +content). Write this test FIRST and watch it fail, like the others. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest wafer_space/projects/tests/test_views.py -v -k "cob"` +Expected: all new tests PASS. (`make lint` runs djlint over templates — fix any template lint it reports.) + +- [ ] **Step 6: Pre-commit gate + commit** ```bash make lint-fix && make lint && make type-check && make test -git add wafer_space/templates/projects/project_detail.html wafer_space/projects/tests/test_views.py -git commit -m "feat: show CoB packaging badge on project detail (#259)" +git add wafer_space/templates/projects/project_detail.html wafer_space/templates/projects/_file_display.html wafer_space/templates/projects/manufacturability_check_status.html wafer_space/projects/tests/test_views.py +git commit -m "feat: show CoB packaging badges (#259)" ``` --- From 04806a2892855c249cb6b6e7895a7a43afc27312 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 00:50:17 +0930 Subject: [PATCH 13/23] feat: add create_check_cob_change model method (#259) Add ManufacturabilityCheck.create_check_cob_change() which creates a PENDING check with TriggerReason.COB_CHANGE, explicitly cancels any in-progress source check (unlike DRC update which relies on superseded- check cleanup), and guards against being called on a non-latest check. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/models.py | 29 +++++++++++ wafer_space/projects/tests/test_models.py | 60 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py index 66b3475f..fdd1dc5c 100644 --- a/wafer_space/projects/models.py +++ b/wafer_space/projects/models.py @@ -2313,6 +2313,35 @@ def create_check_drc_update(self) -> "ManufacturabilityCheck": parent_check=self, ) + def create_check_cob_change(self) -> "ManufacturabilityCheck": + """Create a new pending check after the project's CoB option changed. + + Unlike ``create_check_drc_update`` — which leaves an in-progress check + to the scheduled superseded-check cleanup — this cancels an in-progress + check itself, so the superseded check can never FINISH with a result + computed from the old CoB setting. + + Returns: + The newly created ManufacturabilityCheck. + + Raises: + ValueError: If this check is not the latest check for its file. + """ + latest = self.project_file.latest_manufacturability_check + if latest != self: + msg = "Can only create CoB change check from the latest check for a file" + raise ValueError(msg) + + if self.is_cancellable: + self.mark_cancelling(reason="Chip-on-Board option changed") + + return ManufacturabilityCheck.objects.create( + project=self.project, + project_file=self.project_file, + trigger_reason=self.TriggerReason.COB_CHANGE, + parent_check=self, + ) + @property def queue_wait_seconds(self) -> float | None: """Time spent waiting in queue before running (in seconds). diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py index 6adbe0f2..c54e0a34 100644 --- a/wafer_space/projects/tests/test_models.py +++ b/wafer_space/projects/tests/test_models.py @@ -3194,6 +3194,66 @@ def test_create_check_drc_update_works_for_running_check(self): ) +@pytest.mark.django_db +class TestCreateCheckCobChange: + """Tests for ManufacturabilityCheck.create_check_cob_change().""" + + def test_creates_pending_cob_change_check(self): + """Creates a PENDING check with COB_CHANGE reason chained to the source.""" + old_check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.FINISHED, + ) + + new_check = old_check.create_check_cob_change() + + assert new_check.project == old_check.project + assert new_check.project_file == old_check.project_file + assert ( + new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.COB_CHANGE + ) + assert new_check.parent_check == old_check + assert new_check.status == ManufacturabilityCheck.Status.PENDING + + def test_finished_source_check_is_not_cancelled(self): + """A FINISHED source check keeps its status (nothing to cancel).""" + old_check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.FINISHED, + ) + + old_check.create_check_cob_change() + + old_check.refresh_from_db() + assert old_check.status == ManufacturabilityCheck.Status.FINISHED + + def test_in_progress_source_check_is_marked_cancelling(self): + """A RUNNING source check is explicitly marked CANCELLING.""" + running_check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.RUNNING, + ) + + new_check = running_check.create_check_cob_change() + + running_check.refresh_from_db() + assert running_check.status == ManufacturabilityCheck.Status.CANCELLING + assert "Chip-on-Board option changed" in running_check.processing_logs + assert new_check.status == ManufacturabilityCheck.Status.PENDING + + def test_raises_when_not_latest_check(self): + """Refuses to run on a check that is not the file's latest.""" + project_file = ProjectFileFactory() + old_check = ManufacturabilityCheckFactory( + project_file=project_file, + status=ManufacturabilityCheck.Status.FINISHED, + ) + ManufacturabilityCheckFactory( + project_file=project_file, + status=ManufacturabilityCheck.Status.FINISHED, + ) + + with pytest.raises(ValueError, match="latest check"): + old_check.create_check_cob_change() + + @pytest.mark.django_db class TestProjectChipOnBoard: """Tests for the Project.chip_on_board flag.""" From ac9e26549818b0ec6d0e8c4f4d3273e0820005b6 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:00:36 +0930 Subject: [PATCH 14/23] feat: pass --cob to precheck when chip_on_board is set (#259) Append --cob to the Docker precheck command in do_starting when project.chip_on_board is True; add TDD test confirming the flag is present (cob=True) and absent (cob=False via existing regression test). Co-Authored-By: Claude Fable 5 --- wafer_space/projects/tasks_checks.py | 2 + wafer_space/projects/tests/test_tasks.py | 76 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/wafer_space/projects/tasks_checks.py b/wafer_space/projects/tasks_checks.py index 1d1c5949..b08fa09f 100644 --- a/wafer_space/projects/tasks_checks.py +++ b/wafer_space/projects/tasks_checks.py @@ -1081,6 +1081,8 @@ def do_starting(check: ManufacturabilityCheck) -> dict[str, Any]: "--id", full_id, ] + if check.project.chip_on_board: + command.append("--cob") command_str = " ".join(command) logger.info("[do_starting] Container command: %s", command_str) diff --git a/wafer_space/projects/tests/test_tasks.py b/wafer_space/projects/tests/test_tasks.py index 92dc2d55..e5fc9c02 100644 --- a/wafer_space/projects/tests/test_tasks.py +++ b/wafer_space/projects/tests/test_tasks.py @@ -1353,6 +1353,82 @@ def test_creates_and_starts_container(self, tmp_path, settings) -> None: ) assert check.docker_command == expected_cmd + @pytest.mark.django_db + def test_command_includes_cob_flag_when_requested(self, tmp_path, settings) -> None: + """--cob is appended after --id when project.chip_on_board is True.""" + settings.DOCKER_SERVERS = [ + { + "id": "test-local", + "url": "unix:///test.sock", + "max_concurrent": 4, + "priority": 1, + }, + ] + + test_file = tmp_path / "design.gds" + test_file.write_bytes(b"test gds content") + + shuttle = ShuttleFactory(name="G850") + + check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.STARTING, + docker_server_id="test-local", + docker_image="ghcr.io/test:latest", + project__shuttle=shuttle, + project__project_id="ABCD", + project__chip_on_board=True, + ) + check.project_file.file.name = str(test_file) + check.project_file.top_cell = "chip_top" + check.project_file.save() + + ManufacturabilityCheckTask.objects.create( + manufacturability_check=check, task_id="test", task_name="do_starting" + ) + + mock_docker_path = "wafer_space.projects.tasks_checks.get_docker_client" + mock_tar_path = "wafer_space.projects.tasks_checks.create_tar_archive" + with ( + patch(mock_docker_path) as mock_get_docker_client, + patch(mock_tar_path) as mock_create_tar, + patch("wafer_space.projects.tasks_checks.Path") as mock_path, + ): + mock_client = MagicMock() + mock_get_docker_client.return_value = mock_client + mock_container = MagicMock() + mock_container.id = "container123" + mock_container.status = "running" + mock_client.containers.create.return_value = mock_container + + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = True + mock_path.return_value = mock_path_instance + + mock_tar_stream = MagicMock() + mock_create_tar.return_value.__enter__.return_value = mock_tar_stream + + result = do_starting(check.id) + + assert result["status"] == "success" + + create_call = mock_client.containers.create.call_args + command = create_call.kwargs["command"] + assert command[-1] == "--cob" + assert command[:-1] == [ + "python3", + "precheck.py", + "--input", + "/input/design.gds", + "--output", + "/output/design.gds", + "--top", + "chip_top", + "--slot", + "1x1", + "--id", + "G850ABCD", + ] + @pytest.mark.django_db def test_cleans_up_task_tracking(self, tmp_path) -> None: """Deletes ManufacturabilityCheckTask when done.""" From 34243d91e057e7db54c91a0a0e6fe2f36a59d950 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:08:31 +0930 Subject: [PATCH 15/23] feat: add CoB checkbox to project form (#259) Add chip_on_board field to ProjectForm.Meta.fields/widgets/help_texts and render it in project_form.html after is_public. The field is a user field (not a CORE_FIELD) so it stays enabled for non-staff on existing projects. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/forms.py | 6 ++++++ wafer_space/projects/tests/test_forms.py | 20 +++++++++++++++++++ .../templates/projects/project_form.html | 1 + 3 files changed, 27 insertions(+) diff --git a/wafer_space/projects/forms.py b/wafer_space/projects/forms.py index b9502511..42b2bbb2 100644 --- a/wafer_space/projects/forms.py +++ b/wafer_space/projects/forms.py @@ -205,6 +205,7 @@ class Meta: "name", "description", "is_public", + "chip_on_board", "repository_url", "license_type", "other_license_spdx_id", @@ -231,6 +232,7 @@ class Meta: }, ), "is_public": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "chip_on_board": forms.CheckboxInput(attrs={"class": "form-check-input"}), "repository_url": forms.URLInput( attrs={ "class": "form-control", @@ -255,6 +257,10 @@ class Meta: "description": "Optional details about your design", "slot_size": "Select the die slot size for your design", "is_public": "Make this design publicly visible on the platform", + "chip_on_board": ( + "Run extra Chip-on-Board (CoB) compatibility checks during the" + " manufacturability precheck" + ), "repository_url": "URL to the project's source repository", "other_license_spdx_id": ( "SPDX identifier (e.g., GPL-3.0-only, LGPL-2.1-or-later)" diff --git a/wafer_space/projects/tests/test_forms.py b/wafer_space/projects/tests/test_forms.py index 40dafbb8..08de5f85 100644 --- a/wafer_space/projects/tests/test_forms.py +++ b/wafer_space/projects/tests/test_forms.py @@ -81,6 +81,26 @@ def test_form_invalid_with_empty_name(self): assert not form.is_valid() assert "name" in form.errors + def test_chip_on_board_field_present_and_optional(self): + """chip_on_board is on the form, optional, and defaults to False.""" + form = ProjectForm() + assert "chip_on_board" in form.fields + assert form.fields["chip_on_board"].required is False + + def test_chip_on_board_editable_for_non_staff_on_existing_project(self): + """chip_on_board is a user field — never disabled on edit.""" + user = User.objects.create_user( + username="formuser", email="form@example.com", password=TEST_PASSWORD + ) + project = Project.objects.create( + user=user, + name="Form Project", + shuttle=self.shuttle, + project_id="FRMP", + ) + form = ProjectForm(user=user, instance=project) + assert form.fields["chip_on_board"].disabled is False + def test_form_saves_correctly(self): """Test form saves project correctly.""" user = User.objects.create_user( diff --git a/wafer_space/templates/projects/project_form.html b/wafer_space/templates/projects/project_form.html index c220649a..f47218ed 100644 --- a/wafer_space/templates/projects/project_form.html +++ b/wafer_space/templates/projects/project_form.html @@ -63,6 +63,7 @@
Project Details
{{ form.name|as_crispy_field }} {{ form.description|as_crispy_field }} {{ form.is_public|as_crispy_field }} + {{ form.chip_on_board|as_crispy_field }} {{ form.repository_url|as_crispy_field }} {{ form.license_type|as_crispy_field }} {{ form.other_license_spdx_id|as_crispy_field }} From 804845a08168b9232026ee99a58ade5cc540e81f Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:15:54 +0930 Subject: [PATCH 16/23] refactor: inherit CoB help text from model field Quality review polish: drop the Meta.help_texts duplicate (ModelForm inherits the model field's help_text) and trim an overstated test docstring. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/forms.py | 5 +---- wafer_space/projects/tests/test_forms.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/wafer_space/projects/forms.py b/wafer_space/projects/forms.py index 42b2bbb2..603e8ec8 100644 --- a/wafer_space/projects/forms.py +++ b/wafer_space/projects/forms.py @@ -257,10 +257,7 @@ class Meta: "description": "Optional details about your design", "slot_size": "Select the die slot size for your design", "is_public": "Make this design publicly visible on the platform", - "chip_on_board": ( - "Run extra Chip-on-Board (CoB) compatibility checks during the" - " manufacturability precheck" - ), + # chip_on_board: help_text inherited from the model field "repository_url": "URL to the project's source repository", "other_license_spdx_id": ( "SPDX identifier (e.g., GPL-3.0-only, LGPL-2.1-or-later)" diff --git a/wafer_space/projects/tests/test_forms.py b/wafer_space/projects/tests/test_forms.py index 08de5f85..6ddadfa7 100644 --- a/wafer_space/projects/tests/test_forms.py +++ b/wafer_space/projects/tests/test_forms.py @@ -82,7 +82,7 @@ def test_form_invalid_with_empty_name(self): assert "name" in form.errors def test_chip_on_board_field_present_and_optional(self): - """chip_on_board is on the form, optional, and defaults to False.""" + """chip_on_board is on the form and optional.""" form = ProjectForm() assert "chip_on_board" in form.fields assert form.fields["chip_on_board"].required is False From f23c5b4a46bf59b200e528993082c1f2bfcc8d38 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:21:05 +0930 Subject: [PATCH 17/23] feat: re-run manufacturability check when CoB toggled (#259) Add form_valid override on ProjectUpdateView that detects chip_on_board changes via form.changed_data and calls create_check_cob_change() on the latest check, cancelling any in-progress check and queuing a new PENDING COB_CHANGE check for the scheduled dispatcher to pick up. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/tests/test_views.py | 84 ++++++++++++++++++++++++ wafer_space/projects/views.py | 12 +++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index c6c9d4d6..9b97070c 100644 --- a/wafer_space/projects/tests/test_views.py +++ b/wafer_space/projects/tests/test_views.py @@ -380,6 +380,90 @@ def test_non_owner_cannot_update(self): # Should return 403 Forbidden assert response.status_code == HTTP_FORBIDDEN + def _cob_form_data(self, *, chip_on_board: bool) -> dict: + """Valid update-form payload toggling only chip_on_board.""" + data = { + "name": "Test Project", + "description": "Test project", + "repository_url": "", + "license_type": "proprietary", + "other_license_spdx_id": "", + "proprietary_terms_url": "", + } + if chip_on_board: + data["chip_on_board"] = "on" + return data + + def _make_submitted_check(self, status): + """Attach a submitted file with a check to self.project.""" + project_file = ProjectFileFactory(project=self.project) + self.project.submitted_file = project_file + self.project.save() + return ManufacturabilityCheckFactory( + project=self.project, + project_file=project_file, + status=status, + ) + + def test_toggling_cob_creates_cob_change_check(self): + """Enabling CoB on a project with a check creates one COB_CHANGE check.""" + check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + response = self.client.post(url, self._cob_form_data(chip_on_board=True)) + + assert response.status_code == HTTP_FOUND + self.project.refresh_from_db() + assert self.project.chip_on_board is True + checks = ManufacturabilityCheck.objects.filter( + project_file=check.project_file + ).order_by("created_at") + assert checks.count() == len([check, "new"]) # exactly one new check + new_check = checks.last() + assert new_check is not None + assert ( + new_check.trigger_reason == ManufacturabilityCheck.TriggerReason.COB_CHANGE + ) + assert new_check.parent_check == check + + def test_toggling_cob_cancels_in_progress_check(self): + """Enabling CoB while a check is RUNNING marks it CANCELLING.""" + check = self._make_submitted_check(ManufacturabilityCheck.Status.RUNNING) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.post(url, self._cob_form_data(chip_on_board=True)) + + check.refresh_from_db() + assert check.status == ManufacturabilityCheck.Status.CANCELLING + + def test_toggling_cob_on_draft_only_persists(self): + """No submitted file/check: the flag is saved, no check is created.""" + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.post(url, self._cob_form_data(chip_on_board=True)) + + self.project.refresh_from_db() + assert self.project.chip_on_board is True + assert ManufacturabilityCheck.objects.filter(project=self.project).count() == 0 + + def test_unchanged_cob_creates_no_check(self): + """Submitting the form with CoB unchanged creates no new check.""" + check = self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.post(url, self._cob_form_data(chip_on_board=False)) + + assert ( + ManufacturabilityCheck.objects.filter( + project_file=check.project_file + ).count() + == 1 + ) + @pytest.mark.django_db class TestProjectDeleteView(TestCase): diff --git a/wafer_space/projects/views.py b/wafer_space/projects/views.py index 944bc8f4..8cc04a25 100644 --- a/wafer_space/projects/views.py +++ b/wafer_space/projects/views.py @@ -250,12 +250,20 @@ def get_context_data(self, **kwargs): return context def form_valid(self, form): - """Show success message.""" + """Save, then re-run the manufacturability check if CoB changed.""" + cob_changed = "chip_on_board" in form.changed_data + response = super().form_valid(form) + + if cob_changed: + latest_check = self.object.latest_manufacturability_check + if latest_check is not None: + latest_check.create_check_cob_change() + messages.success( self.request, f"Project '{form.instance.name}' updated successfully!", ) - return super().form_valid(form) + return response def get_success_url(self): """Redirect to project detail page.""" From 6ee5ab8ce6dbfce07e35b213e248e93f92d27927 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:29:51 +0930 Subject: [PATCH 18/23] refactor: use named constant and type hints in CoB toggle tests Quality review fixes: replace the len([check, 'new']) lint-evasion idiom with EXPECTED_CHECKS_AFTER_COB_TOGGLE in tests/constants.py, annotate the _make_submitted_check helper, and derive the form payload's name/description from setUp's project instead of duplicating the strings. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/tests/constants.py | 1 + wafer_space/projects/tests/test_views.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/wafer_space/projects/tests/constants.py b/wafer_space/projects/tests/constants.py index 6475bcea..028af127 100644 --- a/wafer_space/projects/tests/constants.py +++ b/wafer_space/projects/tests/constants.py @@ -27,6 +27,7 @@ # Test counts EXPECTED_IP_RANGE_COUNT = 8 EXPECTED_USER_PROJECTS = 2 # Number of projects created for test user +EXPECTED_CHECKS_AFTER_COB_TOGGLE = 2 # original check + COB_CHANGE re-check # Worker tracking test values TEST_WORKER_PID = 12345 diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index 9b97070c..b9b763d2 100644 --- a/wafer_space/projects/tests/test_views.py +++ b/wafer_space/projects/tests/test_views.py @@ -26,6 +26,7 @@ from wafer_space.users.models import User from wafer_space.users.tests.factories import UserFactory +from .constants import EXPECTED_CHECKS_AFTER_COB_TOGGLE from .constants import EXPECTED_USER_PROJECTS from .constants import FIVE_MB from .constants import HTTP_FORBIDDEN @@ -380,11 +381,11 @@ def test_non_owner_cannot_update(self): # Should return 403 Forbidden assert response.status_code == HTTP_FORBIDDEN - def _cob_form_data(self, *, chip_on_board: bool) -> dict: + def _cob_form_data(self, *, chip_on_board: bool) -> dict[str, str]: """Valid update-form payload toggling only chip_on_board.""" data = { - "name": "Test Project", - "description": "Test project", + "name": self.project.name, + "description": self.project.description, "repository_url": "", "license_type": "proprietary", "other_license_spdx_id": "", @@ -394,7 +395,9 @@ def _cob_form_data(self, *, chip_on_board: bool) -> dict: data["chip_on_board"] = "on" return data - def _make_submitted_check(self, status): + def _make_submitted_check( + self, status: ManufacturabilityCheck.Status + ) -> ManufacturabilityCheck: """Attach a submitted file with a check to self.project.""" project_file = ProjectFileFactory(project=self.project) self.project.submitted_file = project_file @@ -419,7 +422,7 @@ def test_toggling_cob_creates_cob_change_check(self): checks = ManufacturabilityCheck.objects.filter( project_file=check.project_file ).order_by("created_at") - assert checks.count() == len([check, "new"]) # exactly one new check + assert checks.count() == EXPECTED_CHECKS_AFTER_COB_TOGGLE new_check = checks.last() assert new_check is not None assert ( From 264022c7738a7a770419bbf6693aea98d25f9f58 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:35:50 +0930 Subject: [PATCH 19/23] feat: show CoB packaging badges (#259) Add Chip-on-Board badge to project detail page and CoB Change trigger-reason badges to all four if/elif chains in _file_display.html and manufacturability_check_status.html. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/tests/test_views.py | 41 +++++++++++++++++++ .../templates/projects/_file_display.html | 4 ++ .../manufacturability_check_status.html | 4 ++ .../templates/projects/project_detail.html | 8 ++++ 4 files changed, 57 insertions(+) diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index b9b763d2..18cd1ae0 100644 --- a/wafer_space/projects/tests/test_views.py +++ b/wafer_space/projects/tests/test_views.py @@ -180,6 +180,47 @@ def test_project_detail_staff_access(self): assert response.context["project"] == self.project assert response.context["viewing_as_admin"] is True + def test_detail_shows_cob_badge_when_requested(self): + """Detail page shows the CoB badge when chip_on_board is set.""" + self.project.chip_on_board = True + self.project.save() + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:detail", kwargs={"pk": self.project.pk}) + + response = self.client.get(url) + + assert response.status_code == HTTP_OK + assert "Chip-on-Board" in response.content.decode() + + def test_detail_shows_standard_packaging_when_not_requested(self): + """Detail page shows standard packaging when chip_on_board is unset.""" + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:detail", kwargs={"pk": self.project.pk}) + + response = self.client.get(url) + + assert response.status_code == HTTP_OK + assert "Chip-on-Board" not in response.content.decode() + + def test_detail_shows_cob_change_badge_in_check_history(self): + """Detail page shows CoB Change badge for COB_CHANGE trigger in history.""" + project_file = ProjectFileFactory(project=self.project) + self.project.submitted_file = project_file + self.project.save() + ManufacturabilityCheckFactory( + project=self.project, + project_file=project_file, + status=ManufacturabilityCheck.Status.FINISHED, + trigger_reason=ManufacturabilityCheck.TriggerReason.COB_CHANGE, + ) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:detail", kwargs={"pk": self.project.pk}) + + response = self.client.get(url) + + assert response.status_code == HTTP_OK + assert "CoB Change" in response.content.decode() + @pytest.mark.django_db class TestProjectCreateView(TestCase): diff --git a/wafer_space/templates/projects/_file_display.html b/wafer_space/templates/projects/_file_display.html index c0bcb19b..322b420c 100644 --- a/wafer_space/templates/projects/_file_display.html +++ b/wafer_space/templates/projects/_file_display.html @@ -157,6 +157,8 @@
File Hashes
DRC Update {% elif check.trigger_reason == 'admin_rerun' %} Admin Re-run + {% elif check.trigger_reason == 'cob_change' %} + CoB Change {% endif %} {% badge_check_status_and_version check %} @@ -193,6 +195,8 @@
DRC Update {% elif hist_check.trigger_reason == 'admin_rerun' %} Admin Re-run + {% elif hist_check.trigger_reason == 'cob_change' %} + CoB Change {% endif %} {% badge_check_status_and_version hist_check %} diff --git a/wafer_space/templates/projects/manufacturability_check_status.html b/wafer_space/templates/projects/manufacturability_check_status.html index 8293f56c..8bad692b 100644 --- a/wafer_space/templates/projects/manufacturability_check_status.html +++ b/wafer_space/templates/projects/manufacturability_check_status.html @@ -61,6 +61,8 @@
DRC Update {% elif check.trigger_reason == 'admin_rerun' %} Admin + {% elif check.trigger_reason == 'cob_change' %} + CoB Change {% else %} {{ check.get_trigger_reason_display }} {% endif %} @@ -121,6 +123,8 @@
DRC Update {% elif check.trigger_reason == 'admin_rerun' %} Admin + {% elif check.trigger_reason == 'cob_change' %} + CoB Change {% else %} {{ check.get_trigger_reason_display }} {% endif %} diff --git a/wafer_space/templates/projects/project_detail.html b/wafer_space/templates/projects/project_detail.html index 1d5c9a58..4a0157bd 100644 --- a/wafer_space/templates/projects/project_detail.html +++ b/wafer_space/templates/projects/project_detail.html @@ -126,6 +126,14 @@
Project Details
Private {% endif %}

+

+ Packaging: + {% if project.chip_on_board %} + Chip-on-Board (CoB) + {% else %} + Standard + {% endif %} +

{% if project.repository_url %}

Repository: From 7ca1322b79caae4d556529807a71802ee7c27261 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 11 Jun 2026 01:43:14 +0930 Subject: [PATCH 20/23] test: tighten CoB badge assertions Quality review polish: assert the unambiguous 'Chip-on-Board (CoB)' string in the positive test, and make the standard-packaging test assert the Packaging block and Standard badge actually render rather than only absence. Co-Authored-By: Claude Fable 5 --- wafer_space/projects/tests/test_views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index 18cd1ae0..ab9d8840 100644 --- a/wafer_space/projects/tests/test_views.py +++ b/wafer_space/projects/tests/test_views.py @@ -190,7 +190,7 @@ def test_detail_shows_cob_badge_when_requested(self): response = self.client.get(url) assert response.status_code == HTTP_OK - assert "Chip-on-Board" in response.content.decode() + assert "Chip-on-Board (CoB)" in response.content.decode() def test_detail_shows_standard_packaging_when_not_requested(self): """Detail page shows standard packaging when chip_on_board is unset.""" @@ -200,7 +200,10 @@ def test_detail_shows_standard_packaging_when_not_requested(self): response = self.client.get(url) assert response.status_code == HTTP_OK - assert "Chip-on-Board" not in response.content.decode() + content = response.content.decode() + assert "Chip-on-Board" not in content + assert "Packaging:" in content + assert "Standard" in content def test_detail_shows_cob_change_badge_in_check_history(self): """Detail page shows CoB Change badge for COB_CHANGE trigger in history.""" From aefbe51b447204598f22e1c91467639f45863b8d Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 18 Jun 2026 15:29:20 +0930 Subject: [PATCH 21/23] fix: lock source check row in create_check_cob_change Addresses a CodeRabbit finding on PR #265: "latest" and is_cancellable were read off an unlocked in-memory instance, so a concurrent transition could (a) clobber a freshly FINISHED check back to CANCELLING from a stale in-memory status, or (b) let two simultaneous CoB toggles each create a COB_CHANGE check. Wrap the body in transaction.atomic() and re-read the row with select_for_update() before any decision, so the choice is made against committed state and concurrent toggles serialize on the row lock. The new test reproduces the FINISHED-clobber deterministically (no threads): it updates the row out-of-band, then asserts the locked re-read leaves the committed FINISHED status untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M1FsYafhYvbwqWVCkGDPYJ --- wafer_space/projects/models.py | 39 ++++++++++++++++------- wafer_space/projects/tests/test_models.py | 22 +++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/wafer_space/projects/models.py b/wafer_space/projects/models.py index fdd1dc5c..0778ebd6 100644 --- a/wafer_space/projects/models.py +++ b/wafer_space/projects/models.py @@ -13,6 +13,7 @@ from django.core.exceptions import ValidationError from django.core.validators import FileExtensionValidator from django.db import models +from django.db import transaction from django.utils import timezone from django.utils.formats import date_format from simple_history.models import HistoricalRecords @@ -2321,26 +2322,40 @@ def create_check_cob_change(self) -> "ManufacturabilityCheck": check itself, so the superseded check can never FINISH with a result computed from the old CoB setting. + Concurrency: the source row is locked with ``select_for_update`` and + re-read inside a transaction before any decision, so two simultaneous + toggles cannot both create a COB_CHANGE check, and a check that finished + in another worker between page load and submit is observed as FINISHED + (and left alone) rather than clobbered back to CANCELLING from a stale + in-memory status. + Returns: The newly created ManufacturabilityCheck. Raises: ValueError: If this check is not the latest check for its file. """ - latest = self.project_file.latest_manufacturability_check - if latest != self: - msg = "Can only create CoB change check from the latest check for a file" - raise ValueError(msg) + with transaction.atomic(): + locked_self = ManufacturabilityCheck.objects.select_for_update().get( + pk=self.pk + ) - if self.is_cancellable: - self.mark_cancelling(reason="Chip-on-Board option changed") + latest = locked_self.project_file.latest_manufacturability_check + if latest != locked_self: + msg = ( + "Can only create CoB change check from the latest check for a file" + ) + raise ValueError(msg) - return ManufacturabilityCheck.objects.create( - project=self.project, - project_file=self.project_file, - trigger_reason=self.TriggerReason.COB_CHANGE, - parent_check=self, - ) + if locked_self.is_cancellable: + locked_self.mark_cancelling(reason="Chip-on-Board option changed") + + return ManufacturabilityCheck.objects.create( + project=locked_self.project, + project_file=locked_self.project_file, + trigger_reason=self.TriggerReason.COB_CHANGE, + parent_check=locked_self, + ) @property def queue_wait_seconds(self) -> float | None: diff --git a/wafer_space/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py index c54e0a34..59a17db2 100644 --- a/wafer_space/projects/tests/test_models.py +++ b/wafer_space/projects/tests/test_models.py @@ -3253,6 +3253,28 @@ def test_raises_when_not_latest_check(self): with pytest.raises(ValueError, match="latest check"): old_check.create_check_cob_change() + def test_concurrent_finish_is_not_clobbered_by_stale_cancel(self): + """A concurrent FINISH must not be overwritten from a stale source check. + + Reproduces the TOCTOU race: the source check is held in memory while + RUNNING, but another worker transitions it to FINISHED in the database + before the CoB re-check runs. The re-check must observe the committed + FINISHED status via a locked re-read and leave it untouched, rather than + clobbering it back to CANCELLING from the stale in-memory RUNNING value. + """ + check = ManufacturabilityCheckFactory( + status=ManufacturabilityCheck.Status.RUNNING, + ) + # Concurrent worker finishes the check; the in-memory object is unaware. + ManufacturabilityCheck.objects.filter(pk=check.pk).update( + status=ManufacturabilityCheck.Status.FINISHED, + ) + + check.create_check_cob_change() + + check.refresh_from_db() + assert check.status == ManufacturabilityCheck.Status.FINISHED + @pytest.mark.django_db class TestProjectChipOnBoard: From ae7ec14e7cd7b65afa47a759d18d91ccf02dd40d Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Thu, 18 Jun 2026 15:29:26 +0930 Subject: [PATCH 22/23] fix: don't 500 when CoB re-check creation fails post-save Addresses a CodeRabbit finding on PR #265: create_check_cob_change() can raise ValueError when the source check is no longer its file's latest. Because it runs after super().form_valid() has already saved the project, and ATOMIC_REQUESTS wraps the whole request in one transaction, the raise rolled the request back into a 500 and discarded the user's valid CoB edit. Catch the recoverable ValueError, log a warning, and let the request finish normally. The model's transaction.atomic() block means the inner savepoint rolls back cleanly, leaving the committed project save intact; the newer check that superseded this one picks up the new flag on its own. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M1FsYafhYvbwqWVCkGDPYJ --- wafer_space/projects/tests/test_views.py | 25 ++++++++++++++++++++++++ wafer_space/projects/views.py | 16 ++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index ab9d8840..e533e995 100644 --- a/wafer_space/projects/tests/test_views.py +++ b/wafer_space/projects/tests/test_views.py @@ -511,6 +511,31 @@ def test_unchanged_cob_creates_no_check(self): == 1 ) + def test_cob_recheck_failure_does_not_500_or_lose_edit(self): + """A ValueError from the re-check must not 500 or revert the saved edit. + + Under ATOMIC_REQUESTS the whole request shares one transaction, so an + exception raised by create_check_cob_change *after* the project has been + saved would otherwise roll the request back into a 500 and discard the + user's valid CoB change. The view must swallow the recoverable error and + still redirect with the edit persisted. + """ + self._make_submitted_check(ManufacturabilityCheck.Status.FINISHED) + self.client.login(username="testuser", password=TEST_PASSWORD) + url = reverse("projects:update", kwargs={"pk": self.project.pk}) + + self.client.raise_request_exception = False + with patch.object( + ManufacturabilityCheck, + "create_check_cob_change", + side_effect=ValueError("source check is no longer the latest"), + ): + response = self.client.post(url, self._cob_form_data(chip_on_board=True)) + + assert response.status_code == HTTP_FOUND + self.project.refresh_from_db() + assert self.project.chip_on_board is True + @pytest.mark.django_db class TestProjectDeleteView(TestCase): diff --git a/wafer_space/projects/views.py b/wafer_space/projects/views.py index 8cc04a25..ad367a36 100644 --- a/wafer_space/projects/views.py +++ b/wafer_space/projects/views.py @@ -257,7 +257,21 @@ def form_valid(self, form): if cob_changed: latest_check = self.object.latest_manufacturability_check if latest_check is not None: - latest_check.create_check_cob_change() + try: + latest_check.create_check_cob_change() + except ValueError: + # The source check stopped being its file's latest between + # page load and submit (e.g. a concurrent re-check). The CoB + # change is already saved and valid, and the newer check will + # pick up the new flag on its own, so log and carry on rather + # than 500 the whole (already-committed) request. + logger.warning( + "CoB re-check skipped for project %s: source check %s is " + "no longer the latest for its file.", + self.object.pk, + latest_check.pk, + exc_info=True, + ) messages.success( self.request, From ebc828abe1c42ed2323d8945a368143e6f7889b7 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Sun, 21 Jun 2026 03:10:34 +0930 Subject: [PATCH 23/23] fix: label non-CoB packaging as "Bare Die" instead of "Standard" The project detail page showed non-Chip-on-Board projects as "Standard" packaging. The correct term for an unpackaged die is "Bare Die", so rename the badge label and update the detail-view test accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M1FsYafhYvbwqWVCkGDPYJ --- wafer_space/projects/tests/test_views.py | 6 +++--- wafer_space/templates/projects/project_detail.html | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index e533e995..20b2cf12 100644 --- a/wafer_space/projects/tests/test_views.py +++ b/wafer_space/projects/tests/test_views.py @@ -192,8 +192,8 @@ def test_detail_shows_cob_badge_when_requested(self): assert response.status_code == HTTP_OK assert "Chip-on-Board (CoB)" in response.content.decode() - def test_detail_shows_standard_packaging_when_not_requested(self): - """Detail page shows standard packaging when chip_on_board is unset.""" + def test_detail_shows_bare_die_packaging_when_not_requested(self): + """Detail page labels non-CoB packaging as 'Bare Die'.""" self.client.login(username="testuser", password=TEST_PASSWORD) url = reverse("projects:detail", kwargs={"pk": self.project.pk}) @@ -203,7 +203,7 @@ def test_detail_shows_standard_packaging_when_not_requested(self): content = response.content.decode() assert "Chip-on-Board" not in content assert "Packaging:" in content - assert "Standard" in content + assert "Bare Die" in content def test_detail_shows_cob_change_badge_in_check_history(self): """Detail page shows CoB Change badge for COB_CHANGE trigger in history.""" diff --git a/wafer_space/templates/projects/project_detail.html b/wafer_space/templates/projects/project_detail.html index 4a0157bd..6cc30feb 100644 --- a/wafer_space/templates/projects/project_detail.html +++ b/wafer_space/templates/projects/project_detail.html @@ -131,7 +131,7 @@

Project Details
{% if project.chip_on_board %} Chip-on-Board (CoB) {% else %} - Standard + Bare Die {% endif %}

{% if project.repository_url %}