Files
Genarrative/src/components/image-editor/perfectPixelOperationStore.test.ts
T
k88936 e2fbef79e1 修复画布生成配方恢复与快照失真
保持背景音乐规范提示词与生成输入快照一致。

统一 V2 generationInputs 的项目、素材与完美像素账本水合,并保留 legacy 兼容。

为完美像素、裁扩、去背景和图集切片保存确定性配方并禁止改造。

由服务端按当前账号资源重建安全来源引用,修复布尔元数据显示。

补齐刷新恢复、数值布尔、无标签引用、来源溯源与确定性操作回归测试。
2026-08-07 11:53:25 +08:00

249 lines
7.3 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
import {
forgetPerfectPixelOperation,
PERFECT_PIXEL_OPERATION_RETENTION_LIMIT,
PERFECT_PIXEL_OPERATION_RETENTION_MS,
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
const OWNER_USER_ID = 'user-a';
const PROJECT_ID = 'project-1';
const NOW = 1_785_715_270_000;
function buildOperation(
dialogId: string,
overrides: Partial<PerfectPixelOperationSnapshot> = {},
): PerfectPixelOperationSnapshot {
return {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'ref:project-resource:resource-source',
projectId: PROJECT_ID,
sourceResourceId: 'resource-source',
assetKind: 'character',
assetLabel: '角色 · 完美像素',
canvasCompletion: {
dialogId,
title: '角色 · 完美像素',
placeholder: {
x: 100,
y: 120,
width: 320,
height: 320,
originalWidth: 640,
originalHeight: 640,
},
},
},
submittedAt: NOW,
reconcileUntil: NOW + 75_000,
...overrides,
};
}
describe('perfectPixelOperationStore', () => {
beforeEach(() => {
window.localStorage.clear();
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('round-trips an operation for the same owner and project', () => {
const operation = buildOperation('dialog-1', {
request: {
...buildOperation('dialog-1').request,
generationInputs: {
version: 2,
action: 'image.perfect-pixel',
fields: [
{ id: 'scale', title: '数字兼容', value: 2 },
{ id: 'enabled', title: '布尔兼容', value: false },
],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
},
});
savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.get('dialog-1')).toEqual(operation);
});
it('keeps ledgers separated by project and drops another owner entirely', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
// 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。
expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0);
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
});
it('refuses to store an operation whose request targets another project', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
'project-2',
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
});
it('fails closed on a tampered ledger entry instead of replaying it', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-2'),
);
const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`;
const stored = JSON.parse(window.localStorage.getItem(key)!) as {
ownerUserId: string;
operations: Record<string, PerfectPixelOperationSnapshot>;
};
stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else';
window.localStorage.setItem(key, JSON.stringify(stored));
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.has('dialog-1')).toBe(false);
expect(ledger.has('dialog-2')).toBe(true);
});
it('drops entries past the retention window', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-old', {
submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1,
reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999,
}),
);
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-fresh'),
);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect([...ledger.keys()]).toEqual(['dialog-fresh']);
});
it('caps the ledger size by keeping the newest submissions', () => {
for (
let index = 0;
index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT;
index += 1
) {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation(`dialog-${index}`, {
submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index),
reconcileUntil:
NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000,
}),
);
}
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
expect(ledger.has('dialog-0')).toBe(false);
expect(
ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`),
).toBe(true);
});
it('forgets a settled operation and clears the key once empty', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1');
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
expect(
window.localStorage.getItem(
`genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`,
),
).toBeNull();
});
it('degrades to an empty ledger without throwing when storage is unavailable', () => {
const setItem = vi
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new Error('QuotaExceededError');
});
const getItem = vi
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new Error('SecurityError');
});
try {
// 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只
// 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。
expect(() =>
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
),
).not.toThrow();
expect(() =>
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'),
).not.toThrow();
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(
0,
);
} finally {
setItem.mockRestore();
getItem.mockRestore();
}
});
it('returns an empty ledger without an owner or project', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0);
expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0);
});
});