- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
1122 lines
60 KiB
TypeScript
Executable File
1122 lines
60 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");
|
|
|
|
function host(options: { transitions?: boolean; commit?: boolean } = {}) {
|
|
const addAction = vi.fn((action: { apply?: () => void }) => {
|
|
action.apply?.();
|
|
return true;
|
|
});
|
|
const transitionState = { start: false, end: true };
|
|
const ticksPerSecond = 1000000;
|
|
const createNativeTickTime = (seconds: number) => Object.defineProperties({ seconds }, {
|
|
ticks: { value: String(Math.round(seconds * ticksPerSecond)) },
|
|
alignToFrame: { value: (frameRate: { value: number }) => createNativeTickTime(Math.floor(seconds * frameRate.value + 1e-9) / frameRate.value) },
|
|
alignToNearestFrame: { value: (frameRate: { value: number }) => createNativeTickTime(Math.round(seconds * frameRate.value) / frameRate.value) },
|
|
});
|
|
const add = vi.fn((_transition: unknown, options: { applyToStart?: boolean }) => ({
|
|
kind: "add", apply: () => { transitionState[options.applyToStart ? "start" : "end"] = true; }
|
|
}));
|
|
const remove = vi.fn((position: number) => ({
|
|
kind: "remove", apply: () => { transitionState[position === 0 ? "start" : "end"] = false; }
|
|
}));
|
|
const liftAction = { kind: "lift" };
|
|
const selectedItem = { guid: "selected-clip" };
|
|
const selection = { getTrackItems: vi.fn(async () => [selectedItem]) };
|
|
const createRemoveItemsAction = vi.fn(() => liftAction);
|
|
const clip = {
|
|
createAddVideoTransitionAction: add,
|
|
createRemoveVideoTransitionAction: remove,
|
|
getProjectItem: vi.fn(async () => ({ getId: vi.fn(async () => "project-item-1") })),
|
|
getStartTime: vi.fn(async () => ({ seconds: 10 })),
|
|
getEndTime: vi.fn(async () => ({ seconds: 20 })),
|
|
hasVideoTransition: vi.fn(async (position: number) => transitionState[position === 0 ? "start" : "end"]),
|
|
};
|
|
const track = { getTrackItems: vi.fn(async () => [clip]) };
|
|
const exportedFrames: string[] = [];
|
|
const exportSequenceFrame = vi.fn(async (_sequence: unknown, _position: unknown, filename: string) => {
|
|
exportedFrames.push(filename);
|
|
return true;
|
|
});
|
|
const range = { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 };
|
|
const playhead = { positionSeconds: 3 };
|
|
const preferenceValues = new Map<string, string>([
|
|
["auto-peak-generation", "0"],
|
|
["import-workspace", "1"],
|
|
["show-quickstart-dialog", "1"],
|
|
]);
|
|
const sequenceProjectItem = { name: "Timeline", getId: vi.fn(() => "sequence-project-item-1") };
|
|
const insertionBin = { name: "Imports", type: 2, getId: vi.fn(async () => "insertion-bin-1") };
|
|
const sequence = {
|
|
guid: "sequence-1",
|
|
name: "Timeline",
|
|
getVideoTrackCount: vi.fn(async () => 1),
|
|
getVideoTrack: vi.fn(async () => track),
|
|
getSelection: vi.fn(async () => selection),
|
|
getPlayerPosition: vi.fn(async () => ({ seconds: playhead.positionSeconds })),
|
|
setPlayerPosition: vi.fn(async (position: { seconds: number }) => {
|
|
playhead.positionSeconds = position.seconds;
|
|
return true;
|
|
}),
|
|
getFrameSize: vi.fn(async () => ({ width: 1920, height: 1080 })),
|
|
getTimebase: vi.fn(async () => "254016000000"),
|
|
getSequenceAudioTimeDisplayFormat: vi.fn(async () => ({ type: 200 })),
|
|
getSequenceVideoTimeDisplayFormat: vi.fn(async () => ({ type: 100 })),
|
|
getProjectItem: vi.fn(async () => sequenceProjectItem),
|
|
getInPoint: vi.fn(async () => ({ seconds: range.inSeconds })),
|
|
getOutPoint: vi.fn(async () => ({ seconds: range.outSeconds })),
|
|
getZeroPoint: vi.fn(async () => ({ seconds: range.zeroPointSeconds })),
|
|
getEndTime: vi.fn(async () => ({ seconds: range.endSeconds })),
|
|
createSetInPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { range.inSeconds = time.seconds; } })),
|
|
createSetOutPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { range.outSeconds = time.seconds; } })),
|
|
createSetZeroPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { range.zeroPointSeconds = time.seconds; } })),
|
|
};
|
|
const project = {
|
|
guid: "project-1",
|
|
name: "Example",
|
|
path: "/projects/example.prproj",
|
|
getActiveSequence: vi.fn(async () => sequence),
|
|
getSequence: vi.fn((guid: { toString?: () => string } | string) => {
|
|
const value = typeof guid === "string" ? guid : String(guid?.toString?.() || "");
|
|
return value === sequence.guid ? sequence : null;
|
|
}),
|
|
getInsertionBin: vi.fn(async () => insertionBin),
|
|
getSequences: vi.fn(async () => [sequence]),
|
|
save: vi.fn(async () => true),
|
|
createSequenceWithPresetPath: vi.fn(async (name: string) => ({ guid: "sequence-2", name })),
|
|
lockedAccess: vi.fn((callback: () => void) => callback()),
|
|
executeTransaction: vi.fn((callback: (compound: unknown) => void) => {
|
|
if (options.commit === false) return false;
|
|
callback({ addAction });
|
|
return true;
|
|
})
|
|
};
|
|
const optionValues: Record<string, unknown> = {};
|
|
const transitionApis = options.transitions === false ? {} : {
|
|
TransitionFactory: {
|
|
getVideoTransitionMatchNames: vi.fn(async () => ["CrossDissolve", "DipToBlack"]),
|
|
createVideoTransition: vi.fn(async (name: string) => ({ name }))
|
|
},
|
|
AddTransitionOptions: vi.fn(() => {
|
|
const options: { applyToStart?: boolean; setApplyToStart: ReturnType<typeof vi.fn>; setDuration: ReturnType<typeof vi.fn>; setForceSingleSided: ReturnType<typeof vi.fn>; setTransitionAlignment: ReturnType<typeof vi.fn> } = {
|
|
setApplyToStart: vi.fn((value: boolean) => { optionValues.applyToStart = value; options.applyToStart = value; }),
|
|
setDuration: vi.fn((value: unknown) => { optionValues.duration = value; }),
|
|
setForceSingleSided: vi.fn((value: boolean) => { optionValues.forceSingleSided = value; }),
|
|
setTransitionAlignment: vi.fn((value: number) => { optionValues.transitionAlignment = value; })
|
|
};
|
|
return options;
|
|
})
|
|
};
|
|
const ppro = {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
Guid: { fromString: vi.fn((value: string) => ({ toString: () => value })) },
|
|
FrameRate: { createWithValue: vi.fn((value: number) => ({ value, ticksPerFrame: ticksPerSecond / value })) },
|
|
TickTime: {
|
|
createWithSeconds: vi.fn((seconds: number) => createNativeTickTime(seconds)),
|
|
createWithFrameAndFrameRate: vi.fn((frameCount: number, frameRate: { value: number }) => createNativeTickTime(frameCount / frameRate.value)),
|
|
},
|
|
Constants: { TrackItemType: { CLIP: 1 }, MediaType: { ANY: 0 }, TransitionPosition: { START: 0, END: 1 } },
|
|
AppPreference: {
|
|
KEY_AUTO_PEAK_GENERATION: "auto-peak-generation",
|
|
KEY_IMPORT_WORKSPACE: "import-workspace",
|
|
KEY_SHOW_QUICKSTART_DIALOG: "show-quickstart-dialog",
|
|
PROPERTY_PERSISTENT: 1,
|
|
PROPERTY_NON_PERSISTENT: 2,
|
|
getValue: vi.fn((key: string) => preferenceValues.get(key)),
|
|
setValue: vi.fn((key: string, value: string) => { preferenceValues.set(key, value); return true; }),
|
|
},
|
|
SequenceEditor: {
|
|
getEditor: vi.fn(async () => ({ createRemoveItemsAction })),
|
|
getInstalledMogrtPath: vi.fn(async () => "C:\\ProgramData\\Adobe\\Common\\Motion Graphics Templates"),
|
|
},
|
|
ProjectConverter: {
|
|
exportAsOpenTimelineIO: vi.fn(async () => true),
|
|
exportAsFinalCutProXML: vi.fn(async () => true),
|
|
},
|
|
Transcript: { querySupportedLanguages: vi.fn(async () => [{ languageCode: "en-US" }]) },
|
|
ObjectMaskUtils: { hasObjectMask: vi.fn(() => true) },
|
|
EncoderManager: {
|
|
launchEncoder: vi.fn(async () => true),
|
|
setEmbeddedXMPEnabled: vi.fn(async () => true),
|
|
setSidecarXMPEnabled: vi.fn(async () => true),
|
|
startBatchEncode: vi.fn(async () => true),
|
|
},
|
|
Exporter: { exportSequenceFrame },
|
|
...transitionApis
|
|
};
|
|
return { registry: Commands.createCommandRegistry({ ppro, fs: {}, Protocol }), ppro, project, sequence, sequenceProjectItem, insertionBin, range, playhead, preferenceValues, track, clip, add, remove, addAction, optionValues, selection, createRemoveItemsAction, exportSequenceFrame, exportedFrames, transitionState };
|
|
}
|
|
|
|
function expectedTransition(position: "start" | "end", transitionPresent: boolean) {
|
|
return {
|
|
sequenceGuid: "sequence-1", videoTrackIndex: 0, clipIndex: 0, projectItemId: "project-item-1",
|
|
startSeconds: 10, endSeconds: 20, position, transitionPresent,
|
|
};
|
|
}
|
|
|
|
describe("UXP command registry", () => {
|
|
it("reports support per command from the runtime API surface", async () => {
|
|
const available = await host().registry.capabilities();
|
|
expect(available.commands["transition.video.add"]).toMatchObject({ supported: true, destructive: true, undoable: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["timeline.selection.lift"]).toMatchObject({ supported: true, destructive: true, undoable: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["sequence.range.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["sequence.range.update"]).toMatchObject({ supported: true, destructive: true, undoable: true, idempotent: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["sequence.playhead.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["sequence.playhead.set"]).toMatchObject({ supported: true, destructive: false, undoable: false, idempotent: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["time.frameAlignment.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["sequence.timing.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["sequence.timingByGuid.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0", targetCapabilityProbe: "invocation" });
|
|
expect(available.commands["graphics.mogrtPath.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["preferences.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["preferences.set"]).toMatchObject({ supported: true, destructive: false, undoable: false, idempotent: true, minHostVersion: "25.6.0" });
|
|
expect(available.commands["project.insertionBin.inspect"]).toMatchObject({ supported: true, readOnly: true, minHostVersion: "25.6.0" });
|
|
for (const command of ["sequence.createPreset", "interchange.export", "interchange.aaf.export", "frame.export"]) {
|
|
expect(available.commands[command]).toMatchObject({ workspaceRequired: true });
|
|
}
|
|
const unavailable = await host({ transitions: false }).registry.capabilities();
|
|
expect(unavailable.commands["transition.video.add"]).toMatchObject({ supported: false, reason: expect.any(String) });
|
|
});
|
|
|
|
it("requires an explicit available canonical-path state for path command discovery", async () => {
|
|
const value = host();
|
|
const missingState = Commands.createCommandRegistry({
|
|
ppro: value.ppro,
|
|
Protocol,
|
|
workspace: { status: () => ({ configured: true }), assertPathAllowed: (path: string) => path },
|
|
});
|
|
await expect(missingState.capabilities()).resolves.toMatchObject({
|
|
commands: { "sequence.createPreset": { supported: false, workspaceRequired: true } },
|
|
});
|
|
|
|
const availableState = Commands.createCommandRegistry({
|
|
ppro: value.ppro,
|
|
Protocol,
|
|
workspace: { status: () => ({ configured: true, canonicalPathValidation: "available" }), assertPathAllowed: (path: string) => path },
|
|
});
|
|
await expect(availableState.capabilities()).resolves.toMatchObject({
|
|
commands: { "sequence.createPreset": { supported: true, workspaceRequired: true } },
|
|
});
|
|
});
|
|
|
|
it("lists installed video transition match names", async () => {
|
|
await expect(host().registry.dispatch("transition.video.list", {})).resolves.toEqual({
|
|
matchNames: ["CrossDissolve", "DipToBlack"], count: 2
|
|
});
|
|
});
|
|
|
|
it("reads a bounded native insertion-bin snapshot and rejects a changing target", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("project.insertionBin.inspect", {})).resolves.toEqual({
|
|
projectGuid: "project-1",
|
|
insertionBin: { projectItemId: "insertion-bin-1", name: "Imports", type: 2 },
|
|
verificationBoundary: "project_insertion_bin_identity_readback",
|
|
});
|
|
expect(value.project.getInsertionBin).toHaveBeenCalledTimes(2);
|
|
|
|
const changed = host();
|
|
changed.project.getInsertionBin
|
|
.mockResolvedValueOnce(changed.insertionBin)
|
|
.mockResolvedValueOnce({ name: "Graphics", type: 2, getId: vi.fn(async () => "insertion-bin-2") });
|
|
await expect(changed.registry.dispatch("project.insertionBin.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_STALE_INSERTION_BIN" });
|
|
});
|
|
|
|
it("rejects an insertion-bin snapshot when the active project changes during readback", async () => {
|
|
const value = host();
|
|
value.ppro.Project.getActiveProject
|
|
.mockResolvedValueOnce(value.project)
|
|
.mockResolvedValueOnce({ ...value.project, guid: "project-2" });
|
|
await expect(value.registry.dispatch("project.insertionBin.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_STALE_PROJECT" });
|
|
expect(value.project.getInsertionBin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("inspects a bounded native video-transition target before mutation", async () => {
|
|
await expect(host().registry.dispatch("transition.video.inspect", {
|
|
videoTrackIndex: 0, clipIndex: 0, position: "end",
|
|
})).resolves.toEqual({
|
|
target: expectedTransition("end", true),
|
|
verificationBoundary: "video_transition_target_readback",
|
|
});
|
|
});
|
|
|
|
it("accepts a transition match name returned by an earlier list when host enumeration changes", async () => {
|
|
const value = host();
|
|
value.ppro.TransitionFactory.getVideoTransitionMatchNames
|
|
.mockResolvedValueOnce(["ADBE Cross Dissolve New"])
|
|
.mockResolvedValueOnce(["ADBE Additive Dissolve"]);
|
|
|
|
await expect(value.registry.dispatch("transition.video.list", {})).resolves.toEqual({
|
|
matchNames: ["ADBE Cross Dissolve New"], count: 1,
|
|
});
|
|
await expect(value.registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "ADBE Cross Dissolve New", position: "start",
|
|
expectedTarget: expectedTransition("start", false),
|
|
})).resolves.toMatchObject({ applied: true, matchName: "ADBE Cross Dissolve New" });
|
|
expect(value.ppro.TransitionFactory.createVideoTransition).toHaveBeenCalledWith("ADBE Cross Dissolve New");
|
|
});
|
|
|
|
it("exports a PNG with a bare host filename and a one-extension returned path", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("frame.export", {
|
|
outputDirectory: "C:/approved", filename: "frame.png",
|
|
})).resolves.toMatchObject({ path: "C:/approved/frame.png", exporterResult: true });
|
|
expect(value.exportSequenceFrame).toHaveBeenCalledWith(
|
|
value.sequence, { seconds: 3 }, "frame", "C:/approved", 1920, 1080,
|
|
);
|
|
expect(value.exportedFrames).toEqual(["frame"]);
|
|
});
|
|
|
|
it("does not report a frame path when Premiere rejects the export", async () => {
|
|
const value = host();
|
|
value.exportSequenceFrame.mockResolvedValueOnce(false);
|
|
await expect(value.registry.dispatch("frame.export", {
|
|
outputDirectory: "C:/approved", filename: "frame.png",
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
});
|
|
|
|
it("returns a stable compact project snapshot", async () => {
|
|
await expect(host().registry.dispatch("project.snapshot", {})).resolves.toMatchObject({
|
|
revision: expect.stringMatching(/^uxp-/),
|
|
project: { guid: "project-1", name: "Example" },
|
|
activeSequenceGuid: "sequence-1",
|
|
sequences: [{ guid: "sequence-1", name: "Timeline" }],
|
|
});
|
|
});
|
|
|
|
it("inspects and transactionally updates a complete guarded sequence range", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("sequence.range.inspect", {})).resolves.toEqual({
|
|
sequenceGuid: "sequence-1",
|
|
range: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
verificationBoundary: "sequence_range_readback",
|
|
});
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2, outSeconds: 110, zeroPointSeconds: 7200 },
|
|
operationId: "range-1",
|
|
})).resolves.toMatchObject({
|
|
updated: true,
|
|
outcome: "verified",
|
|
sequenceGuid: "sequence-1",
|
|
range: { inSeconds: 2, outSeconds: 110, zeroPointSeconds: 7200, endSeconds: 120 },
|
|
operation: {
|
|
mutatesProject: true,
|
|
verification: { status: "verified" },
|
|
undo: { supported: true },
|
|
cancellation: { supported: false },
|
|
},
|
|
operationId: "range-1",
|
|
});
|
|
expect(value.project.lockedAccess).toHaveBeenCalledOnce();
|
|
expect(value.project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Update sequence range");
|
|
expect(value.sequence.createSetInPointAction).toHaveBeenCalledWith({ seconds: 2 });
|
|
expect(value.sequence.createSetOutPointAction).toHaveBeenCalledWith({ seconds: 110 });
|
|
expect(value.sequence.createSetZeroPointAction).toHaveBeenCalledWith({ seconds: 7200 });
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2, outSeconds: 110, zeroPointSeconds: 7200 },
|
|
operationId: "range-1",
|
|
})).resolves.toMatchObject({ replayed: true });
|
|
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("serializes concurrent sequence-range updates with different operation IDs", async () => {
|
|
const value = host();
|
|
const expectedRange = { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 };
|
|
const first = value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange,
|
|
updates: { inSeconds: 2 },
|
|
operationId: "range-concurrent-first",
|
|
});
|
|
const second = value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange,
|
|
updates: { outSeconds: 110 },
|
|
operationId: "range-concurrent-second",
|
|
});
|
|
|
|
await expect(first).resolves.toMatchObject({
|
|
updated: true,
|
|
operationId: "range-concurrent-first",
|
|
range: { inSeconds: 2, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
});
|
|
await expect(second).rejects.toMatchObject({ code: "UXP_STALE_RANGE" });
|
|
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
|
|
expect(value.range).toEqual({ inSeconds: 2, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 });
|
|
});
|
|
|
|
it("inspects, guardedly sets, and replays the sequence player position", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("sequence.playhead.inspect", {})).resolves.toEqual({
|
|
sequenceGuid: "sequence-1",
|
|
positionSeconds: 3,
|
|
verificationBoundary: "sequence_playhead_readback",
|
|
});
|
|
const args = {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedPositionSeconds: 3,
|
|
positionSeconds: 8,
|
|
operationId: "playhead-1",
|
|
};
|
|
await expect(value.registry.dispatch("sequence.playhead.set", args)).resolves.toMatchObject({
|
|
positioned: true,
|
|
outcome: "verified",
|
|
sequenceGuid: "sequence-1",
|
|
positionSeconds: 8,
|
|
verified: "sequence_playhead_readback",
|
|
operationId: "playhead-1",
|
|
operation: {
|
|
mutatesProject: false,
|
|
verification: { status: "verified", boundary: "sequence_playhead_readback" },
|
|
undo: { supported: false },
|
|
cancellation: { supported: false },
|
|
},
|
|
});
|
|
expect(value.sequence.setPlayerPosition).toHaveBeenCalledWith({ seconds: 8 });
|
|
expect(value.project.lockedAccess).not.toHaveBeenCalled();
|
|
await expect(value.registry.dispatch("sequence.playhead.set", args)).resolves.toMatchObject({ replayed: true });
|
|
expect(value.sequence.setPlayerPosition).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("inspects, guardedly sets, and replays bounded documented app preferences", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("preferences.inspect", {})).resolves.toEqual({
|
|
preferences: [
|
|
{ preference: "auto_peak_generation", value: "0" },
|
|
{ preference: "import_workspace", value: "1" },
|
|
{ preference: "show_quickstart_dialog", value: "1" },
|
|
],
|
|
verificationBoundary: "app_preference_native_string_readback",
|
|
});
|
|
const args = {
|
|
preference: "auto_peak_generation",
|
|
expectedValue: "0",
|
|
value: "1",
|
|
persistence: "persistent",
|
|
confirmPreferenceChange: true,
|
|
operationId: "app-preference-1",
|
|
};
|
|
await expect(value.registry.dispatch("preferences.set", args)).resolves.toMatchObject({
|
|
updated: true,
|
|
outcome: "verified",
|
|
preference: "auto_peak_generation",
|
|
beforeValue: "0",
|
|
value: "1",
|
|
persistence: "persistent",
|
|
verified: "app_preference_native_string_readback",
|
|
operationId: "app-preference-1",
|
|
operation: {
|
|
mutatesProject: false,
|
|
verification: { status: "verified", boundary: "app_preference_native_string_readback" },
|
|
undo: { supported: false },
|
|
cancellation: { supported: false },
|
|
},
|
|
});
|
|
expect(value.ppro.AppPreference.setValue).toHaveBeenCalledWith("auto-peak-generation", "1", 1);
|
|
await expect(value.registry.dispatch("preferences.set", args)).resolves.toMatchObject({ replayed: true });
|
|
expect(value.ppro.AppPreference.setValue).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("serializes competing app-preference writes and fails closed on stale or unverified values", async () => {
|
|
const value = host();
|
|
const first = value.registry.dispatch("preferences.set", {
|
|
preference: "import_workspace", expectedValue: "1", value: "0", persistence: "non_persistent",
|
|
confirmPreferenceChange: true, operationId: "app-preference-concurrent-first",
|
|
});
|
|
const second = value.registry.dispatch("preferences.set", {
|
|
preference: "import_workspace", expectedValue: "1", value: "2", persistence: "persistent",
|
|
confirmPreferenceChange: true, operationId: "app-preference-concurrent-second",
|
|
});
|
|
await expect(first).resolves.toMatchObject({ updated: true, value: "0", operationId: "app-preference-concurrent-first" });
|
|
await expect(second).rejects.toMatchObject({ code: "UXP_STALE_APP_PREFERENCE" });
|
|
expect(value.ppro.AppPreference.setValue).toHaveBeenCalledOnce();
|
|
|
|
const stale = host();
|
|
stale.preferenceValues.set("show-quickstart-dialog", "0");
|
|
await expect(stale.registry.dispatch("preferences.set", {
|
|
preference: "show_quickstart_dialog", expectedValue: "1", value: "0", persistence: "persistent",
|
|
confirmPreferenceChange: true, operationId: "app-preference-stale",
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_APP_PREFERENCE" });
|
|
expect(stale.ppro.AppPreference.setValue).not.toHaveBeenCalled();
|
|
|
|
const noReadback = host();
|
|
noReadback.ppro.AppPreference.setValue.mockImplementationOnce(() => true);
|
|
await expect(noReadback.registry.dispatch("preferences.set", {
|
|
preference: "show_quickstart_dialog", expectedValue: "1", value: "0", persistence: "persistent",
|
|
confirmPreferenceChange: true, operationId: "app-preference-readback",
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
});
|
|
|
|
it("rejects malformed, unconfirmed, unsupported, and unavailable app-preference writes", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("preferences.set", {
|
|
preference: "auto_peak_generation", expectedValue: "0", value: "1", persistence: "persistent", operationId: "app-preference-unconfirmed",
|
|
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
|
|
await expect(value.registry.dispatch("preferences.set", {
|
|
preference: "unknown", expectedValue: "0", value: "1", persistence: "persistent", confirmPreferenceChange: true, operationId: "app-preference-invalid",
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("preferences.set", {
|
|
preference: "auto_peak_generation", expectedValue: "0", value: "1", persistence: "persistent", confirmPreferenceChange: true,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("preferences.inspect", { extra: true }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
|
|
const unavailable = host();
|
|
unavailable.ppro.AppPreference.setValue = undefined;
|
|
const capabilities = await unavailable.registry.capabilities();
|
|
expect(capabilities.commands["preferences.inspect"]).toMatchObject({ supported: true });
|
|
expect(capabilities.commands["preferences.set"]).toMatchObject({ supported: false, reason: expect.any(String) });
|
|
});
|
|
|
|
it("uses only native TickTime and FrameRate values for bounded frame alignment", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("time.frameAlignment.inspect", {
|
|
action: "align", frameRate: 24, seconds: 1.03,
|
|
})).resolves.toMatchObject({
|
|
action: "align",
|
|
requested: { seconds: 1.03, frameRate: 24 },
|
|
frameRate: { value: 24 },
|
|
input: { seconds: 1.03, ticks: "1030000" },
|
|
aligned: { down: { seconds: 1 }, nearest: { seconds: 1.0416666666666667 } },
|
|
verificationBoundary: "native_ticktime_value_readback",
|
|
});
|
|
expect(value.ppro.FrameRate.createWithValue).toHaveBeenCalledWith(24);
|
|
expect(value.ppro.TickTime.createWithSeconds).toHaveBeenCalledWith(1.03);
|
|
|
|
await expect(value.registry.dispatch("time.frameAlignment.inspect", {
|
|
action: "frame", frameRate: 30000 / 1001, frameCount: 30,
|
|
})).resolves.toMatchObject({
|
|
action: "frame",
|
|
requested: { frameCount: 30, frameRate: 30000 / 1001 },
|
|
value: { seconds: 1.0010000000000001 },
|
|
verificationBoundary: "native_ticktime_value_readback",
|
|
});
|
|
expect(value.ppro.TickTime.createWithFrameAndFrameRate).toHaveBeenCalledWith(30, expect.objectContaining({ value: 30000 / 1001 }));
|
|
});
|
|
|
|
it("fails closed for malformed frame-alignment inputs and invalid native readback", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("time.frameAlignment.inspect", { action: "align", frameRate: 24 }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("time.frameAlignment.inspect", { action: "frame", frameRate: 24, seconds: 1, frameCount: 1 }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("time.frameAlignment.inspect", { action: "frame", frameRate: 24, frameCount: 20736001 }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("time.frameAlignment.inspect", { action: "align", frameRate: 24, seconds: 1, extra: true }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
|
|
const malformed = host();
|
|
malformed.ppro.TickTime.createWithSeconds.mockReturnValueOnce({ seconds: 1, ticks: "not-ticks", alignToFrame: () => ({ seconds: 1, ticks: "1000000" }), alignToNearestFrame: () => ({ seconds: 1, ticks: "1000000" }) });
|
|
await expect(malformed.registry.dispatch("time.frameAlignment.inspect", { action: "align", frameRate: 24, seconds: 1 }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_HOST_STATE" });
|
|
|
|
const unavailable = host();
|
|
unavailable.ppro.TickTime.createWithFrameAndFrameRate = undefined;
|
|
await expect(unavailable.registry.capabilities()).resolves.toMatchObject({
|
|
commands: { "time.frameAlignment.inspect": { supported: false, reason: expect.any(String) } },
|
|
});
|
|
});
|
|
|
|
it("returns a bounded native sequence-timing snapshot and rejects a final active-sequence mismatch", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("sequence.timing.inspect", {})).resolves.toEqual({
|
|
sequenceGuid: "sequence-1",
|
|
sequenceName: "Timeline",
|
|
frameSize: { width: 1920, height: 1080 },
|
|
timebase: "254016000000",
|
|
audioTimeDisplayFormat: { type: 200 },
|
|
videoTimeDisplayFormat: { type: 100 },
|
|
projectItem: { id: "sequence-project-item-1", name: "Timeline" },
|
|
verificationBoundary: "sequence_timing_readback",
|
|
});
|
|
|
|
const changed = host();
|
|
changed.sequence.getTimebase.mockImplementation(async () => {
|
|
changed.project.getActiveSequence.mockResolvedValueOnce({ ...changed.sequence, guid: "sequence-2" });
|
|
return "254016000000";
|
|
});
|
|
await expect(changed.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE" });
|
|
});
|
|
|
|
it("redacts the documented installed MOGRT directory unless path disclosure is explicitly requested", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("graphics.mogrtPath.inspect", {})).resolves.toEqual({
|
|
available: true,
|
|
installedMogrtPath: null,
|
|
pathDisclosure: "redacted",
|
|
verificationBoundary: "documented_sequence_editor_installation_directory_readback",
|
|
});
|
|
await expect(value.registry.dispatch("graphics.mogrtPath.inspect", { includePath: true })).resolves.toEqual({
|
|
available: true,
|
|
installedMogrtPath: "C:\\ProgramData\\Adobe\\Common\\Motion Graphics Templates",
|
|
pathDisclosure: "requested",
|
|
verificationBoundary: "documented_sequence_editor_installation_directory_readback",
|
|
});
|
|
expect(value.ppro.SequenceEditor.getInstalledMogrtPath).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("fails closed for invalid installed MOGRT directories, unsupported getters, and invalid arguments", async () => {
|
|
const invalid = host();
|
|
invalid.ppro.SequenceEditor.getInstalledMogrtPath.mockResolvedValueOnce(" ");
|
|
await expect(invalid.registry.dispatch("graphics.mogrtPath.inspect", { includePath: true }))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const nul = host();
|
|
nul.ppro.SequenceEditor.getInstalledMogrtPath.mockResolvedValueOnce(["C:\\Templates", "hidden"].join("\0"));
|
|
await expect(nul.registry.dispatch("graphics.mogrtPath.inspect", { includePath: true }))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
await expect(host().registry.dispatch("graphics.mogrtPath.inspect", { includePath: "true" }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(host().registry.dispatch("graphics.mogrtPath.inspect", { extra: true }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
|
|
const unavailable = host();
|
|
unavailable.ppro.SequenceEditor.getInstalledMogrtPath = undefined;
|
|
await expect(unavailable.registry.capabilities()).resolves.toMatchObject({
|
|
commands: { "graphics.mogrtPath.inspect": { supported: false, reason: expect.any(String) } },
|
|
});
|
|
});
|
|
|
|
it("inspects one non-active sequence by documented GUID lookup without activating it and rejects changing double-read state", async () => {
|
|
const value = host();
|
|
const targetProjectItem = { name: "Nested Timeline", getId: vi.fn(async () => "sequence-project-item-2") };
|
|
const target = {
|
|
...value.sequence,
|
|
guid: "sequence-2",
|
|
name: "Nested Timeline",
|
|
getFrameSize: vi.fn(async () => ({ width: 3840, height: 2160 })),
|
|
getTimebase: vi.fn(async () => "508032000000"),
|
|
getSequenceAudioTimeDisplayFormat: vi.fn(async () => ({ type: 201 })),
|
|
getSequenceVideoTimeDisplayFormat: vi.fn(async () => ({ type: 101 })),
|
|
getProjectItem: vi.fn(async () => targetProjectItem),
|
|
};
|
|
value.project.getSequence.mockImplementation((guid: { toString?: () => string } | string) => {
|
|
const requested = typeof guid === "string" ? guid : String(guid?.toString?.() || "");
|
|
return requested === "sequence-2" ? target : null;
|
|
});
|
|
await expect(value.registry.dispatch("sequence.timingByGuid.inspect", { sequenceGuid: "sequence-2" })).resolves.toMatchObject({
|
|
projectGuid: "project-1", requestedSequenceGuid: "sequence-2", sequenceGuid: "sequence-2",
|
|
sequenceName: "Nested Timeline", frameSize: { width: 3840, height: 2160 },
|
|
projectItem: { id: "sequence-project-item-2", name: "Nested Timeline" },
|
|
verificationBoundary: "targeted_sequence_timing_double_readback",
|
|
});
|
|
expect(value.project.getActiveSequence).not.toHaveBeenCalled();
|
|
expect(value.ppro.Guid.fromString).toHaveBeenCalledWith("sequence-2");
|
|
expect(value.project.getSequence).toHaveBeenCalledTimes(2);
|
|
|
|
const switchedProject = host();
|
|
const switchedTarget = { ...switchedProject.sequence, guid: "sequence-2", name: "Nested Timeline" };
|
|
switchedProject.project.getSequence.mockReturnValue(switchedTarget);
|
|
const replacementProject = { ...switchedProject.project, guid: "project-2" };
|
|
switchedProject.ppro.Project.getActiveProject
|
|
.mockResolvedValueOnce(switchedProject.project)
|
|
.mockResolvedValueOnce(replacementProject);
|
|
await expect(switchedProject.registry.dispatch("sequence.timingByGuid.inspect", { sequenceGuid: "sequence-2" }))
|
|
.rejects.toMatchObject({ code: "UXP_STALE_PROJECT" });
|
|
|
|
const changed = host();
|
|
const first = { ...changed.sequence, guid: "sequence-2", name: "Nested Timeline" };
|
|
const final = { ...changed.sequence, guid: "sequence-2", name: "Nested Timeline", getTimebase: vi.fn(async () => "508032000000") };
|
|
changed.project.getSequence.mockReturnValueOnce(first).mockReturnValueOnce(final);
|
|
await expect(changed.registry.dispatch("sequence.timingByGuid.inspect", { sequenceGuid: "sequence-2" }))
|
|
.rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE" });
|
|
});
|
|
|
|
it("fails closed for malformed timing values, identity, unknown arguments, and unavailable timing APIs", async () => {
|
|
const invalidDisplay = host();
|
|
invalidDisplay.sequence.getSequenceAudioTimeDisplayFormat.mockResolvedValueOnce({ type: Number.NaN });
|
|
await expect(invalidDisplay.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const invalidFrame = host();
|
|
invalidFrame.sequence.getFrameSize.mockResolvedValueOnce({ width: 0, height: 1080 });
|
|
await expect(invalidFrame.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const fractionalFrame = host();
|
|
fractionalFrame.sequence.getFrameSize.mockResolvedValueOnce({ width: 1920.5, height: 1080 });
|
|
await expect(fractionalFrame.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const oversizedFrame = host();
|
|
oversizedFrame.sequence.getFrameSize.mockResolvedValueOnce({ width: 10241, height: 8193 });
|
|
await expect(oversizedFrame.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const negativeDisplayCode = host();
|
|
negativeDisplayCode.sequence.getSequenceVideoTimeDisplayFormat.mockResolvedValueOnce({ type: -1 });
|
|
await expect(negativeDisplayCode.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const fractionalDisplayCode = host();
|
|
fractionalDisplayCode.sequence.getSequenceVideoTimeDisplayFormat.mockResolvedValueOnce({ type: 100.5 });
|
|
await expect(fractionalDisplayCode.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const invalidTimebase = host();
|
|
invalidTimebase.sequence.getTimebase.mockResolvedValueOnce(123);
|
|
await expect(invalidTimebase.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const blankTimebase = host();
|
|
blankTimebase.sequence.getTimebase.mockResolvedValueOnce(" ");
|
|
await expect(blankTimebase.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
for (const malformedTimebase of ["not-a-timebase", "0", "-1", "1.5", "1234567890123456789"]) {
|
|
const malformedTimebaseHost = host();
|
|
malformedTimebaseHost.sequence.getTimebase.mockResolvedValueOnce(malformedTimebase);
|
|
await expect(malformedTimebaseHost.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
}
|
|
|
|
const whitespaceSequenceGuid = host();
|
|
whitespaceSequenceGuid.sequence.guid = " ";
|
|
await expect(whitespaceSequenceGuid.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const nonGuidSequenceIdentity = host();
|
|
nonGuidSequenceIdentity.sequence.guid = 123;
|
|
await expect(nonGuidSequenceIdentity.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const nativeGuidObject = host();
|
|
nativeGuidObject.sequence.guid = { toString: vi.fn(() => "sequence-guid-object") };
|
|
await expect(nativeGuidObject.registry.dispatch("sequence.timing.inspect", {}))
|
|
.resolves.toMatchObject({ sequenceGuid: "sequence-guid-object" });
|
|
|
|
const blankProjectItemName = host();
|
|
blankProjectItemName.sequenceProjectItem.name = "";
|
|
await expect(blankProjectItemName.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const blankProjectItemId = host();
|
|
blankProjectItemId.sequenceProjectItem.getId.mockReturnValueOnce(" ");
|
|
await expect(blankProjectItemId.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const nonStringProjectItemId = host();
|
|
nonStringProjectItemId.sequenceProjectItem.getId.mockReturnValueOnce(123);
|
|
await expect(nonStringProjectItemId.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const missingProjectItem = host();
|
|
missingProjectItem.sequence.getProjectItem.mockResolvedValueOnce(null);
|
|
await expect(missingProjectItem.registry.dispatch("sequence.timing.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
await expect(host().registry.dispatch("sequence.timing.inspect", { extra: true }))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
|
|
const unavailable = host();
|
|
unavailable.sequence.getTimebase = undefined;
|
|
const capabilities = await unavailable.registry.capabilities();
|
|
expect(capabilities.commands["sequence.timing.inspect"])
|
|
.toMatchObject({ supported: false, reason: expect.any(String) });
|
|
|
|
await expect(host().registry.dispatch("sequence.timingByGuid.inspect", {}))
|
|
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(host().registry.dispatch("sequence.timingByGuid.inspect", { sequenceGuid: "missing" }))
|
|
.rejects.toMatchObject({ code: "UXP_TARGET_NOT_FOUND" });
|
|
const missingGuid = host();
|
|
missingGuid.ppro.Guid.fromString = undefined;
|
|
const byGuidCapabilities = await missingGuid.registry.capabilities();
|
|
expect(byGuidCapabilities.commands["sequence.timingByGuid.inspect"])
|
|
.toMatchObject({ supported: false, reason: expect.any(String) });
|
|
});
|
|
|
|
it("serializes concurrent sequence player-position requests with different operation IDs", async () => {
|
|
const value = host();
|
|
const first = value.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 8, operationId: "playhead-concurrent-first",
|
|
});
|
|
const second = value.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 9, operationId: "playhead-concurrent-second",
|
|
});
|
|
|
|
await expect(first).resolves.toMatchObject({ positioned: true, operationId: "playhead-concurrent-first", positionSeconds: 8 });
|
|
await expect(second).rejects.toMatchObject({ code: "UXP_STALE_PLAYHEAD" });
|
|
expect(value.sequence.setPlayerPosition).toHaveBeenCalledOnce();
|
|
expect(value.playhead).toEqual({ positionSeconds: 8 });
|
|
});
|
|
|
|
it("fails closed for malformed, stale, rejected, unreadable, and unavailable sequence player-position setters", async () => {
|
|
const malformed = host();
|
|
await expect(malformed.registry.dispatch("sequence.playhead.set", {
|
|
expectedPositionSeconds: 3, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(malformed.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: Number.NaN, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(malformed.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 86400.001,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(malformed.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 8, unexpected: true,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
expect(malformed.sequence.setPlayerPosition).not.toHaveBeenCalled();
|
|
|
|
const staleSequence = host();
|
|
await expect(staleSequence.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "other-sequence", expectedPositionSeconds: 3, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE" });
|
|
expect(staleSequence.sequence.setPlayerPosition).not.toHaveBeenCalled();
|
|
|
|
const staleDuringConversion = host();
|
|
staleDuringConversion.ppro.TickTime.createWithSeconds.mockImplementation((seconds: number) => {
|
|
staleDuringConversion.playhead.positionSeconds = 4;
|
|
return { seconds };
|
|
});
|
|
await expect(staleDuringConversion.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_PLAYHEAD" });
|
|
expect(staleDuringConversion.sequence.setPlayerPosition).not.toHaveBeenCalled();
|
|
|
|
const rejected = host();
|
|
rejected.sequence.setPlayerPosition.mockResolvedValueOnce(false);
|
|
await expect(rejected.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const noReadback = host();
|
|
noReadback.sequence.setPlayerPosition.mockImplementationOnce(async () => true);
|
|
await expect(noReadback.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const changedSequence = host();
|
|
const newlyActiveSequence = {
|
|
...changedSequence.sequence,
|
|
guid: "sequence-2",
|
|
getPlayerPosition: vi.fn(async () => ({ seconds: 8 })),
|
|
};
|
|
changedSequence.sequence.setPlayerPosition.mockImplementationOnce(async (position: { seconds: number }) => {
|
|
changedSequence.playhead.positionSeconds = position.seconds;
|
|
changedSequence.project.getActiveSequence.mockResolvedValueOnce(newlyActiveSequence);
|
|
return true;
|
|
});
|
|
await expect(changedSequence.registry.dispatch("sequence.playhead.set", {
|
|
expectedSequenceGuid: "sequence-1", expectedPositionSeconds: 3, positionSeconds: 8,
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
expect(changedSequence.sequence.setPlayerPosition).toHaveBeenCalledOnce();
|
|
|
|
const unavailable = host();
|
|
unavailable.sequence.setPlayerPosition = undefined;
|
|
const capabilities = await unavailable.registry.capabilities();
|
|
expect(capabilities.commands["sequence.playhead.inspect"]).toMatchObject({ supported: true });
|
|
expect(capabilities.commands["sequence.playhead.set"]).toMatchObject({ supported: false, reason: expect.any(String) });
|
|
});
|
|
|
|
it("rejects a range change that occurs while TickTime values are being created", async () => {
|
|
const value = host();
|
|
value.ppro.TickTime.createWithSeconds.mockImplementation((seconds: number) => {
|
|
value.range.outSeconds = 99;
|
|
return { seconds };
|
|
});
|
|
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2 },
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_RANGE" });
|
|
expect(value.ppro.TickTime.createWithSeconds).toHaveBeenCalledWith(2);
|
|
expect(value.project.lockedAccess).not.toHaveBeenCalled();
|
|
expect(value.sequence.createSetInPointAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed for stale, malformed, and out-of-bounds sequence-range updates", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "other-sequence",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2 },
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE" });
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 0, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2 },
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_RANGE" });
|
|
const durationChanged = host();
|
|
durationChanged.range.endSeconds = 119;
|
|
await expect(durationChanged.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2 },
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_RANGE" });
|
|
expect(durationChanged.project.executeTransaction).not.toHaveBeenCalled();
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 111, outSeconds: 110 },
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: {},
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
expect(value.sequence.createSetInPointAction).not.toHaveBeenCalled();
|
|
expect(value.project.executeTransaction).not.toHaveBeenCalled();
|
|
|
|
const rejectedAction = host();
|
|
rejectedAction.sequence.createSetInPointAction.mockReturnValue(undefined);
|
|
await expect(rejectedAction.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2 },
|
|
})).rejects.toMatchObject({ code: "UXP_ACTION_REJECTED" });
|
|
});
|
|
|
|
it("rejects failed range readback and advertises only the supported command variant", async () => {
|
|
const value = host();
|
|
value.sequence.getOutPoint
|
|
.mockResolvedValueOnce({ seconds: 100 })
|
|
.mockResolvedValueOnce({ seconds: 99 });
|
|
await expect(value.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { outSeconds: 110 },
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const durationChanged = host();
|
|
durationChanged.sequence.getEndTime
|
|
.mockResolvedValueOnce({ seconds: 120 })
|
|
.mockResolvedValueOnce({ seconds: 119 });
|
|
await expect(durationChanged.registry.dispatch("sequence.range.update", {
|
|
expectedSequenceGuid: "sequence-1",
|
|
expectedRange: { inSeconds: 1, outSeconds: 100, zeroPointSeconds: 3600, endSeconds: 120 },
|
|
updates: { inSeconds: 2 },
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
|
|
const unavailable = host();
|
|
unavailable.sequence.createSetZeroPointAction = undefined;
|
|
const capabilities = await unavailable.registry.capabilities();
|
|
expect(capabilities.commands["sequence.range.inspect"]).toMatchObject({ supported: true });
|
|
expect(capabilities.commands["sequence.range.update"]).toMatchObject({ supported: false, reason: expect.any(String) });
|
|
});
|
|
|
|
it("deduplicates completed mutations by operation id", async () => {
|
|
const value = host();
|
|
const args = { operationId: "save-123" };
|
|
await expect(value.registry.dispatch("project.save", args)).resolves.toMatchObject({
|
|
saved: true, outcome: "verified", operationId: "save-123",
|
|
});
|
|
await expect(value.registry.dispatch("project.save", args)).resolves.toMatchObject({
|
|
saved: true, replayed: true,
|
|
});
|
|
expect(value.project.save).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("coalesces concurrent mutations with the same operation id", async () => {
|
|
const value = host();
|
|
let releaseSave: (saved: boolean) => void = () => undefined;
|
|
value.project.save.mockReturnValue(new Promise<boolean>((resolve) => { releaseSave = resolve; }));
|
|
const args = { operationId: "save-concurrent" };
|
|
|
|
const first = value.registry.dispatch("project.save", args);
|
|
const second = value.registry.dispatch("project.save", args);
|
|
await vi.waitFor(() => expect(value.project.save).toHaveBeenCalledOnce());
|
|
releaseSave(true);
|
|
|
|
const results = await Promise.all([first, second]);
|
|
expect(results).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ saved: true, operationId: "save-concurrent" }),
|
|
expect.objectContaining({ saved: true, operationId: "save-concurrent", replayed: true }),
|
|
]));
|
|
expect(results.filter((result) => result.replayed === true)).toHaveLength(1);
|
|
expect(value.project.save).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("shares concurrent mutation failures and releases the operation id for retry", async () => {
|
|
const value = host();
|
|
let rejectSave: (error: Error) => void = () => undefined;
|
|
value.project.save
|
|
.mockReturnValueOnce(new Promise<boolean>((_resolve, reject) => { rejectSave = reject; }))
|
|
.mockResolvedValueOnce(true);
|
|
const args = { operationId: "save-retry" };
|
|
|
|
const first = value.registry.dispatch("project.save", args);
|
|
const second = value.registry.dispatch("project.save", args);
|
|
await vi.waitFor(() => expect(value.project.save).toHaveBeenCalledOnce());
|
|
rejectSave(new Error("save failed"));
|
|
|
|
const failures = await Promise.allSettled([first, second]);
|
|
expect(failures).toEqual([
|
|
expect.objectContaining({ status: "rejected", reason: expect.objectContaining({ message: "save failed" }) }),
|
|
expect.objectContaining({ status: "rejected", reason: expect.objectContaining({ message: "save failed" }) }),
|
|
]);
|
|
await expect(value.registry.dispatch("project.save", args)).resolves.toMatchObject({
|
|
saved: true, operationId: "save-retry",
|
|
});
|
|
expect(value.project.save).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("applies replay protection to the injected transcript import mutator", async () => {
|
|
const value = host();
|
|
const importTranscript = vi.fn(async () => ({ imported: true }));
|
|
const registry = Commands.createCommandRegistry({
|
|
ppro: value.ppro,
|
|
Protocol,
|
|
transcriptImportHandler: importTranscript,
|
|
transcriptImportProbe: () => true,
|
|
});
|
|
const args = { json: "{}", operationId: "transcript-import" };
|
|
|
|
const results = await Promise.all([
|
|
registry.dispatch("transcript.import", args),
|
|
registry.dispatch("transcript.import", args),
|
|
]);
|
|
expect(results).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ imported: true, operationId: "transcript-import" }),
|
|
expect.objectContaining({ imported: true, operationId: "transcript-import", replayed: true }),
|
|
]));
|
|
await expect(registry.dispatch("transcript.import", args)).resolves.toMatchObject({
|
|
imported: true, replayed: true,
|
|
});
|
|
expect(importTranscript).toHaveBeenCalledOnce();
|
|
await expect(registry.capabilities()).resolves.toMatchObject({
|
|
commands: { "transcript.import": { supported: true, destructive: true, undoable: true } },
|
|
});
|
|
});
|
|
|
|
it("exports supported interchange formats and configures AME", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("interchange.export", {
|
|
format: "otio", outputFilePath: "/tmp/edit.otio",
|
|
})).resolves.toMatchObject({ exported: true, format: "otio", outcome: "verified" });
|
|
await expect(value.registry.dispatch("encoder.configure", {
|
|
launch: true, embeddedXmp: true, startBatch: true,
|
|
})).resolves.toMatchObject({
|
|
configured: true,
|
|
outcome: "committed_unverified",
|
|
performed: ["launch", "embeddedXmp", "startBatch"],
|
|
});
|
|
});
|
|
|
|
it("adds a configured transition in a locked undoable transaction", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", position: "start",
|
|
durationSeconds: 0.5, forceSingleSided: true, transitionAlignment: 1,
|
|
expectedTarget: expectedTransition("start", false),
|
|
})).resolves.toMatchObject({ applied: true, verified: "video_transition_edge_readback", target: { transitionPresent: true } });
|
|
expect(value.project.lockedAccess).toHaveBeenCalledOnce();
|
|
expect(value.project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Add video transition");
|
|
expect(value.add).toHaveBeenCalledWith({ name: "CrossDissolve" }, expect.any(Object));
|
|
expect(value.optionValues).toEqual({ applyToStart: true, duration: { seconds: 0.5 }, forceSingleSided: true, transitionAlignment: 1 });
|
|
});
|
|
|
|
it("removes the requested transition side in a transaction", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("transition.video.remove", {
|
|
videoTrackIndex: 0, clipIndex: 0, position: "end", expectedTarget: expectedTransition("end", true),
|
|
})).resolves.toMatchObject({ removed: true, verified: "video_transition_edge_readback", target: { transitionPresent: false } });
|
|
expect(value.remove).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("rejects a stale transition snapshot before creating an action", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", position: "start",
|
|
expectedTarget: { ...expectedTransition("start", false), startSeconds: 11 },
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_TRANSITION_TARGET" });
|
|
expect(value.add).not.toHaveBeenCalled();
|
|
expect(value.project.lockedAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serializes different transition operation IDs through snapshot, action, and readback", async () => {
|
|
const value = host();
|
|
let releasePreflight: () => void = () => undefined;
|
|
const preflight = new Promise<void>((resolve) => { releasePreflight = resolve; });
|
|
value.clip.hasVideoTransition.mockImplementationOnce(async () => {
|
|
await preflight;
|
|
return false;
|
|
});
|
|
const first = value.registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", position: "start",
|
|
expectedTarget: expectedTransition("start", false), operationId: "transition-first",
|
|
});
|
|
await vi.waitFor(() => expect(value.clip.hasVideoTransition).toHaveBeenCalledOnce());
|
|
const second = value.registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", position: "start",
|
|
expectedTarget: expectedTransition("start", false), operationId: "transition-second",
|
|
});
|
|
releasePreflight();
|
|
await expect(first).resolves.toMatchObject({ outcome: "verified", target: { transitionPresent: true } });
|
|
await expect(second).rejects.toMatchObject({ code: "UXP_STALE_TRANSITION_TARGET" });
|
|
expect(value.add).toHaveBeenCalledOnce();
|
|
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("replays one guarded transition operation without creating a second action", async () => {
|
|
const value = host();
|
|
const args = {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", position: "start",
|
|
expectedTarget: expectedTransition("start", false), operationId: "transition-replay",
|
|
};
|
|
await expect(value.registry.dispatch("transition.video.add", args)).resolves.toMatchObject({ outcome: "verified" });
|
|
await expect(value.registry.dispatch("transition.video.add", args)).resolves.toMatchObject({ replayed: true, outcome: "verified" });
|
|
expect(value.add).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("fails closed when transition presence readback does not confirm the committed add", async () => {
|
|
const value = host();
|
|
value.clip.hasVideoTransition.mockResolvedValueOnce(false).mockResolvedValueOnce(false);
|
|
await expect(value.registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", position: "start",
|
|
expectedTarget: expectedTransition("start", false),
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
expect(value.add).toHaveBeenCalledOnce();
|
|
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("lifts the current selection without ripple in one undoable transaction", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("timeline.selection.lift", {
|
|
expectedSequenceGuid: "sequence-1", operationId: "lift-1",
|
|
})).resolves.toMatchObject({
|
|
lifted: true, selectedItemCount: 1, ripple: false, outcome: "committed_unverified", operationId: "lift-1",
|
|
operation: { mutatesProject: true, verification: { status: "not_verified" }, undo: { supported: true } },
|
|
});
|
|
expect(value.createRemoveItemsAction).toHaveBeenCalledWith(value.selection, false, 0, false);
|
|
expect(value.project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Lift selected timeline items");
|
|
});
|
|
|
|
it("rejects a stale target before creating a selection-lift action", async () => {
|
|
const value = host();
|
|
await expect(value.registry.dispatch("timeline.selection.lift", { expectedSequenceGuid: "other-sequence" }))
|
|
.rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE" });
|
|
expect(value.createRemoveItemsAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects unknown transitions, invalid targets, and failed commits", async () => {
|
|
await expect(host().registry.dispatch("transition.video.add", {
|
|
videoTrackIndex: 0, clipIndex: 0, matchName: "Missing", position: "start", expectedTarget: expectedTransition("start", false),
|
|
}))
|
|
.rejects.toMatchObject({ code: "UXP_TRANSITION_NOT_FOUND" });
|
|
await expect(host().registry.dispatch("transition.video.remove", { videoTrackIndex: 1, clipIndex: 0, expectedTarget: {
|
|
...expectedTransition("end", true), videoTrackIndex: 1,
|
|
} }))
|
|
.rejects.toMatchObject({ code: "UXP_TARGET_NOT_FOUND" });
|
|
await expect(host({ commit: false }).registry.dispatch("transition.video.remove", {
|
|
videoTrackIndex: 0, clipIndex: 0, expectedTarget: expectedTransition("end", true),
|
|
}))
|
|
.rejects.toMatchObject({ code: "UXP_TRANSACTION_FAILED" });
|
|
});
|
|
});
|
|
|
|
describe("UXP transition argument validation", () => {
|
|
it("requires explicit non-negative clip coordinates and a match name", () => {
|
|
expect(() => Commands.validateAddArgs({ videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", expectedTarget: expectedTransition("end", true) })).not.toThrow();
|
|
expect(() => Commands.validateAddArgs({ videoTrackIndex: -1, clipIndex: 0, matchName: "CrossDissolve", expectedTarget: expectedTransition("end", true) })).toThrow("videoTrackIndex");
|
|
expect(() => Commands.validateAddArgs({ videoTrackIndex: 0, clipIndex: 0, matchName: "CrossDissolve", expectedTarget: expectedTransition("end", true), surprise: true })).toThrow("Unknown argument");
|
|
});
|
|
});
|