147 lines
6.7 KiB
TypeScript
147 lines
6.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
test("M9-11 atomically publishes packed and UDIM dirty tiles after verified OPFS writes", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async () => {
|
|
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
|
const projectId = "m9-texture-paint-atomic";
|
|
const hash = async (bytes: Uint8Array | ArrayBuffer) => {
|
|
const data = bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes;
|
|
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
};
|
|
const encode = async (pixels: Uint8Array) => {
|
|
const canvas = new OffscreenCanvas(2, 2);
|
|
const context = canvas.getContext("2d")!;
|
|
context.putImageData(new ImageData(new Uint8ClampedArray(pixels), 2, 2), 0, 0);
|
|
return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
|
|
};
|
|
const decode = async (data: ArrayBuffer) => {
|
|
const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" }));
|
|
const canvas = new OffscreenCanvas(2, 2);
|
|
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
|
context.drawImage(bitmap, 0, 0);
|
|
bitmap.close();
|
|
return new Uint8Array(context.getImageData(0, 0, 2, 2).data);
|
|
};
|
|
const commit = async ({
|
|
client,
|
|
textureAssetId,
|
|
kind,
|
|
tile,
|
|
revision,
|
|
sourcePath,
|
|
baseAssetSha256,
|
|
basePixels,
|
|
offset,
|
|
dirty,
|
|
faultAt,
|
|
}: {
|
|
client: InstanceType<typeof StorageClient>;
|
|
textureAssetId: string;
|
|
kind: "PACKED" | "UDIM";
|
|
tile: number;
|
|
revision: number;
|
|
sourcePath: string;
|
|
baseAssetSha256: string;
|
|
basePixels: Uint8Array;
|
|
offset: number;
|
|
dirty: number[];
|
|
faultAt?: "after-asset-write";
|
|
}) => {
|
|
const next = basePixels.slice();
|
|
next.set(dirty, offset);
|
|
return client.commitTexturePaintTile({
|
|
schemaVersion: 1,
|
|
target: {
|
|
schemaVersion: 1,
|
|
projectId,
|
|
imageId: "image:M9Paint",
|
|
textureAssetId,
|
|
kind,
|
|
tile,
|
|
revision,
|
|
width: 2,
|
|
height: 2,
|
|
mimeType: "image/png",
|
|
colorSpace: "SRGB",
|
|
sourcePath,
|
|
baseAssetSha256,
|
|
},
|
|
patch: {
|
|
schemaVersion: 1,
|
|
textureAssetId,
|
|
tile,
|
|
revision,
|
|
width: 2,
|
|
height: 2,
|
|
format: "RGBA8",
|
|
colorSpace: "SRGB",
|
|
baseSha256: await hash(basePixels),
|
|
resultSha256: await hash(next),
|
|
byteOffset: offset,
|
|
bytes: new Uint8Array(dirty),
|
|
},
|
|
faultAt,
|
|
});
|
|
};
|
|
|
|
const opaqueBlack = new Uint8Array(Array.from({ length: 4 }, () => [0, 0, 0, 255]).flat());
|
|
const client = new StorageClient();
|
|
const packedBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/packed.png");
|
|
const packedId = "image:M9Paint:packed";
|
|
const packedFirstPixels = opaqueBlack.slice();
|
|
packedFirstPixels.set([255, 0, 0, 255], 0);
|
|
const packedFirst = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 4, sourcePath: "textures/packed.png", baseAssetSha256: packedBase.sha256, basePixels: opaqueBlack, offset: 0, dirty: [255, 0, 0, 255] });
|
|
const firstAsset = await client.readAsset(projectId, packedFirst.binding.assetSha256);
|
|
const firstPixels = await decode(firstAsset.data);
|
|
|
|
let injected = "";
|
|
try {
|
|
await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255], faultAt: "after-asset-write" });
|
|
}
|
|
catch (error) { injected = error instanceof Error ? error.message : String(error); }
|
|
const afterFailure = await client.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 });
|
|
const packedSecondPixels = packedFirstPixels.slice();
|
|
packedSecondPixels.set([0, 255, 0, 255], 4);
|
|
const packedSecond = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255] });
|
|
|
|
const udimBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/paint.1002.png");
|
|
const udimId = "image:M9Paint:tile:1002";
|
|
const udim = await commit({ client, textureAssetId: udimId, kind: "UDIM", tile: 1002, revision: 5, sourcePath: "textures/paint.1002.png", baseAssetSha256: udimBase.sha256, basePixels: opaqueBlack, offset: 12, dirty: [0, 0, 255, 255] });
|
|
client.terminate();
|
|
|
|
const restarted = new StorageClient();
|
|
const reopenedPacked = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 });
|
|
const reopenedUdim = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: udimId, tile: 1002 });
|
|
const reopenedAsset = await restarted.readAsset(projectId, reopenedPacked.binding!.assetSha256);
|
|
const reopenedPixels = await decode(reopenedAsset.data);
|
|
restarted.terminate();
|
|
return {
|
|
packedBase: packedBase.sha256,
|
|
packedFirst: packedFirst.binding,
|
|
firstPixels: Array.from(firstPixels),
|
|
injected,
|
|
afterFailure: afterFailure.binding,
|
|
packedSecond: packedSecond.binding,
|
|
udim: udim.binding,
|
|
reopenedPacked: reopenedPacked.binding,
|
|
reopenedUdim: reopenedUdim.binding,
|
|
reopenedPixels: Array.from(reopenedPixels),
|
|
expectedPackedPixels: Array.from(packedSecondPixels),
|
|
};
|
|
});
|
|
|
|
expect(result.packedFirst).toMatchObject({ kind: "PACKED", tile: 1001, generation: 1, pixelSha256: expect.stringMatching(/^[a-f0-9]{64}$/) });
|
|
expect(result.packedFirst.assetSha256).not.toBe(result.packedBase);
|
|
expect(result.firstPixels.slice(0, 4)).toEqual([255, 0, 0, 255]);
|
|
expect(result.injected).toContain("STORAGE_TRANSACTION");
|
|
expect(result.afterFailure).toEqual(result.packedFirst);
|
|
expect(result.packedSecond).toMatchObject({ kind: "PACKED", tile: 1001, generation: 2 });
|
|
expect(result.packedSecond.assetSha256).not.toBe(result.packedFirst.assetSha256);
|
|
expect(result.udim).toMatchObject({ kind: "UDIM", tile: 1002, generation: 1 });
|
|
expect(result.reopenedPacked).toEqual(result.packedSecond);
|
|
expect(result.reopenedUdim).toEqual(result.udim);
|
|
expect(result.reopenedPixels).toEqual(result.expectedPackedPixels);
|
|
});
|