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..147e7431 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-chip-on-board-packaging.md @@ -0,0 +1,708 @@ +# 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() + # 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 +``` + +- [ ] **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` (no `chip_on_board` attribute on the fresh +instances) / `AssertionError` (not in `USER_FIELDS`). + +- [ ] **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 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** + +```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`. 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() + 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 +``` + +(`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 — `AssertionError` (field not in `form.fields`) / +`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 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** + +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: Add `cob_change` to the trigger-reason badge chains** + +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: + +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/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)" +``` + +--- + +### 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. 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..abb61823 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-chip-on-board-packaging-design.md @@ -0,0 +1,210 @@ +# Chip-on-Board (CoB) Packaging Support — Design + +- **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 + +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 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 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 + +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 (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`; 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). This is pure + 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 + `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 + (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 + 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 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 + (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. 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 → 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. `create_check_cob_change()` has already marked any in-progress check as + 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`. +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 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. +- An in-progress latest check is cancelled explicitly inside + `create_check_cob_change()` via `mark_cancelling` (gated on `is_cancellable`, + 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. + +## Testing (TDD) + +- **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; 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. +- **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; the project detail page shows the + CoB badge when the flag is set (and not when unset). + +## 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. +- 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`.) diff --git a/wafer_space/projects/forms.py b/wafer_space/projects/forms.py index b9502511..603e8ec8 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,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: 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/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/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 35211aa8..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 @@ -160,6 +161,7 @@ class Status(models.TextChoices): "name", "description", "is_public", + "chip_on_board", "repository_url", "license_type", "other_license_spdx_id", @@ -243,6 +245,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, @@ -1551,6 +1563,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.""" @@ -2301,6 +2314,49 @@ 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. + + 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. + """ + with transaction.atomic(): + locked_self = ManufacturabilityCheck.objects.select_for_update().get( + pk=self.pk + ) + + 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) + + 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: """Time spent waiting in queue before running (in seconds). 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/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_forms.py b/wafer_space/projects/tests/test_forms.py index 40dafbb8..6ddadfa7 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 and optional.""" + 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/projects/tests/test_models.py b/wafer_space/projects/tests/test_models.py index 4d7125b5..59a17db2 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. @@ -3181,3 +3192,111 @@ 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 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() + + 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: + """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 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.""" diff --git a/wafer_space/projects/tests/test_views.py b/wafer_space/projects/tests/test_views.py index c6c9d4d6..20b2cf12 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 @@ -179,6 +180,50 @@ 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 (CoB)" in response.content.decode() + + 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}) + + response = self.client.get(url) + + assert response.status_code == HTTP_OK + content = response.content.decode() + assert "Chip-on-Board" not in content + assert "Packaging:" 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.""" + 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): @@ -380,6 +425,117 @@ 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[str, str]: + """Valid update-form payload toggling only chip_on_board.""" + data = { + "name": self.project.name, + "description": self.project.description, + "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: 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 + 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() == EXPECTED_CHECKS_AFTER_COB_TOGGLE + 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 + ) + + 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 944bc8f4..ad367a36 100644 --- a/wafer_space/projects/views.py +++ b/wafer_space/projects/views.py @@ -250,12 +250,34 @@ 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: + 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, f"Project '{form.instance.name}' updated successfully!", ) - return super().form_valid(form) + return response def get_success_url(self): """Redirect to project detail page.""" 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..6cc30feb 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 %} + Bare Die + {% endif %} +

{% if project.repository_url %}

Repository: 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 }}