8f19964e3f
Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/124 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
229 lines
6.8 KiB
TypeScript
229 lines
6.8 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');
|
|
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);
|
|
});
|
|
});
|