Files
jhonny-editor/code/tests/uxp/slip-workflows.test.ts
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

173 lines
8.0 KiB
TypeScript
Executable File

import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type SlipHostOptions = {
reverse?: boolean;
speed?: number;
pauseFirstSnapshot?: boolean;
};
function slipHost(options: SlipHostOptions = {}) {
const state = { start: 10, end: 20, inPoint: 30, outPoint: 40 };
let releaseFirstSnapshot: (() => void) | null = null;
let firstSnapshotStarted: (() => void) | null = null;
const firstSnapshot = new Promise<void>((resolve) => { firstSnapshotStarted = resolve; });
const firstSnapshotRelease = new Promise<void>((resolve) => { releaseFirstSnapshot = resolve; });
let waitForFirstSnapshot = options.pauseFirstSnapshot === true;
const item = {
getStartTime: vi.fn(async () => ({ seconds: state.start })),
getEndTime: vi.fn(async () => ({ seconds: state.end })),
getInPoint: vi.fn(async () => {
if (waitForFirstSnapshot) {
waitForFirstSnapshot = false;
firstSnapshotStarted?.();
await firstSnapshotRelease;
}
return { seconds: state.inPoint };
}),
getOutPoint: vi.fn(async () => ({ seconds: state.outPoint })),
getDuration: vi.fn(async () => ({ seconds: state.end - state.start })),
getSpeed: vi.fn(async () => options.speed ?? 1),
isSpeedReversed: vi.fn(async () => options.reverse ?? false),
createSetInPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { state.inPoint = time.seconds; } })),
createSetOutPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { state.outPoint = time.seconds; } })),
};
const videoTrack = { getTrackItems: vi.fn(async () => [item]) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1),
getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 0),
getAudioTrack: vi.fn(async () => null),
};
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const project = {
guid: "project-1",
getActiveSequence: vi.fn(async () => sequence),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => {
callback({ addAction });
return true;
}),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
Constants: { TrackItemType: { CLIP: 1 } },
};
return {
state,
item,
project,
firstSnapshot,
releaseFirstSnapshot: () => releaseFirstSnapshot?.(),
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
const expectedSnapshot = {
projectGuid: "project-1",
sequenceId: "sequence-1",
mediaType: "video",
trackIndex: 0,
clipIndex: 0,
startSeconds: 10,
endSeconds: 20,
inSeconds: 30,
outSeconds: 40,
durationSeconds: 10,
speed: 1,
reversed: false,
};
const target = { mediaType: "video", trackIndex: 0, clipIndex: 0 };
describe("guarded documented UXP track-item slip workflow", () => {
it("advertises separate bounded inspection and one-transaction mutation commands", async () => {
const value = slipHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({
commands: {
"trackItem.slip.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"trackItem.slip": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
},
});
await expect(value.registry.dispatch("trackItem.slip.inspect", target)).resolves.toEqual(expectedSnapshot);
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("slips one reviewed forward 1x item without moving timeline timing and reads every guarded field back", async () => {
const value = slipHost();
await expect(value.registry.dispatch("trackItem.slip", {
...target,
expectedSnapshot,
slipBySeconds: 2.5,
confirmSlip: true,
operationId: "slip-apply-1",
})).resolves.toMatchObject({
operationId: "slip-apply-1",
slipped: true,
outcome: "verified",
before: expectedSnapshot,
after: { ...expectedSnapshot, inSeconds: 32.5, outSeconds: 42.5 },
verificationBoundary: "track_item_source_and_timeline_readback",
});
expect(value.state).toEqual({ start: 10, end: 20, inPoint: 32.5, outPoint: 42.5 });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Slip timeline item source");
expect(value.item.createSetInPointAction).toHaveBeenCalledWith({ seconds: 32.5 });
expect(value.item.createSetOutPointAction).toHaveBeenCalledWith({ seconds: 42.5 });
});
it("requires a complete unchanged snapshot, confirmation, replay key, forward 1x state, and valid source bounds before action creation", async () => {
const value = slipHost();
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 1, operationId: "missing-confirmation",
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 1, confirmSlip: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot: { ...expectedSnapshot, endSeconds: 21 }, slipBySeconds: 1, confirmSlip: true, operationId: "stale",
})).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: -31, confirmSlip: true, operationId: "negative-handle",
})).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
const reverse = slipHost({ reverse: true });
await expect(reverse.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot: { ...expectedSnapshot, reversed: true }, slipBySeconds: 1, confirmSlip: true, operationId: "reverse",
})).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(reverse.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays the same operation ID without creating a second host transaction", async () => {
const value = slipHost();
const args = { ...target, expectedSnapshot, slipBySeconds: 1, confirmSlip: true, operationId: "replay-slip" };
await expect(value.registry.dispatch("trackItem.slip", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("trackItem.slip", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.state).toEqual({ start: 10, end: 20, inPoint: 31, outPoint: 41 });
});
it("serializes different operation IDs through preflight, action creation, and readback so a stale later snapshot cannot slip the wrong source", async () => {
const value = slipHost({ pauseFirstSnapshot: true });
const first = value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 1, confirmSlip: true, operationId: "concurrent-first",
});
await value.firstSnapshot;
const second = value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 2, confirmSlip: true, operationId: "concurrent-second",
});
value.releaseFirstSnapshot();
await expect(first).resolves.toMatchObject({ outcome: "verified", after: { inSeconds: 31, outSeconds: 41 } });
await expect(second).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.state).toEqual({ start: 10, end: 20, inPoint: 31, outPoint: 41 });
});
});