import type { PathMapFile } from './pathMap' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { describe, expect, it } from 'vitest' import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, normalizeKey, outsideKey, outsidePath, placeEntries, removeFiles, tableKey, toPathKey } from './pathMap' describe('路径的键', () => { it('相对工作区文件夹、用 / 分隔', () => { expect(toPathKey('/work/luffy', '/work/luffy/backend/src/campaign')).toBe('backend/src/campaign') expect(toPathKey('/work/luffy/', '/work/luffy/a.ts')).toBe('a.ts') // Windows 上 Uri.path 也是 / 分隔,盘符在最前面 expect(toPathKey('/c:/work/luffy', '/c:/work/luffy/src/a.ts')).toBe('src/a.ts') }) it('工作区文件夹本身和外面的路径没有键', () => { expect(toPathKey('/work/luffy', '/work/luffy')).toBeUndefined() expect(toPathKey('/work/luffy', '/work/other/a.ts')).toBeUndefined() expect(toPathKey('/work/luffy', '/work/luffy-2/a.ts')).toBeUndefined() }) it('手写的键规范成同一种写法,不合法的丢掉', () => { expect(normalizeKey('./src/a.ts')).toBe('src/a.ts') expect(normalizeKey('src//campaign/')).toBe('src/campaign') expect(normalizeKey('src/x/../a.ts')).toBe('src/a.ts') expect(normalizeKey('')).toBeUndefined() expect(normalizeKey('.')).toBeUndefined() expect(normalizeKey('../a.ts')).toBeUndefined() expect(normalizeKey('/etc/passwd')).toBeUndefined() }) }) describe('工作区外的文件的键', () => { const HOME = '/Users/me' const PATHLIB = '/Users/me/.local/share/uv/python/cpython-3.12.4-macos-aarch64-none/lib/python3.12/pathlib.py' it('家目录下的写成 ~/…,别处写绝对路径;名字只是前缀相同的不算家目录下', () => { expect(outsideKey(PATHLIB, HOME)).toBe('~/.local/share/uv/python/cpython-3.12.4-macos-aarch64-none/lib/python3.12/pathlib.py') expect(outsideKey('/opt/homebrew/lib/python3.12/site-packages/requests/api.py', HOME)).toBe('/opt/homebrew/lib/python3.12/site-packages/requests/api.py') expect(outsideKey('/Users/meow/a.py', HOME)).toBe('/Users/meow/a.py') // Windows 上 Uri.path 带盘符 expect(outsideKey('/c:/Users/me/AppData/a.py', '/c:/Users/me')).toBe('~/AppData/a.py') }) it('反过来还原成磁盘上的路径;工作区里的相对路径键不是工作区外的', () => { expect(outsidePath(outsideKey(PATHLIB, HOME), HOME)).toBe(PATHLIB) expect(outsidePath('/opt/homebrew/a.py', HOME)).toBe('/opt/homebrew/a.py') expect(outsidePath('src/a.ts', HOME)).toBeUndefined() }) it('读表时认这两种键,规范成同一种写法;跑出家目录的 ~/../ 丢掉', () => { expect(tableKey('~/.local/lib//pathlib.py')).toBe('~/.local/lib/pathlib.py') expect(tableKey('/opt/homebrew/lib/')).toBe('/opt/homebrew/lib') expect(tableKey('./src/a.ts')).toBe('src/a.ts') expect(tableKey('~/../etc/passwd')).toBeUndefined() expect(tableKey('/')).toBeUndefined() expect(tableKey('../a.ts')).toBeUndefined() }) it('工作区里的改名、删除、挪走碰不到工作区外的键,哪怕名字一样', () => { const map = { '~/lib/a.py': '库', '/opt/lib/a.py': '库', 'lib/a.py': '项目', 'opt/lib/a.py': '项目' } expect(dropEntries(map, 'lib')).toEqual({ '~/lib/a.py': '库', '/opt/lib/a.py': '库', 'opt/lib/a.py': '项目' }) expect(entriesUnder(map, 'opt')).toEqual({ 'lib/a.py': '项目' }) expect(moveEntries(map, 'opt', 'vendor')).toEqual({ '~/lib/a.py': '库', '/opt/lib/a.py': '库', 'lib/a.py': '项目', 'vendor/lib/a.py': '项目' }) }) }) describe('改名、挪位置', () => { const notes = { 'campaign': '营销活动', 'campaign/benefits.py': '权益发放', 'campaign/console/views.py': '后台', 'campaign2/x.py': '名字只是前缀相同', 'user/routes.py': '用户接口', } it('文件改名,注释跟着走', () => { expect(moveEntries(notes, 'user/routes.py', 'user/api.py')).toEqual({ 'campaign': '营销活动', 'campaign/benefits.py': '权益发放', 'campaign/console/views.py': '后台', 'campaign2/x.py': '名字只是前缀相同', 'user/api.py': '用户接口', }) }) it('目录挪位置,里面各层的注释一起走,名字只是前缀相同的不动', () => { expect(moveEntries(notes, 'campaign', 'marketing/campaign')).toEqual({ 'marketing/campaign': '营销活动', 'marketing/campaign/benefits.py': '权益发放', 'marketing/campaign/console/views.py': '后台', 'campaign2/x.py': '名字只是前缀相同', 'user/routes.py': '用户接口', }) }) it('没有注释的目录挪走,里面有注释的文件照样跟着走', () => { expect(moveEntries({ 'src/a/b.ts': 'B' }, 'src/a', 'lib/a')).toEqual({ 'lib/a/b.ts': 'B' }) }) it('挪到另一个工作区文件夹:先取出再放进去', () => { const moving = entriesUnder(notes, 'campaign') expect(moving).toEqual({ '': '营销活动', 'benefits.py': '权益发放', 'console/views.py': '后台' }) expect(placeEntries({ 'other.ts': 'O' }, 'pkg/campaign', moving)).toEqual({ 'other.ts': 'O', 'pkg/campaign': '营销活动', 'pkg/campaign/benefits.py': '权益发放', 'pkg/campaign/console/views.py': '后台', }) }) }) describe('删除', () => { it('删目录连带里面各层的注释,名字只是前缀相同的留着', () => { expect(dropEntries({ 'campaign': '营销活动', 'campaign/benefits.py': '权益发放', 'campaign/console/views.py': '后台', 'campaign2/x.py': '留着', }, 'campaign')).toEqual({ 'campaign2/x.py': '留着' }) }) it('删文件只删它自己', () => { expect(dropEntries({ 'a.ts': 'A', 'b.ts': 'B' }, 'a.ts')).toEqual({ 'b.ts': 'B' }) }) }) describe('变了哪些行', () => { it('增、删、改都算,没变的不算', () => { expect(changedKeys({ a: '1', b: '2', c: '3' }, { a: '1', b: '二', d: '4' }).sort()).toEqual(['b', 'c', 'd']) expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([]) }) }) describe('条目带着的文件', () => { const file: PathMapFile = { path: '.spx/x.json', label: 'x', parse: () => undefined, attachments: files => files } it('改表后没有哪条再带着的才删:删掉的条目、改掉的引用;别的条目还带着同一个文件的留着', () => { const before = { 'a.py': ['.spx/f/1.png', '.spx/f/2.png'], 'b.py': ['.spx/f/3.png', '.spx/f/2.png'] } expect(detachedFiles(file, before, { 'b.py': ['.spx/f/3.png', '.spx/f/2.png'] })).toEqual(['.spx/f/1.png']) expect(detachedFiles(file, before, { 'a.py': ['.spx/f/1.png'] }).sort()).toEqual(['.spx/f/2.png', '.spx/f/3.png']) expect(detachedFiles(file, before, before)).toEqual([]) }) it('不带文件的表什么都不删', () => { expect(detachedFiles({ ...file, attachments: undefined }, { a: ['x'] }, {})).toEqual([]) }) it('删文件:已经没了的不算错,跑到工作区文件夹外面的路径不碰', async () => { const parent = await fsp.mkdtemp(path.join(os.tmpdir(), 'path-map-')) try { const root = path.join(parent, 'work') await fsp.mkdir(path.join(root, '.spx', 'f'), { recursive: true }) await fsp.writeFile(path.join(root, '.spx', 'f', '1.png'), '') await fsp.writeFile(path.join(parent, 'outside.png'), '') await removeFiles(root, ['.spx/f/1.png', '.spx/f/missing.png', '../outside.png', path.join(parent, 'outside.png')]) await expect(fsp.access(path.join(root, '.spx', 'f', '1.png'))).rejects.toThrow() await expect(fsp.access(path.join(parent, 'outside.png'))).resolves.toBeUndefined() } finally { await fsp.rm(parent, { recursive: true, force: true }) } }) })