Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { strFromU8, unzipSync } from "fflate";
import {
parseAcceptedPlateExportJobResult,
parseAcceptedPlateWorkspace,
type JobSnapshot,
} from "@print-partner/contracts";
import { createSelfHostPorts } from "../adapters/self-host/index.js";
import { buildApp } from "../app.js";
import { loadConfig } from "../config.js";
import { acceptedPlanBasis } from "../db/accepted-plan-progress.js";
import { InProcessJobRunner } from "../services/job-runner.js";
import { saveFleet } from "../services/printer-fleet.js";

vi.mock("../services/webhook-store.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../services/webhook-store.js")>()),
Expand Down Expand Up @@ -47,7 +49,7 @@ function modelXml(bytes: Uint8Array): string {

async function fixture(
jobOptions?: ConstructorParameters<typeof InProcessJobRunner>[1],
options: Readonly<{ publishPlates?: boolean }> = {},
options: Readonly<{ publishPlates?: boolean; filename?: string }> = {},
) {
const root = mkdtempSync(join(tmpdir(), "pp-accepted-export-routes-"));
const exchangeDir = join(root, "exchange");
Expand All @@ -65,7 +67,7 @@ async function fixture(
const locator = `${source.id}/revisions/accepted`;
const snapshotRoot = join(root, "repos", locator);
mkdirSync(snapshotRoot, { recursive: true });
writeFileSync(join(snapshotRoot, "part.stl"), `solid accepted
writeFileSync(join(snapshotRoot, options.filename ?? "part.stl"), `solid accepted
facet normal 0 0 1
outer loop
vertex 0 0 0
Expand Down Expand Up @@ -154,7 +156,7 @@ endsolid accepted`);
kind: "orca",
dialect: "orca_json",
guiUrl: "http://127.0.0.1:18888",
watchPath: "/profiles/orca",
watchPath: "",
enabled: true,
});
if (jobOptions) {
Expand All @@ -175,6 +177,65 @@ endsolid accepted`);
}

describe("accepted Plate export delivery routes", () => {
it.each(["completed", "selected"])("prepares and exports only two chosen copies after a %s copy had its own Plate", async (priorBatch) => {
const { app, repo, profile } = await fixture(undefined, { publishPlates: false, filename: "part_x3.stl" });
saveFleet(repo, [{
id: "printer-one", name: "Printer One", model: "Model One",
bed_width_mm: 250, bed_depth_mm: 210, bed_height_mm: 200, margin_mm: 4,
max_filament_slots: 1, loaded_filaments: [],
}]);
const read = repo.readAcceptedPlanOperationalSnapshot(profile.id);
if (read.kind !== "ready") throw new Error("Expected accepted Plan");
const part = read.snapshot.parts[0];
if (!part) throw new Error("Expected accepted part");
const marked = await app.inject({ method: "PATCH", url: `/parts/${part.projectionPartId}/progress`, payload: { unit_index: 0, completed: true } });
expect(marked.statusCode).toBe(200);
const setup = parseAcceptedPlateWorkspace((await app.inject({ method: "GET", url: `/plans/${profile.id}/plates` })).json());
if (setup.kind !== "setup") throw new Error("Expected initial Plate setup");
expect(setup.units).toHaveLength(3);
const completed = setup.units.filter((unit) => unit.completed);
const selected = setup.units.filter((unit) => !unit.completed);
expect(completed).toHaveLength(1);
expect(selected).toHaveLength(2);
const previousUnits = priorBatch === "completed" ? completed : selected.slice(0, 1);
const first = await app.inject({
method: "POST", url: `/plans/${profile.id}/plates/initialize`,
payload: { expected: setup.basis, expected_plate_revision_id: null,
assignments: previousUnits.map((unit) => ({ token: unit.token, printer_id: "printer-one" })) },
});
expect(first.statusCode).toBe(200);
const previous = parseAcceptedPlateWorkspace(first.json());
if (previous.kind !== "ready") throw new Error("Expected prior Plate");
const prepared = await app.inject({
method: "POST", url: `/plans/${profile.id}/plates/initialize`,
payload: { expected: setup.basis, expected_plate_revision_id: previous.plate_revision_id,
assignments: selected.map((unit) => ({ token: unit.token, printer_id: "printer-one" })) },
});
expect(prepared.statusCode).toBe(200);
const workspace = parseAcceptedPlateWorkspace(prepared.json());
if (workspace.kind !== "ready") throw new Error("Expected selected Plate");
expect(workspace.plate_revision_id).not.toBe(previous.plate_revision_id);
expect(workspace.plate_revision_number).toBe(previous.plate_revision_number + 1);
expect(workspace.plates.flatMap((plate) => plate.units.map((unit) => unit.token)).sort())
.toEqual(selected.map((unit) => unit.token).sort());
expect(workspace.unassigned.map((unit) => unit.token)).toEqual(completed.map((unit) => unit.token));
const started = await app.inject({ method: "POST", url: "/jobs/export-accepted-plate-3mf", payload: {
profile_id: profile.id, expected_plate_revision_id: workspace.plate_revision_id,
} });
expect(started.statusCode).toBe(200);
const job = await waitForJob(app, started.json().job_id);
expect(job.status).toBe("done");
const exported = parseAcceptedPlateExportJobResult(job.result);
const objects: string[] = [];
for (const plate of exported.plates) {
const downloaded = await app.inject({ method: "GET", url: plate.download_url });
expect(downloaded.statusCode).toBe(200);
const xml = modelXml(downloaded.rawPayload);
objects.push(...[...xml.matchAll(/<object\b[^>]*partnumber="([^"]+)"/g)].map((match) => match[1]));
}
expect(objects.sort()).toEqual(selected.map((unit) => unit.token).sort());
});

it("exports one unarranged named-object 3MF without published Plates", async () => {
const { app, profile, token } = await fixture(undefined, { publishPlates: false });
const started = await app.inject({
Expand Down
20 changes: 1 addition & 19 deletions web/apps/server/src/services/accepted-plate-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,24 +542,6 @@ function isPackedPlateInputs(
return Array.isArray(value);
}

function mergeExistingPlates(
input: Extract<
ReadAcceptedPlateWorkspaceInputResult,
{ kind: "setup" | "ready" }
>,
packed: readonly AcceptedPlateInput[],
assignments: InitializeAcceptedPlatesCommand["assignments"],
): readonly AcceptedPlateInput[] {
if (input.kind !== "ready") return packed;
const existingTokens = new Set(
input.plates.flatMap((plate) => plate.units.map((unit) => unit.token)),
);
if (assignments.some((assignment) => existingTokens.has(assignment.token))) {
return packed;
}
return [...currentPlateInputs(input.plates), ...packed];
}

export async function initializeAcceptedPlates(
dependencies: AcceptedPlateWorkspaceDependencies,
command: InitializeAcceptedPlatesCommand,
Expand Down Expand Up @@ -689,7 +671,7 @@ export async function initializeAcceptedPlates(
rules: packingRules,
});
if (!isPackedPlateInputs(packed)) return packed;
const plates = mergeExistingPlates(input, packed, command.assignments);
const plates = packed;

const published = dependencies.repository.publishAcceptedPlates({
profileId: command.profileId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import {
Expand Down Expand Up @@ -102,6 +102,7 @@ function readyWorkspace(): AcceptedPlateWorkspace {
}

let mockWorkspace = setupWorkspace();
const initialize = vi.fn(() => Promise.resolve());

vi.mock("../../../queries/acceptedPlates", () => ({
invalidateAcceptedPlateWorkspace: vi.fn(() => Promise.resolve()),
Expand All @@ -115,7 +116,7 @@ vi.mock("../../../queries/acceptedPlates", () => ({
}),
useInitializeAcceptedPlatesMutation: () => ({
isPending: false,
mutateAsync: vi.fn(() => Promise.resolve()),
mutateAsync: initialize,
}),
useAcceptedPlateActionMutation: () => ({ mutateAsync: vi.fn() }),
useMoveAcceptedPlateUnitMutation: () => ({ mutateAsync: vi.fn() }),
Expand All @@ -124,9 +125,42 @@ vi.mock("../../../queries/acceptedPlates", () => ({
afterEach(() => {
cleanup();
mockWorkspace = setupWorkspace();
initialize.mockClear();
});

describe("AcceptedPlateSection assignment draft identity", () => {
it("prepares the whole selected batch when adding an unassigned unit to an existing Plate", async () => {
mockWorkspace = readyWorkspace();
render(
<QueryClientProvider client={new QueryClient()}>
<AcceptedPlateSection profileId={7} enabled view="assign" selectedTokens={new Set([placedToken, otherToken])} />
</QueryClientProvider>,
);
expect(screen.getByText("1 of 2 selected units assigned")).toBeTruthy();
fireEvent.change(screen.getByRole("combobox", { name: "Assign all selected units" }), { target: { value: printer.id } });
fireEvent.click(screen.getByRole("button", { name: "Prepare Plates again for 2 selected units" }));
await waitFor(() => expect(initialize).toHaveBeenCalledWith({
expected: basis,
expected_plate_revision_id: 19,
assignments: [
{ token: placedToken, printer_id: printer.id },
{ token: otherToken, printer_id: printer.id },
],
}));
});

it("does not offer an empty assignment form for units outside the selection", () => {
mockWorkspace = readyWorkspace();
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AcceptedPlateSection profileId={7} enabled view="assign" selectedTokens={new Set([placedToken])} />
</QueryClientProvider>,
);
expect(screen.queryByText("0 of 0 selected units assigned")).toBeNull();
expect(screen.getByText("Plate 1 · Printer One · 1 unit")).toBeTruthy();
});

it("limits an existing workspace to the selected unassigned units", () => {
mockWorkspace = readyWorkspace();
const queryClient = new QueryClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../../../queries/acceptedPlates";
import { useProductionSetup } from "../../../queries/productionSetup";
import { settingsPrintersRoute } from "../../../lib/routes";
import { productionPlateReadiness, productionSelectableUnits } from "../../../lib/productionSelection";
import { Button } from "../../ui/button";
import {
Card,
Expand Down Expand Up @@ -130,6 +131,11 @@ export default function AcceptedPlateSection({
const [reassigning, setReassigning] = useState(false);
const [failure, setFailureState] = useState<PlateOperationFailure | null>(null);
const workspace = query.data;
const readiness = productionPlateReadiness(
workspace,
selectedTokens ?? new Set(workspace ? productionSelectableUnits(workspace).map((unit) => unit.token) : []),
);
const needsPreparation = readiness.unassigned > 0 || readiness.hasUnselectedPlateUnits;
const showAssign = view !== "arrange";
const showArrange = view !== "assign";

Expand Down Expand Up @@ -420,31 +426,28 @@ export default function AcceptedPlateSection({
<AcceptedPlateAssignmentForm
rules={productionSetup.data?.rules}
savedAssignments={productionSetup.data?.printer_assignments}
key={assignmentIdentity(workspace)}
key={`${assignmentIdentity(workspace)}:${selectionIdentity(selectedTokens)}`}
workspace={workspace}
submitting={initialize.isPending}
selectedTokens={selectedTokens}
onSubmit={submitAssignments}
onAssignmentsChange={saveAssignmentDraft}
onCancel={() => setReassigning(false)}
/>
) : null}
{showAssign && workspace?.kind === "ready" && !reassigning && workspace.unassigned.length > 0 ? (
{showAssign && workspace?.kind === "ready" && !reassigning && needsPreparation ? (
<AcceptedPlateAssignmentForm
rules={productionSetup.data?.rules}
savedAssignments={productionSetup.data?.printer_assignments}
key={`${assignmentIdentity(workspace)}:unassigned:${selectionIdentity(selectedTokens)}`}
workspace={workspace}
submitting={initialize.isPending}
selectedTokens={new Set(
workspace.unassigned
.filter((unit) => selectedTokens == null || selectedTokens.has(unit.token))
.map((unit) => unit.token),
)}
selectedTokens={selectedTokens}
onSubmit={submitAssignments}
onAssignmentsChange={saveAssignmentDraft}
/>
) : null}
{view === "assign" && workspace?.kind === "ready" && !reassigning && workspace.unassigned.length === 0 ? (
{view === "assign" && workspace?.kind === "ready" && !reassigning && !needsPreparation ? (
<ul className="space-y-1 text-sm text-muted-foreground">
{workspace.plates.map((plate) => (
<li key={plate.plate_id}>
Expand Down
17 changes: 17 additions & 0 deletions web/apps/web/src/lib/productionSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ export function productionSelectableUnits(
return [];
}

export function productionPlateReadiness(
workspace: AcceptedPlateWorkspace | undefined,
selectedTokens: ReadonlySet<string>,
) {
if (workspace?.kind !== "ready") {
return { unassigned: 0, unplaced: 0, hasUnselectedPlateUnits: false };
}
return {
unassigned: workspace.unassigned.filter((unit) => selectedTokens.has(unit.token)).length,
unplaced: workspace.unplaced.filter((unit) => selectedTokens.has(unit.token)).length,
hasUnselectedPlateUnits: [
...workspace.plates.flatMap((plate) => plate.units),
...workspace.unplaced,
].some((unit) => !selectedTokens.has(unit.token)),
};
}

export function initialMissingSelection(
units: readonly ProductionSelectableUnit[],
): Set<RequiredUnitToken> {
Expand Down
7 changes: 4 additions & 3 deletions web/apps/web/src/lib/workPackageProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
RequiredUnitToken,
} from "@print-partner/contracts";
import type { PrinterCheckoffLink } from "../api/endpoints/checkoff";
import type { ProductionSelectableUnit } from "./productionSelection";
import { productionPlateReadiness, type ProductionSelectableUnit } from "./productionSelection";
import { progressRoute } from "./routes";

/**
Expand Down Expand Up @@ -529,8 +529,9 @@ export function projectWorkPackages(input: WorkPackageProjectionInput): WorkPack
? { id: ready.plate_revision_id, number: ready.plate_revision_number }
: null;
const exportArtifact = currentExportArtifact(input.exportRecords, plateRevision?.id ?? null);
const needsAssignment = setup != null || (ready?.unassigned.length ?? 0) > 0;
const needsArrangement = (ready?.unplaced.length ?? 0) > 0;
const readiness = productionPlateReadiness(workspace, selected);
const needsAssignment = setup != null || readiness.unassigned > 0 || readiness.hasUnselectedPlateUnits;
const needsArrangement = readiness.unplaced > 0;
const status = benchStatus({
route,
hasPlan: true,
Expand Down
13 changes: 9 additions & 4 deletions web/apps/web/src/lib/workPackageTasks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AcceptedPlateWorkspace, ProductionRoute } from "@print-partner/contracts";
import type { WorkflowTaskState } from "../components/layout/TaskList";
import type { WorkPackage } from "./workPackageProjection";
import { productionPlateReadiness } from "./productionSelection";

/**
* Production work is a list of resumable tasks, not a numbered pass. The user
Expand Down Expand Up @@ -187,12 +188,14 @@ function platesTasks(input: PlatesTaskInput): ProductionTask[] {
const ready = workspace?.kind === "ready" ? workspace : null;
const isSetup = workspace?.kind === "setup";
const noPlan = workspace == null || workspace.kind === "empty_plan";
const unassigned = ready?.unassigned.length ?? 0;
const unplaced = ready?.unplaced.length ?? 0;
const links = input.pkg.links;
const { unassigned, unplaced, hasUnselectedPlateUnits } = productionPlateReadiness(
workspace,
new Set(links.unitTokens),
);

const selected = input.selectedCount > 0;
const assigned = ready != null && unassigned === 0;
const assigned = ready != null && unassigned === 0 && !hasUnselectedPlateUnits;
const arranged = assigned && unplaced === 0;
const prepared = selected && input.printerCount > 0 && arranged && !isSetup;
const exported = links.exportArtifact != null;
Expand All @@ -214,7 +217,9 @@ function platesTasks(input: PlatesTaskInput): ProductionTask[] {
: !assigned
? unassigned > 0
? `${plural(unassigned, "unit needs", "units need")} a printer. Assign by Source layer, directory, color, role, or part.`
: "Assign by Source layer, directory, color, role, or part, then prepare the Plates."
: hasUnselectedPlateUnits
? "The selection changed. Prepare Plates again to export only the chosen units."
: "Assign by Source layer, directory, color, role, or part, then prepare the Plates."
: unplaced > 0
? `${plural(unplaced, "unit does", "units do")} not fit where they are. Review the Plate layout.`
: ready
Expand Down
Loading
Loading