feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)
- 3D dependencies (three, react-three-fiber, drei, postprocessing, motion), exact versions, supply-chain checked; scene loaded only on demand on the colony page (283 kB gzip), SVG stays as fallback - glass hologram per owner design 2026-10-16: true Liebig Zander shapes (grips, rebate, Wanderboden foot), cool blue edge glow, click any part -> it flies to the front while the rest stays as a faint ghost; focus shows content (feed levels, Bausperre, pockets); frames rise on hover and take their type look (colour + comb pattern) - full-bleed card with frosted overlays and glass buttons - Futtertasche (FRAME_FEEDER): new part kind occupying frame slots with a fill level, catalog row, validator rules in both ports, outline, sheets, follow-ups, settings Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c608f5d405
commit
6e13236d49
100 files changed
+9432
-209
No files matched your search
@@ -29,10 +29,11 @@ interface HarnessProps {
|
||||
initialState?: HiveState;
|
||||
submit?: (ops: HiveOp[]) => Promise<unknown>;
|
||||
startWithEvaporator?: boolean;
|
||||
startWithFrameFeeder?: boolean;
|
||||
}
|
||||
|
||||
/** Applies submitted ops to its own state, like the optimistic cache does. */
|
||||
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
|
||||
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator, startWithFrameFeeder }: HarnessProps) {
|
||||
const [state, setState] = useState(initialState);
|
||||
const [open, setOpen] = useState(true);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
@@ -62,6 +63,7 @@ function Harness({ partId, initialState = baseState(), submit, startWithEvaporat
|
||||
submit={send}
|
||||
outlineRef={outlineRef}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
startWithFrameFeeder={startWithFrameFeeder}
|
||||
/>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
@@ -329,4 +331,99 @@ describe("PartSheet", () => {
|
||||
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame feeder", () => {
|
||||
const withPocket = (): HiveState => {
|
||||
const s = baseState();
|
||||
s.stack[1] = part("b", "box", {
|
||||
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
|
||||
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 0.5 } })],
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
it("inserts a frame feeder: setFrames clears the covered slots, addInsert hangs it", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
await user.click(screen.getByRole("radio", { name: "Plätze 3–4" }));
|
||||
expect(screen.getByText(/werden zu „leer“/)).toBeInTheDocument();
|
||||
const syrup = within(dialog()).getByLabelText("Flüssigfutter");
|
||||
await user.clear(syrup);
|
||||
await user.type(syrup, "1,5");
|
||||
await user.click(save());
|
||||
const expectedFrames = frames(10).map((f) => (f.slot === 3 || f.slot === 4 ? { ...f, type: "NONE" } : f));
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "setFrames", boxId: "b", frames: expectedFrames },
|
||||
{
|
||||
op: "addInsert",
|
||||
hostId: "b",
|
||||
part: { id: expect.any(String), partTypeId: "frame-feeder", state: { syrupLiters: 1.5 }, slot: 3 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips setFrames when the covered slots are already empty", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
const state = baseState();
|
||||
state.stack[1] = part("b", "box", {
|
||||
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
|
||||
});
|
||||
render(<Harness partId="b" initialState={state} submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
expect(screen.queryByText(/werden zu „leer“/)).toBeNull();
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "addInsert", hostId: "b", part: expect.objectContaining({ partTypeId: "frame-feeder", slot: 1 }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers only positions where the slots are still free", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" initialState={withPocket()} />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
expect(screen.queryByRole("radio", { name: "Plätze 1–2" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Plätze 2–3" })).toBeNull();
|
||||
expect(screen.getByRole("radio", { name: "Plätze 3–4" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preselects the frame feeder when opened from the outline action", () => {
|
||||
render(<Harness partId="b" startWithFrameFeeder />);
|
||||
expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("moves focus to the new pocket item after inserting", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /^F1–2 · Futtertasche/ })).toHaveFocus());
|
||||
});
|
||||
|
||||
it("edits the pocket level with a stepper", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
|
||||
expect(screen.getByRole("dialog", { name: "Futtertasche" })).toBeInTheDocument();
|
||||
await user.click(within(dialog()).getByRole("button", { name: /Flüssigfutter erhöhen/ }));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ff", key: "syrupLiters", value: 0.75 }]);
|
||||
});
|
||||
|
||||
it("asks before removing the pocket, then removes it and focuses the box", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
|
||||
await user.click(screen.getByRole("button", { name: "Futtertasche entfernen" }));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("group", { name: "Futtertasche wirklich entfernen?" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ff" }]);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user