fix(frontend,application,backend,web-server,infrastructure): le set de couleurs projet s'applique à tous les projets — #316 (QA verte)

Cause: update_project_settings remplaçait tout ProjectSettings avec la
seule couleur fournie — champ absent et null indistinguables — et des
réponses de chargement/sauvegarde obsolètes d'un projet précédent
pouvaient réafficher/écraser la couleur lors d'un changement de projet
rapide, d'où l'impression que la couleur ne prenait pas sur tous les
projets.

- DTO tri-état (absent = inchangé, null = défaut produit) avec
  Deserialize manuel et use case de mise à jour partielle du champ
  theme_color; commandes Tauri et web-server alignées sur le même
  contrat.
- Garde-fous de course frontend: séquences load/save + révision thème
  par projet (pub/sub onProjectThemeChanged) dans ProjectSettingsPanel,
  garde de révision de focus dans ViewWindow.
- Tests: infrastructure settings, contrats web-server (inchangé/reset),
  ProjectSettingsPanel, ViewWindow, projects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:01:05 +02:00
parent 89937c7cd9
commit bbe8c1d04b
10 changed files with 306 additions and 27 deletions

View File

@ -4,7 +4,19 @@ import { describe, expect, it, vi } from "vitest";
import { createMockGateways } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { ProjectSettingsPanel, PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel";
import { accessibleProjectAccent, validThemeColor } from "./projectTheme";
import {
accessibleProjectAccent,
publishProjectThemeChanged,
validThemeColor,
} from "./projectTheme";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe("ProjectSettingsPanel", () => {
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
@ -55,6 +67,83 @@ describe("ProjectSettingsPanel", () => {
);
});
it("keeps sibling settings surfaces in sync without persisting the blue fallback", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Alpha", "/alpha");
const update = vi.spyOn(gateways.project, "updateProjectSettings");
render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
const pickers = (await screen.findAllByLabelText(
"Couleur du projet",
)) as HTMLInputElement[];
await waitFor(() =>
expect(pickers.every((picker) => !picker.disabled)).toBe(true),
);
expect(pickers.map((picker) => picker.value)).toEqual(["#4c8dff", "#4c8dff"]);
expect(update).not.toHaveBeenCalled();
fireEvent.change(pickers[0], { target: { value: "#aa3366" } });
await waitFor(() => expect(pickers[1].value).toBe("#aa3366"));
});
it("does not let a delayed read replace a newer cross-window colour", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Alpha", "/alpha");
const pendingRead = deferred<{ themeColor: string | null }>();
vi.spyOn(gateways.project, "readProjectSettings").mockReturnValue(
pendingRead.promise,
);
render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
publishProjectThemeChanged({ projectId: project.id, themeColor: "#22AA66" });
pendingRead.resolve({ themeColor: "#AA3366" });
await waitFor(() =>
expect((screen.getByLabelText("Couleur du projet") as HTMLInputElement).value).toBe(
"#22aa66",
),
);
});
it("does not apply a delayed save response to the newly edited project", async () => {
const gateways = createMockGateways();
const alpha = await gateways.project.createProject("Alpha", "/alpha");
const beta = await gateways.project.createProject("Beta", "/beta");
await gateways.project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
const pendingSave = deferred<{ themeColor: string | null }>();
vi.spyOn(gateways.project, "updateProjectSettings").mockImplementation(
async (projectId, settings) =>
projectId === alpha.id ? pendingSave.promise : settings,
);
const view = render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={alpha.id} />
</DIProvider>,
);
const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "#aa3366" } });
view.rerender(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={beta.id} />
</DIProvider>,
);
await waitFor(() => expect(picker.value).toBe("#22aa66"));
pendingSave.resolve({ themeColor: "#AA3366" });
await waitFor(() => expect(picker.value).toBe("#22aa66"));
});
it("keeps strict colours and raises dark accents to readable contrast", () => {
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
expect(validThemeColor("red")).toBeNull();

View File

@ -5,6 +5,7 @@ import { Button, Panel, Spinner } from "@/shared";
import { ProjectContextPanel } from "./ProjectContextPanel";
import {
DEFAULT_PROJECT_COLOR,
onProjectThemeChanged,
publishProjectThemeChanged,
validThemeColor,
} from "./projectTheme";
@ -17,18 +18,32 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadSequence = useRef(0);
const saveSequence = useRef(0);
const currentProjectId = useRef(projectId);
const themeRevisionRef = useRef<Record<string, number>>({});
currentProjectId.current = projectId;
const load = useCallback(async () => {
const sequence = ++loadSequence.current;
const loadedProjectId = projectId;
const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0;
setBusy(true);
setError(null);
setThemeColor(null);
try {
const settings = await project.readProjectSettings(projectId);
if (loadSequence.current !== sequence) return;
const settings = await project.readProjectSettings(loadedProjectId);
if (
loadSequence.current !== sequence ||
currentProjectId.current !== loadedProjectId ||
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
) return;
setThemeColor(validThemeColor(settings.themeColor));
} catch {
if (loadSequence.current !== sequence) return;
if (
loadSequence.current !== sequence ||
currentProjectId.current !== loadedProjectId ||
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
) return;
setError("Impossible de charger les paramètres du projet.");
} finally {
if (loadSequence.current === sequence) setBusy(false);
@ -39,21 +54,58 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
void load();
return () => {
loadSequence.current += 1;
saveSequence.current += 1;
};
}, [load]);
useEffect(
() =>
onProjectThemeChanged(
({ projectId: changedProjectId, themeColor: next }) => {
themeRevisionRef.current[changedProjectId] =
(themeRevisionRef.current[changedProjectId] ?? 0) + 1;
if (changedProjectId === currentProjectId.current) {
setThemeColor(validThemeColor(next));
}
},
),
[],
);
async function save(next: string | null) {
const savedProjectId = projectId;
const sequence = ++saveSequence.current;
setBusy(true);
setError(null);
try {
const saved = await project.updateProjectSettings(projectId, { themeColor: next });
const saved = await project.updateProjectSettings(savedProjectId, {
themeColor: next,
});
const normalized = validThemeColor(saved.themeColor);
setThemeColor(normalized);
publishProjectThemeChanged({ projectId, themeColor: normalized });
if (
currentProjectId.current === savedProjectId &&
saveSequence.current === sequence
) {
setThemeColor(normalized);
}
publishProjectThemeChanged({
projectId: savedProjectId,
themeColor: normalized,
});
} catch {
setError("Impossible d’enregistrer la couleur du projet.");
if (
currentProjectId.current === savedProjectId &&
saveSequence.current === sequence
) {
setError("Impossible d’enregistrer la couleur du projet.");
}
} finally {
setBusy(false);
if (
currentProjectId.current === savedProjectId &&
saveSequence.current === sequence
) {
setBusy(false);
}
}
}

View File

@ -114,6 +114,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");
const beta = await project.createProject("beta", "/p/b");
await project.createProject("gamma", "/p/c");
await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" });
await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
renderView(project);
@ -134,6 +135,14 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"),
);
const gammaRow = screen
.getAllByRole("listitem")
.find((item) => within(item).queryByText("gamma"))!;
fireEvent.click(within(gammaRow).getByRole("button", { name: "Open" }));
await waitFor(() =>
expect(scope.style.getPropertyValue("--project-theme-color")).toBe(""),
);
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
await waitFor(() =>
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"),