import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); void test('zipStore: deleteProjectById removes legacy sibling copies by entry.fileName', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /async deleteProjectById/); assert.match(src, /for \(const legacyRoot of getLegacyProjectsRootDirs\(\)\)/); assert.match(src, /path\.join\(legacyRoot, entry\.fileName\)/); assert.match(src, /rmWithRetries\(fs\.rm, legacyZipPath, \{ force: true \}\)/); }); void test('zipStore: legacy migration moves or copy\\+rm so deleted projects are not resurrected', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /migrateLegacyProjectZipsIfNeeded/); assert.match(src, /if \(destZips\.has\(name\)\) continue/); assert.match(src, /await fs\.rename\(from, to\)/); assert.match(src, /await fs\.copyFile\(from, to\)/); assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/); assert.match(src, /не «возрождались»/); }); void test('zipStore: openProjectById flushes pending saveNow before cache reset', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); // When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first. assert.match(src, /async openProjectById/); assert.match(src, /enqueueOpenProject/); assert.match(src, /openProjectByIdInner/); assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/); assert.match(src, /await this\.drainSavePipeline\(\)/); assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/); assert.match(src, /await unzipToDir\(zipPath, cacheDir/); }); void test('zipStore: openProjectById skips re-unzip when project is already open', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/); }); void test('zipStore: pack and open operations are serialized', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /private packChain: Promise/); assert.match(src, /private projectSwitchChain: Promise/); assert.match(src, /enqueuePack/); assert.match(src, /enqueueOpenProject/); assert.match(src, /enqueueProjectSwitch/); }); void test('zipStore: project updates and preview finalize are serialized', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /private projectUpdateChain: Promise/); assert.match(src, /private previewFinalizeChain: Promise/); assert.match(src, /enqueueProjectUpdate/); assert.match(src, /enqueuePreviewFinalize/); assert.match(src, /finalizeScenePreviewImportInner/); assert.match(src, /drainProjectMutations/); assert.match(src, /addBuffer/); }); void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /async closeOpenProject\(\): Promise \{[\s\S]*enqueueProjectSwitch/); assert.match(src, /Открытие проекта отменено/); }); void test('zipStore: Foundry archive playlists are copied before temp unzip cleanup', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const fn = src.slice(src.indexOf('async importProjectFromFoundry')); const body = fn.slice(0, fn.indexOf('\n async exportProjectZipToPath')); const copyAt = body.indexOf('await importPlaylists(pendingAppPlaylists)'); const cleanupAt = body.indexOf('await loaded.cleanup()'); assert.ok(copyAt > 0 && cleanupAt > copyAt); }); void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /async exportProjectZipToPath/); assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*await this\.saveNow\(\);\s*\}/); assert.match(src, /await fs\.copyFile\(src, dest\)/); }); void test('zipStore: normalizeScene defaults previewThumbAssetId for older projects', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /previewThumbAssetId/); assert.match(src, /function normalizeScene\(/); }); void test('zipStore: NPC parse defaults externalUrl', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /externalUrl: normalizeNpcExternalUrl/); }); void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)'); assert.ok(i >= 0); const j = src.indexOf('async updateConnections(', i); assert.ok(j > i); const block = src.slice(i, j); assert.match(block, /await this\.updateProject\(\(p\) => \{/); assert.match(block, /const existing = p\.scenes\[sceneId\]/); assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/); }); void test('zipStore: listProjects skips unreadable archives', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match( src, /async listProjects[\s\S]+?for \(const filePath of files\)[\s\S]+?try \{[\s\S]+?readProjectJsonFromZip/, ); }); void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const m = src.match(/setSessionPointer\([\s\S]*?\n \}/); assert.ok(m); assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/); }); void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => { const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8'); const m = src.match( /registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/, ); assert.ok(m); assert.match(m[0], /setSessionPointer/); assert.doesNotMatch(m[0], /updateProject/); }); void test('main index: full zip import loads app-tokens like playlists', () => { const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8'); assert.match(src, /async function importAppTokensFromOpenProjectCache/); assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/); assert.match(src, /remapProjectSceneTokenIds/); const importZip = src.match( /registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/, ); assert.ok(importZip); assert.match(importZip[0], /importAppTokensFromOpenProjectCache/); const importFromPath = src.match( /registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/, ); assert.ok(importFromPath); assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/); }); void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /from '\.\/ensureParentDir'/); const i = src.indexOf('async function zipDir('); assert.ok(i >= 0); const block = src.slice(i, i + 800); assert.match(block, /await ensureParentDir\(outZipPath\)/); assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/); }); void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); const i = src.indexOf('export async function replaceFileAtomic'); assert.ok(i >= 0); const j = src.indexOf('export async function recoverOrphanProjectZipTmpInRoot', i); assert.ok(j > i); const block = src.slice(i, j); assert.match(block, /rename\(finalPath, backupPath\)/); assert.doesNotMatch(block, /\.rm\(\s*finalPath/); });