e2fbef79e1
保持背景音乐规范提示词与生成输入快照一致。 统一 V2 generationInputs 的项目、素材与完美像素账本水合,并保留 legacy 兼容。 为完美像素、裁扩、去背景和图集切片保存确定性配方并禁止改造。 由服务端按当前账号资源重建安全来源引用,修复布尔元数据显示。 补齐刷新恢复、数值布尔、无标签引用、来源溯源与确定性操作回归测试。
249 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|