mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +02:00
fix(inventory): malformed rgba no longer bricks the Filaments page (#1055)
A single legacy spool with a 7-char rgba ('FFFFFFF', missing one F)
caused GET /api/v1/inventory/spools to 500 with a pydantic
ResponseValidationError, leaving the reporter with a blank Filaments
page and "Add Spool" silently failing. Root cause spans three layers:
1. Write path: SpoolUpdate.rgba had no pattern constraint (only
SpoolCreate did), so PATCH could plant malformed values in the DB.
2. Frontend: ColorSection hex input's `val.length <= 6 ? 'FF' : ''`
emitted 7-char rgba for 5-char input (XXXXX + FF = 7) and for
7-char typed input (no alpha appended).
3. Read path: SpoolResponse inherited the write-side pattern, so a
single bad row 500'd the entire list endpoint instead of being
tolerated through serialize.
SpoolUpdate.rgba now carries the same ^[0-9A-Fa-f]{8}$ pattern as
SpoolCreate. The hex input emits a fully-formed 8-char RRGGBBAA on
every keystroke — 8-char paste passes through, 7-char drops the
stray, shorter input pads RGB with '0' and appends FF alpha.
SpoolResponse.rgba is now Optional[str] with no pattern — write-side
validation is the right place for format rules; responses must
tolerate historical rows.
Tests: 16 schema tests (SpoolCreate/Update reject, SpoolResponse
tolerate), 7 frontend tests covering every input length 0–8 plus
non-hex strip. A user who already has a bad row in their DB now sees
it render with a default color instead of having to hand-edit SQLite.
This commit is contained in:
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Printer Card Shows Plate Name on Multi-Plate Prints** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — When two printers were running different plates of the same multi-plate 3MF, the Printers page cards displayed the same file name on both and gave no visual way to tell them apart. The Queue view already showed the plate name by querying the archive's plate list; the Printers page didn't have that linkage. The `GET /printers/{id}/status` endpoint now returns `current_archive_id` (resolved by matching the MQTT `subtask_id` against `PrintArchive.subtask_id`, the same bridge introduced in #972 for restart-resume) and `current_plate_id` (parsed from the MQTT `gcode_file` path by a new shared `parse_plate_id` helper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the same `api.getArchivePlates()` call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a "Plate N" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previous `plate_(\d+).gcode` regex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence in `formatPrintName`. Thanks to @stringham for the follow-up and screenshot.
|
||||
|
||||
### Fixed
|
||||
- **Single Malformed `rgba` Bricks the Entire Filaments Inventory Page** ([#1055](https://github.com/maziggy/bambuddy/issues/1055)) — A user's Filaments page went blank and "Add Spool" became a no-op with no visible error. The backend was returning HTTP 500 from `GET /api/v1/inventory/spools` with `fastapi.exceptions.ResponseValidationError: rgba → 'FFFFFFF' should match pattern '^[0-9A-Fa-f]{8}$'` — a single legacy spool row had a 7-char rgba (missing one trailing `F`) and Pydantic's strict pattern on `SpoolResponse` refused to serialize the whole list because of it. Root cause spans three layers: (1) `SpoolUpdate` had no rgba pattern constraint, so PATCH calls could plant malformed values straight into the DB (`SpoolCreate` did validate, but only on initial create); (2) the `ColorSection` hex input's onChange ternary `val.length <= 6 ? 'FF' : ''` silently emitted 7-char strings for 5-char or 7-char typed input (5 chars + `FF` alpha = 7 chars; 7 chars got no alpha appended at all), which then flowed to the unvalidated PATCH endpoint; (3) `SpoolResponse` inherited the same pattern as `SpoolCreate`, so any malformed row already in the DB exploded the entire list endpoint on serialize even though write-side validation was the right place for the check. Fixed on all three layers: `SpoolUpdate.rgba` now carries the same `^[0-9A-Fa-f]{8}$` pattern as `SpoolCreate`, so PATCH requests with malformed rgba are rejected with 422 at the boundary. The hex input always emits a fully-formed 8-char RRGGBBAA on every keystroke — 8-char paste passes through, 7-char drops the stray char, shorter input is right-padded with `'0'` and given FF alpha. `SpoolResponse.rgba` is now an unconstrained `Optional[str]`: the pattern belongs on request schemas where Pydantic can reject bad input, not on responses where it turns a single bad row into a total page failure. A legacy malformed row still appears in the UI (the color just renders as whatever browser default applies) but the user can see, edit, and delete it instead of having to hand-edit SQLite. Backend tests cover all three schema contracts (16 cases across `SpoolCreate` accept/reject, `SpoolUpdate` accept/reject, `SpoolResponse` lenient-tolerance on 7-char / null / garbage). Frontend tests cover the hex-input normalization for every input length 0–8 plus non-hex strip-and-pad. Thanks to @fdsghy4a for the end-to-end debugging and for locating the exact malformed row in their DB.
|
||||
- **Printer-Card "Print" Button Leaves Transient Copy in File Manager** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The "Print" button on a printer card (and the equivalent drag-drop-onto-card flow) was silently uploading the chosen file into the Library file manager as a side effect before printing. Root cause is structural: the frontend opened `FileUploadModal` to persist the file as a `LibraryFile`, then `PrintModal` dispatched a library print through `POST /library/files/{id}/print`, which uses the LibraryFile as the source for both the archive copy and the FTP upload to the printer. When the dispatch finished, both the `LibraryFile` row and its disk file in `data/library/` were left behind, so every one-off Direct-Print accumulated an unwanted File Manager entry that the user had to find and delete manually. The other three print entry points are untouched: Archive "Reprint" never involved the library, and File Manager "Print" / Project Detail "Print" are paths where the user deliberately put the file in the library, so their entries are preserved. `POST /library/files/{id}/print` now accepts an optional `cleanup_library_after_dispatch` boolean. When true, `_run_print_library_file` stages the LibraryFile row for deletion in the same transaction as the archive insert (so a mid-flight FTP or `start_print` failure rolls back both at once, leaving no orphan), commits together, then unlinks the library disk file and thumbnail from disk after commit succeeds. External library files (`is_external = True`, pointing at user-managed folders outside Bambuddy's control) are never touched regardless of the flag. The Printers-page Direct-Print flow is the only caller that sends `true`; every other `api.printLibraryFile` call site leaves the flag unset so default-False preserves their library entries. Added two unit tests at the enqueue level (default-false + flag-propagates-true), two integration tests at the endpoint level (default-false + forwards-true + cleanup flag never leaks into the MQTT options dict), and two frontend tests on `PrintModal` guarding that `cleanupLibraryAfterDispatch` only forwards when explicitly set — so future File Manager / Project Detail entry points can't accidentally inherit the Direct-Print semantics. Thanks to @3823u44238 for flagging the surprising side effect.
|
||||
- **Direct / File Manager / Library Prints Still Unattributed to User** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The 0.2.3.1 fix (commit `f03d0c4c`) plumbed the authenticated user from `POST /library/files/{id}/print` into the background-dispatch job object, but the dispatcher itself never read it back out: `_run_print_library_file` called `ArchiveService.archive_print()` without the `created_by_id` parameter and never called `printer_manager.set_current_print_user()`. Net effect: direct prints from the printer-card "Print" button, File Manager prints, and Library prints all continued to land archives with `created_by_id = NULL` (invisible to the per-user stats filter), and the post-print email notification had no user to target. The dispatcher now forwards `job.requested_by_user_id` to the archive at creation time and registers the current-print user after `start_print` succeeds — matching the reprint path's behaviour. Reprint-from-Archive attribution is a separate bug (the reprint reuses the source archive row as-is, so a NULL `created_by_id` stays NULL) and is tracked on #730. Thanks to @3823u44238 for the thorough end-to-end retest.
|
||||
- **Spoolman Iframe Blocked by CSP on HTTP Instances** ([#1054](https://github.com/maziggy/bambuddy/issues/1054)) — The Filament tab showed a blank page with a brief Spoolman flash on reload. Browser console reported `Content-Security-Policy: The page's settings blocked the loading of a resource (frame-src) at http://<host>:7912/spool because it violates the following directive: "frame-src 'self' https:"`. Root cause: commit `53a70e37` (#995) tightened the CSP to allow external sidebar iframes but only whitelisted `https:`, overlooking that self-hosted services on LANs — Spoolman, OctoPrint, etc. — almost always run over plain HTTP. The `frame-src` directive now allows `http:` as well (`frame-src 'self' http: https:`), matching the `connect-src 'self' ws: wss:` pattern already used for WebSockets. `frame-ancestors 'none'` still prevents Bambuddy itself from being framed cross-origin. Thanks to @saint-hh for reporting.
|
||||
|
||||
@@ -41,7 +41,7 @@ class SpoolUpdate(BaseModel):
|
||||
material: str | None = None
|
||||
subtype: str | None = None
|
||||
color_name: str | None = None
|
||||
rgba: str | None = None
|
||||
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
|
||||
brand: str | None = None
|
||||
label_weight: int | None = None
|
||||
core_weight: int | None = None
|
||||
@@ -82,6 +82,11 @@ class SpoolKProfileResponse(SpoolKProfileBase):
|
||||
|
||||
class SpoolResponse(SpoolBase):
|
||||
id: int
|
||||
# rgba is intentionally unconstrained on the response side: the write paths
|
||||
# (SpoolCreate, SpoolUpdate) enforce the 8-char hex pattern, but legacy rows
|
||||
# or data sourced from AMS firmware / backups may carry malformed values.
|
||||
# A single bad row must not 500 the entire inventory list endpoint (#1055).
|
||||
rgba: str | None = None
|
||||
added_full: bool | None = None
|
||||
last_used: datetime | None = None
|
||||
encode_time: datetime | None = None
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Schema validation tests for the spool rgba field (#1055).
|
||||
|
||||
Three guarantees to lock in:
|
||||
1. SpoolCreate and SpoolUpdate must reject malformed rgba (short, long, non-hex)
|
||||
on the write path — this is the "add a check" the reporter asked for.
|
||||
2. SpoolResponse must NOT validate rgba on the read path: a single legacy row
|
||||
with a 7-char rgba (as in #1055) must not 500 the entire inventory list.
|
||||
3. Valid 8-char hex must continue to round-trip through all three schemas.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.app.schemas.spool import SpoolCreate, SpoolUpdate
|
||||
|
||||
|
||||
class TestSpoolCreateRgbaValidation:
|
||||
"""Write-path validation on the create schema."""
|
||||
|
||||
def test_accepts_valid_8char_hex(self):
|
||||
spool = SpoolCreate(material="PLA", rgba="FF00AAFF")
|
||||
assert spool.rgba == "FF00AAFF"
|
||||
|
||||
def test_accepts_lowercase_hex(self):
|
||||
spool = SpoolCreate(material="PLA", rgba="ff00aaff")
|
||||
assert spool.rgba == "ff00aaff"
|
||||
|
||||
def test_accepts_null_rgba(self):
|
||||
spool = SpoolCreate(material="PLA", rgba=None)
|
||||
assert spool.rgba is None
|
||||
|
||||
def test_rejects_7char_rgba(self):
|
||||
"""#1055 repro: a 7-char 'FFFFFFF' must not be acceptable on create."""
|
||||
with pytest.raises(ValidationError, match="rgba"):
|
||||
SpoolCreate(material="PLA", rgba="FFFFFFF")
|
||||
|
||||
def test_rejects_6char_rgba(self):
|
||||
"""Plain RRGGBB without alpha must be rejected — frontend appends FF."""
|
||||
with pytest.raises(ValidationError, match="rgba"):
|
||||
SpoolCreate(material="PLA", rgba="FF0000")
|
||||
|
||||
def test_rejects_non_hex_char(self):
|
||||
with pytest.raises(ValidationError, match="rgba"):
|
||||
SpoolCreate(material="PLA", rgba="FFZZ00FF")
|
||||
|
||||
|
||||
class TestSpoolUpdateRgbaValidation:
|
||||
"""Write-path validation on the update schema — the gap that let #1055 happen.
|
||||
|
||||
Before the fix, SpoolUpdate.rgba was a bare `str | None` so a PATCH could
|
||||
plant a 7-char value straight into the DB. That row then caused a 500 on
|
||||
the next GET because SpoolResponse enforced the pattern at serialize time.
|
||||
"""
|
||||
|
||||
def test_accepts_valid_8char_hex(self):
|
||||
update = SpoolUpdate(rgba="00FF00FF")
|
||||
assert update.rgba == "00FF00FF"
|
||||
|
||||
def test_accepts_null_rgba(self):
|
||||
update = SpoolUpdate(rgba=None)
|
||||
assert update.rgba is None
|
||||
|
||||
def test_accepts_missing_rgba(self):
|
||||
"""Partial updates — rgba not present in payload — must still be valid."""
|
||||
update = SpoolUpdate(material="PETG")
|
||||
assert update.rgba is None
|
||||
|
||||
def test_rejects_7char_rgba(self):
|
||||
"""#1055 repro: PATCH must reject the exact pattern that bricked the reporter."""
|
||||
with pytest.raises(ValidationError, match="rgba"):
|
||||
SpoolUpdate(rgba="FFFFFFF")
|
||||
|
||||
def test_rejects_9char_rgba(self):
|
||||
with pytest.raises(ValidationError, match="rgba"):
|
||||
SpoolUpdate(rgba="FFFFFFFFF")
|
||||
|
||||
def test_rejects_non_hex_char(self):
|
||||
with pytest.raises(ValidationError, match="rgba"):
|
||||
SpoolUpdate(rgba="FFGG00FF")
|
||||
|
||||
|
||||
class TestSpoolResponseRgbaLeniency:
|
||||
"""Read-path leniency — a legacy bad row must never 500 the list endpoint.
|
||||
|
||||
Before the fix, SpoolResponse inherited the pattern from SpoolBase so a
|
||||
single 7-char rgba in the DB blew up the whole inventory listing. The
|
||||
response schema now treats rgba as an unconstrained Optional[str] — write
|
||||
validation is where the pattern belongs; responses must tolerate whatever
|
||||
is already persisted.
|
||||
"""
|
||||
|
||||
# SpoolResponse requires id + timestamps so it's easier to test via a
|
||||
# minimal dict payload than by constructing a full instance.
|
||||
@staticmethod
|
||||
def _make_response_kwargs(**overrides):
|
||||
from datetime import datetime
|
||||
|
||||
base = {
|
||||
"id": 1,
|
||||
"material": "PLA",
|
||||
"created_at": datetime.fromisoformat("2026-01-01T00:00:00"),
|
||||
"updated_at": datetime.fromisoformat("2026-01-01T00:00:00"),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_tolerates_7char_rgba_on_serialize(self):
|
||||
"""This is the #1055 bug fixed: malformed legacy rgba must serialize cleanly."""
|
||||
from backend.app.schemas.spool import SpoolResponse
|
||||
|
||||
response = SpoolResponse(**self._make_response_kwargs(rgba="FFFFFFF"))
|
||||
assert response.rgba == "FFFFFFF"
|
||||
|
||||
def test_tolerates_null_rgba(self):
|
||||
from backend.app.schemas.spool import SpoolResponse
|
||||
|
||||
response = SpoolResponse(**self._make_response_kwargs(rgba=None))
|
||||
assert response.rgba is None
|
||||
|
||||
def test_tolerates_non_hex_rgba(self):
|
||||
"""Even completely garbage rgba shouldn't crash the endpoint."""
|
||||
from backend.app.schemas.spool import SpoolResponse
|
||||
|
||||
response = SpoolResponse(**self._make_response_kwargs(rgba="not-hex-at-all"))
|
||||
assert response.rgba == "not-hex-at-all"
|
||||
|
||||
def test_passes_valid_rgba_through(self):
|
||||
from backend.app.schemas.spool import SpoolResponse
|
||||
|
||||
response = SpoolResponse(**self._make_response_kwargs(rgba="FF00AAFF"))
|
||||
assert response.rgba == "FF00AAFF"
|
||||
@@ -348,6 +348,73 @@ describe('SpoolFormModal weightTouched', () => {
|
||||
expect(payload).toHaveProperty('cost_per_kg', null);
|
||||
});
|
||||
|
||||
it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
|
||||
// #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
|
||||
// was editable in the UI but any save 422'd because SpoolUpdate now enforces
|
||||
// the 8-char pattern. The form must sanitize the loaded value to a valid
|
||||
// default so users can edit unrelated fields without being forced to fix
|
||||
// a color they may not even have noticed was broken.
|
||||
const spoolWithBadRgba: InventorySpool = {
|
||||
...existingSpool,
|
||||
rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
|
||||
};
|
||||
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={spoolWithBadRgba}
|
||||
currencySymbol="$"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
|
||||
// The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
|
||||
// value loaded from the stale DB row.
|
||||
expect(payload).toHaveProperty('rgba');
|
||||
expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
|
||||
expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
|
||||
});
|
||||
|
||||
it('preserves a valid existing rgba on edit (no forced default)', async () => {
|
||||
// Sanity: the normalization only kicks in for malformed values. A valid
|
||||
// 8-char rgba must round-trip untouched so untouched edits don't quietly
|
||||
// reset a user's chosen color.
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={existingSpool} // rgba = 'FF0000FF' (valid)
|
||||
currencySymbol="$"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
|
||||
expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
|
||||
});
|
||||
|
||||
it('displays correct catalog name when duplicates exist', async () => {
|
||||
const spoolWithCatalogId: InventorySpool = {
|
||||
...existingSpool,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Regression tests for the ColorSection hex input normalization (#1055).
|
||||
*
|
||||
* The original bug: typing 5 hex chars on the RRGGBB field produced a 7-char
|
||||
* rgba ("FFFFF" + "FF" alpha = 7 chars); typing 7 chars left the 7-char string
|
||||
* unpadded. Either way the value passed frontend validation, survived a backend
|
||||
* PATCH (SpoolUpdate had no pattern constraint), and then bricked the entire
|
||||
* Filaments page because SpoolResponse enforced the 8-char pattern on serialize
|
||||
* and one bad row 500'd the whole list endpoint.
|
||||
*
|
||||
* The input now emits a valid 8-char RRGGBBAA on every keystroke: shorter input
|
||||
* is right-padded with '0' and given FF alpha; 7-char input drops the stray 7th
|
||||
* char; 8-char paste passes through unchanged.
|
||||
*
|
||||
* These tests drive the onChange handler directly (via fireEvent.change) rather
|
||||
* than userEvent.type so each assertion exercises a specific input length. The
|
||||
* component itself is a controlled input whose displayed value derives from
|
||||
* formData.rgba.substring(0, 6), so the real-world UX of typing one char at a
|
||||
* time is quirkier than the handler contract — but the handler contract is
|
||||
* what this regression guards.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../../i18n';
|
||||
import { ColorSection } from '../../../components/spool-form/ColorSection';
|
||||
import { defaultFormData } from '../../../components/spool-form/types';
|
||||
|
||||
type UpdateField = <K extends keyof typeof defaultFormData>(
|
||||
key: K,
|
||||
value: (typeof defaultFormData)[K],
|
||||
) => void;
|
||||
|
||||
function renderColorSection(overrides: Partial<typeof defaultFormData> = {}) {
|
||||
const updateField = vi.fn() as ReturnType<typeof vi.fn> & UpdateField;
|
||||
const formData = { ...defaultFormData, ...overrides };
|
||||
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ColorSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
recentColors={[]}
|
||||
onColorUsed={vi.fn()}
|
||||
catalogColors={[]}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByPlaceholderText('RRGGBB') as HTMLInputElement;
|
||||
return { hexInput, updateField };
|
||||
}
|
||||
|
||||
function lastRgba(updateField: ReturnType<typeof vi.fn>): string | undefined {
|
||||
const rgbaCalls = updateField.mock.calls.filter(([key]) => key === 'rgba');
|
||||
return rgbaCalls.at(-1)?.[1] as string | undefined;
|
||||
}
|
||||
|
||||
describe('ColorSection hex input normalization (#1055)', () => {
|
||||
it('pads a 6-char RRGGBB to 8-char RRGGBBAA with FF alpha', () => {
|
||||
const { hexInput, updateField } = renderColorSection();
|
||||
fireEvent.change(hexInput, { target: { value: 'FF0000' } });
|
||||
expect(lastRgba(updateField)).toBe('FF0000FF');
|
||||
});
|
||||
|
||||
it('passes an 8-char RRGGBBAA paste through unchanged', () => {
|
||||
const { hexInput, updateField } = renderColorSection();
|
||||
fireEvent.change(hexInput, { target: { value: '00112233' } });
|
||||
expect(lastRgba(updateField)).toBe('00112233');
|
||||
});
|
||||
|
||||
it('drops the stray 7th char — the exact #1055 trigger pattern', () => {
|
||||
const { hexInput, updateField } = renderColorSection();
|
||||
fireEvent.change(hexInput, { target: { value: 'FFFFFFF' } });
|
||||
// Previously emitted "FFFFFFF" (7 chars) verbatim. Must now be 8 chars.
|
||||
const rgba = lastRgba(updateField);
|
||||
expect(rgba).toBe('FFFFFFFF');
|
||||
expect(rgba).toMatch(/^[0-9A-F]{8}$/);
|
||||
});
|
||||
|
||||
it('pads a 5-char input to 8 chars instead of emitting a 7-char rgba', () => {
|
||||
// 5-char + 'FF' alpha = 7 chars was the other #1055 trigger pattern.
|
||||
// Right-pad RGB to 6 with '0' so the output is always 8 chars.
|
||||
const { hexInput, updateField } = renderColorSection();
|
||||
fireEvent.change(hexInput, { target: { value: 'FFFFF' } });
|
||||
const rgba = lastRgba(updateField);
|
||||
expect(rgba).toBe('FFFFF0FF');
|
||||
expect(rgba).toMatch(/^[0-9A-F]{8}$/);
|
||||
});
|
||||
|
||||
it('pads any partial input to exactly 8 chars — never 7', () => {
|
||||
// The essential invariant: for every legal input length (0..8), the
|
||||
// emitted rgba must be 8 chars. Anything else risks reintroducing #1055.
|
||||
const { hexInput, updateField } = renderColorSection();
|
||||
for (const input of ['', 'F', 'FF', 'FFF', 'FFFF', 'FFFFF', 'FFFFFF', 'FFFFFFF', 'FFFFFFFF']) {
|
||||
updateField.mockClear();
|
||||
fireEvent.change(hexInput, { target: { value: input } });
|
||||
const rgba = lastRgba(updateField);
|
||||
expect(rgba).toBeDefined();
|
||||
expect(rgba!.length).toBe(8);
|
||||
expect(rgba).toMatch(/^[0-9A-F]{8}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores input past 8 chars (no updateField call)', () => {
|
||||
const { hexInput, updateField } = renderColorSection({ rgba: 'FFFFFFFF' });
|
||||
updateField.mockClear();
|
||||
fireEvent.change(hexInput, { target: { value: '0011223344' } });
|
||||
expect(updateField.mock.calls.filter(([k]) => k === 'rgba')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips non-hex characters before normalizing', () => {
|
||||
// '#FF00ZZ' → strip '#' and non-hex → 'FF00' (4 chars) → pad to 6 + FF alpha
|
||||
const { hexInput, updateField } = renderColorSection();
|
||||
fireEvent.change(hexInput, { target: { value: '#FF00ZZ' } });
|
||||
expect(lastRgba(updateField)).toBe('FF0000FF');
|
||||
});
|
||||
});
|
||||
@@ -253,12 +253,22 @@ export function SpoolFormModal({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (spool) {
|
||||
// Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
|
||||
// from #1055 before the create/update pattern was enforced). The
|
||||
// backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
|
||||
// re-submitting a malformed value would 422 every edit on that spool
|
||||
// — even edits that don't touch color. Normalize on load: any value
|
||||
// that isn't exactly 8 hex chars falls back to the default, so the
|
||||
// user can save unrelated fields (weight, material, note) without
|
||||
// first being forced to fix a color they may not even be aware is
|
||||
// broken. Saving also purges the bad value from the DB.
|
||||
const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
|
||||
setFormData({
|
||||
material: spool.material || '',
|
||||
subtype: spool.subtype || '',
|
||||
brand: spool.brand || '',
|
||||
color_name: spool.color_name || '',
|
||||
rgba: spool.rgba || '808080FF',
|
||||
rgba: validRgba,
|
||||
label_weight: spool.label_weight || 1000,
|
||||
core_weight: spool.core_weight || 250,
|
||||
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
|
||||
|
||||
@@ -298,8 +298,18 @@ export function ColorSection({
|
||||
placeholder="RRGGBB"
|
||||
value={currentHex.toUpperCase()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
|
||||
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
|
||||
if (val.length > 8) return;
|
||||
// Normalize to a valid 8-char RRGGBBAA on every keystroke so
|
||||
// the backend never receives a malformed rgba (#1055). 8-char
|
||||
// paste passes through; 7-char drops the stray typo; anything
|
||||
// shorter is right-padded with '0' to a full RGB triplet and
|
||||
// given FF alpha. Prior logic emitted 3/5/7-char strings mid-
|
||||
// typing that PATCH would accept (SpoolUpdate was unchecked)
|
||||
// and later 500 the list endpoint on response serialization.
|
||||
const rgba =
|
||||
val.length === 8 ? val : val.length === 7 ? val.substring(0, 6) + 'FF' : val.padEnd(6, '0') + 'FF';
|
||||
updateField('rgba', rgba);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BskdRxdi.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CASdUlGi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkAOuJaW.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user