Files
paseo/packages/app/src/mobile-panels/model.test.ts
T
Mohamed BoudraandGitHub 6af1379357 Stop mobile sidebars getting stuck out of sync (#1953)
* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
2026-07-09 23:08:56 +02:00

184 lines
5.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { MobilePanelView } from "@/stores/panel-store";
import {
canBeginMobilePanelGesture,
createMobilePanelMotionState,
getMobilePanelFrame,
isMobilePanelGestureCurrent,
transitionMobilePanel,
type MobilePanelCommit,
type MobilePanelEvent,
type MobilePanelMotionState,
} from "./model";
class MobilePanelsScenario {
private nextRevision = 0;
private startedRevision = -1;
private state: MobilePanelMotionState;
readonly commits: MobilePanelCommit[] = [];
constructor(target: MobilePanelView = "agent") {
this.state = createMobilePanelMotionState({ target, revision: this.nextRevision });
}
command(target: MobilePanelView) {
this.nextRevision += 1;
this.dispatch({ type: "command", selection: { target, revision: this.nextRevision } });
return this;
}
beginGesture(origin: MobilePanelView) {
this.dispatch({ type: "gesture.begin", origin });
this.startedRevision = this.state.gesture?.startedRevision ?? -1;
return this;
}
finishGesture(target: MobilePanelView) {
this.dispatch({
type: "gesture.finish",
startedRevision: this.startedRevision,
success: true,
target,
});
return this;
}
cancelGesture() {
this.dispatch({
type: "gesture.finish",
startedRevision: this.startedRevision,
success: false,
target: this.state.target,
});
return this;
}
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
this.dispatch({ type: "animation.finished", revision, target });
return this;
}
snapshot() {
return {
motionTarget: this.state.motionTarget,
revision: this.state.revision,
settledTarget: this.state.settledTarget,
target: this.state.target,
};
}
private dispatch(event: MobilePanelEvent) {
const transition = transitionMobilePanel(this.state, event);
this.state = transition.state;
if (transition.commit) {
this.commits.push(transition.commit);
}
}
}
describe("mobile panel ownership", () => {
it("follows programmatic commands through left, center, and right", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list").finishAnimation("agent-list");
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
settledTarget: "agent-list",
revision: 1,
});
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "file-explorer",
revision: 3,
});
});
it("turns a completed drag into one semantic commit", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").finishGesture("agent-list");
expect(panels.snapshot()).toEqual({
target: "agent",
motionTarget: "agent-list",
settledTarget: "agent",
revision: 0,
});
expect(panels.commits).toEqual([{ target: "agent-list", startedRevision: 0 }]);
});
it("returns a canceled drag to the latest canonical target", () => {
const panels = new MobilePanelsScenario("agent-list");
panels.beginGesture("agent-list").cancelGesture();
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
settledTarget: "agent-list",
revision: 0,
});
expect(panels.commits).toEqual([]);
});
it("makes a command during a drag invalidate the stale gesture finish", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").command("file-explorer").finishGesture("agent-list");
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "agent",
revision: 1,
});
expect(panels.commits).toEqual([]);
});
it("keeps the latest rapid command and rejects stale animation completion", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list");
const staleRevision = panels.snapshot().revision;
panels.command("agent").command("file-explorer");
panels.finishAnimation("agent-list", staleRevision);
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "agent",
revision: 3,
});
});
it("keeps an activated drag current while blocking a second drag", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 7 });
const active = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
expect(isMobilePanelGestureCurrent(active, 7)).toBe(true);
expect(canBeginMobilePanelGesture(active, "agent", 0)).toBe(false);
});
it("derives both transforms and both backdrops from one normalized position", () => {
expect(getMobilePanelFrame(0.25, 400)).toEqual({
leftBackdropOpacity: 0,
leftTranslateX: -400,
rightBackdropOpacity: 0.25,
rightTranslateX: 300,
});
expect(getMobilePanelFrame(0.25, 800)).toEqual({
leftBackdropOpacity: 0,
leftTranslateX: -800,
rightBackdropOpacity: 0.25,
rightTranslateX: 600,
});
});
});