diff --git a/src/domain/FileSystem/Change.ts b/src/domain/FileSystem/Change.ts index 31ec0ddf..cd21cadc 100644 --- a/src/domain/FileSystem/Change.ts +++ b/src/domain/FileSystem/Change.ts @@ -5,7 +5,7 @@ import { LocalFileInfo } from './LocalFileInfo'; import { RemoteFileInfo, RemoteNodeInfo } from './RemoteNodeInfo'; export type RemoteChangeType = - | tagged.Tagged<'noChange'> + | tagged.Tagged<'noChange'> // fixme: seems to be unused - remove | tagged.Tagged<'updated', number> | tagged.Tagged<'removed'> | tagged.Tagged<'added', number>; @@ -13,7 +13,7 @@ export type RemoteChangeType = export const remoteChangeType = tagged.build(); export type LocalChangeType = - | tagged.Tagged<'noChange'> + | tagged.Tagged<'noChange'> // fixme: seems to be unused - remove | tagged.Tagged<'updated'> | tagged.Tagged<'removed'> | tagged.Tagged<'added'>; @@ -73,7 +73,7 @@ export const getRemoteChanges = ( array.map(({ type, path, version }) => ({ type, path, - change: remoteChangeType.updated(version), + change: remoteChangeType.updated(version), // fixme: use latest version here })), ); return pipe( diff --git a/src/domain/FileSystem/Conflict.ts b/src/domain/FileSystem/Conflict.ts index 94b9b3ef..f9c36a34 100644 --- a/src/domain/FileSystem/Conflict.ts +++ b/src/domain/FileSystem/Conflict.ts @@ -22,6 +22,7 @@ export const getConflicts = ( remote, array.findFirst((changeRemote) => eqFsNode.equals(changeLocal, changeRemote)), option.map(({ change }) => change), + // fixme: don't do this: instead, replace array.intersection with a groupBy(getPath) option.getOrElseW(() => remoteChangeType.noChange()), ), })), diff --git a/src/domain/FileSystem/HashInfo.ts b/src/domain/FileSystem/HashInfo.ts index 27816c9a..63aadf55 100644 --- a/src/domain/FileSystem/HashInfo.ts +++ b/src/domain/FileSystem/HashInfo.ts @@ -15,6 +15,7 @@ export const hashInfoFromFsFile = pipe( stack.join(projectDir, file.path), task.chain(stack.getFileHash), + // fixme: should we use panic here? taskEither.getOrElse((e) => { throw e; }), diff --git a/src/domain/FileSystem/Path.tests.ts b/src/domain/FileSystem/Path.tests.ts new file mode 100644 index 00000000..8caa6f23 --- /dev/null +++ b/src/domain/FileSystem/Path.tests.ts @@ -0,0 +1,51 @@ +import { fc } from '@code-expert/test-utils'; +import { assert, describe, it } from 'vitest'; +import { iots } from '@code-expert/prelude'; +import { PfsPath, PfsPathBrand, isValidDirName, isValidFileName } from '@/domain/FileSystem'; + +const wordCharBuilders = [ + { num: 26, build: (v: number) => String.fromCharCode(v + 0x41) }, + { num: 26, build: (v: number) => String.fromCharCode(v + 0x61) }, + { num: 10, build: (v: number) => String.fromCharCode(v + 0x30) }, + { num: 1, build: () => String.fromCharCode(0x5f) }, +]; + +const fsNodeCharBuilders = [ + ...wordCharBuilders, + { num: 0, build: () => String.fromCharCode(0x20) }, + { num: 1, build: () => String.fromCharCode(0x2d) }, +]; + +export const legalDirNameArb = fc.stringOf(fc.mapToConstant(...fsNodeCharBuilders), { + minLength: 1, + maxLength: 80, +}); + +export const legalFileNameArb = fc + .tuple( + fc.stringOf(fc.mapToConstant(...fsNodeCharBuilders), { minLength: 0, maxLength: 80 }), + fc.constant('.'), + fc.stringOf(fc.mapToConstant(...wordCharBuilders), { minLength: 1, maxLength: 5 }), + ) + .map((elements) => elements.join('')); + +// todo: it is unclear how many "collisions" this produces, we might need a better solution +export const pfsPathArb: fc.Arbitrary = fc + .tuple(fc.array(legalDirNameArb), legalFileNameArb) + .map(([path, file]) => `./${path.join('/')}/${file}`) + .map(iots.brandFromLiteral); + +describe('PfsPath', () => { + describe('legalDirNameArb', () => { + it('should only produce strings that are accepted by isValidDirName', () => { + fc.assert(fc.property(legalDirNameArb, (dirName) => assert.isTrue(isValidDirName(dirName)))); + }); + }); + describe('legalFileNameArb', () => { + it('should only produce strings that are accepted by isValidFileName', () => { + fc.assert( + fc.property(legalFileNameArb, (fileName) => assert.isTrue(isValidFileName(fileName))), + ); + }); + }); +}); diff --git a/src/domain/ProjectSync/apiStack.ts b/src/domain/ProjectSync/apiStack.ts index 99257ec1..29a02076 100644 --- a/src/domain/ProjectSync/apiStack.ts +++ b/src/domain/ProjectSync/apiStack.ts @@ -20,7 +20,7 @@ export const apiStack: ApiStack = { readRemoteProjectFile: (projectId, file) => apiGetSigned({ path: `project/${projectId}/file`, - jwtPayload: { path: file }, + jwtPayload: { path: file }, // fixme: add version codec: iots.Uint8ArrayC, responseType: ResponseType.Binary, }), diff --git a/src/domain/ProjectSync/getLocalChanges.tests.ts b/src/domain/ProjectSync/getLocalChanges.tests.ts new file mode 100644 index 00000000..c264ec9b --- /dev/null +++ b/src/domain/ProjectSync/getLocalChanges.tests.ts @@ -0,0 +1,150 @@ +import { fc } from '@code-expert/test-utils'; +import { NonEmptyArray } from 'fp-ts/NonEmptyArray'; +import { Lens } from 'monocle-ts'; +import * as nodePath from 'path'; +import { assert, describe, it } from 'vitest'; +import { nonEmptyArray, option, pipe, task, taskEither, taskOption } from '@code-expert/prelude'; +import { + LocalFileChange, + LocalFileInfo, + getLocalChanges, + localChangeType, +} from '@/domain/FileSystem'; +import { pfsPathArb } from '@/domain/FileSystem/Path.tests'; +import { FileSystemStack } from '@/domain/FileSystem/fileSystemStack'; +import { ordFsNode } from '@/lib/tauri/fs'; +import { escape } from '@/lib/tauri/path'; +import { panic } from '@/utils/error'; + +const Hash = Lens.fromProp()('hash'); +const modifyHash = Hash.modify((h) => h + '2'); + +export const nodeFsStack: FileSystemStack = { + dirname: (path) => taskEither.fromIO(() => nodePath.dirname(path)), + join: (...paths) => task.fromIO(() => nodePath.join(...paths)), + stripAncestor: (ancestor) => (to) => + taskEither.fromIO(() => { + const relative = nodePath.relative(ancestor, to); + const normalized = nodePath.normalize(relative); + return nodePath.format({ dir: '.', base: normalized }); + }), + escape, + getFileHash: () => panic('getFileHash is not implemented on nodeFsStack'), + removeFile: () => panic('removeFile is not implemented on nodeFsStack'), + basename: (path) => pipe(path.split('/'), (ps) => ps[ps.length - 1], taskOption.of), + tempDir: () => panic('tempDir is not implemented on nodeFsStack'), + readBinaryFile: () => panic('readBinaryFile is not implemented on nodeFsStack'), + readFsTree: () => panic('readFsTree is not implemented on nodeFsStack'), +}; + +const localFileInfoArb = fc.record({ + type: fc.constant('file'), + path: pfsPathArb, + hash: fc.string({ minLength: 8, maxLength: 8 }), +}); + +describe('getLocalChanges', () => { + const toRemoved = (fileInfo: LocalFileInfo): LocalFileChange => ({ + path: fileInfo.path, + type: 'file', + change: localChangeType.removed(), + }); + const toAdded = (fileInfo: LocalFileInfo): LocalFileChange => ({ + path: fileInfo.path, + type: 'file', + change: localChangeType.added(), + }); + const toUpdated = (fileInfo: LocalFileInfo): LocalFileChange => ({ + path: fileInfo.path, + type: 'file', + change: localChangeType.updated(), + }); + + it('should classify missing files as "removed"', () => { + const commonFilesArb = fc.array(localFileInfoArb); + const missingFilesArb = fc.nonEmptyArray(localFileInfoArb); + + const missingToRemoved = ( + commonFiles: Array, + missingFiles: NonEmptyArray, + ) => + assert.deepEqual( + getLocalChanges([...commonFiles, ...missingFiles], [...commonFiles]), + pipe(missingFiles, nonEmptyArray.map(toRemoved), option.some), + ); + + fc.assert(fc.property(commonFilesArb, missingFilesArb, missingToRemoved)); + }); + + it('should classify new files as "added"', () => { + const commonFilesArb = fc.array(localFileInfoArb); + const newFilesArb = fc.nonEmptyArray(localFileInfoArb); + + const newToAdded = ( + commonFiles: Array, + newFiles: NonEmptyArray, + ) => + assert.deepEqual( + getLocalChanges([...commonFiles], [...commonFiles, ...newFiles]), + pipe(newFiles, nonEmptyArray.map(toAdded), option.some), + ); + + fc.assert(fc.property(commonFilesArb, newFilesArb, newToAdded)); + }); + + it('should classify files with a different version as "updated"', () => { + const commonFilesArb = fc.array(localFileInfoArb); + const changedFilesArb = fc.nonEmptyArray(localFileInfoArb); + + const changedToUpdated = ( + commonFiles: Array, + changedFiles: NonEmptyArray, + ) => + assert.deepEqual( + getLocalChanges( + [...commonFiles, ...changedFiles], + [...commonFiles, ...changedFiles.map(modifyHash)], + ), + pipe(changedFiles, nonEmptyArray.map(toUpdated), option.some), + ); + + fc.assert(fc.property(commonFilesArb, changedFilesArb, changedToUpdated)); + }); + + it('should do all three at the same time', () => { + const commonFilesArb = fc.array(localFileInfoArb); + const missingFilesArb = fc.nonEmptyArray(localFileInfoArb); + const newFilesArb = fc.nonEmptyArray(localFileInfoArb); + const changedFilesArb = fc.nonEmptyArray(localFileInfoArb); + + const removedNewChanged = ( + commonFiles: Array, + missingFiles: NonEmptyArray, + newFiles: NonEmptyArray, + changedFiles: NonEmptyArray, + ) => + assert.deepEqual( + pipe( + getLocalChanges( + [...commonFiles, ...missingFiles, ...changedFiles], + [...commonFiles, ...newFiles, ...changedFiles.map(modifyHash)], + ), + option.map(nonEmptyArray.sort(ordFsNode)), + ), + pipe( + [ + nonEmptyArray.map(toRemoved)(missingFiles), + nonEmptyArray.map(toAdded)(newFiles), + nonEmptyArray.map(toUpdated)(changedFiles), + ], + nonEmptyArray.concatAll(nonEmptyArray.getSemigroup()), + nonEmptyArray.sort(ordFsNode), + option.some, + ), + ); + + fc.assert( + fc.property(commonFilesArb, missingFilesArb, newFilesArb, changedFilesArb, removedNewChanged), + ); + }); +}); diff --git a/src/domain/ProjectSync/getRemoteChanges.tests.ts b/src/domain/ProjectSync/getRemoteChanges.tests.ts new file mode 100644 index 00000000..c52f74cf --- /dev/null +++ b/src/domain/ProjectSync/getRemoteChanges.tests.ts @@ -0,0 +1,154 @@ +import { fc } from '@code-expert/test-utils'; +import { NonEmptyArray } from 'fp-ts/NonEmptyArray'; +import { Lens } from 'monocle-ts'; +import { assert, describe, it } from 'vitest'; +import { nonEmptyArray, option, pipe } from '@code-expert/prelude'; +import { + RemoteDirInfo, + RemoteFileChange, + RemoteFileInfo, + RemoteNodeInfo, + getRemoteChanges, + remoteChangeType, +} from '@/domain/FileSystem'; +import { pfsPathArb } from '@/domain/FileSystem/Path.tests'; +import { ordFsNode } from '@/lib/tauri/fs'; + +const Version = Lens.fromProp()('version'); +const incrementVersion = Version.modify((v) => v + 1); + +const remoteFileInfoArb = fc.record({ + type: fc.constant('file'), + path: pfsPathArb, + version: fc.nat(), + permissions: fc.constantFrom('r', 'rw'), +}); + +const remoteDirInfoArb = fc.record({ + type: fc.constant('dir'), + path: pfsPathArb, + version: fc.nat(), + permissions: fc.constantFrom('r', 'rw'), +}); + +describe('getRemoteChanges', () => { + const toRemoved = (fileInfo: RemoteFileInfo): RemoteFileChange => ({ + path: fileInfo.path, + type: 'file', + change: remoteChangeType.removed(), + }); + const toAdded = (fileInfo: RemoteFileInfo): RemoteFileChange => ({ + path: fileInfo.path, + type: 'file', + change: remoteChangeType.added(fileInfo.version), + }); + const toUpdated = (fileInfo: RemoteFileInfo): RemoteFileChange => ({ + path: fileInfo.path, + type: 'file', + change: remoteChangeType.updated(fileInfo.version), + }); + + it('should classify missing files as "removed"', () => { + const dirsArb = fc.array(remoteDirInfoArb); + const commonFilesArb = fc.array(remoteFileInfoArb); + const missingFilesArb = fc.nonEmptyArray(remoteFileInfoArb); + + const missingToRemoved = ( + dirs: Array, + commonFiles: Array, + missingFiles: NonEmptyArray, + ) => + assert.deepEqual( + // fixme: does concat order matter? how do we make it not matter? + getRemoteChanges([...commonFiles, ...missingFiles], [...commonFiles, ...dirs]), + pipe(missingFiles, nonEmptyArray.map(toRemoved), option.some), + ); + + fc.assert(fc.property(dirsArb, commonFilesArb, missingFilesArb, missingToRemoved)); + }); + + it('should classify new files as "added"', () => { + const dirsArb = fc.array(remoteDirInfoArb); + const commonFilesArb = fc.array(remoteFileInfoArb); + const newFilesArb = fc.nonEmptyArray(remoteFileInfoArb); + + const newToAdded = ( + dirs: Array, + commonFiles: Array, + newFiles: NonEmptyArray, + ) => + assert.deepEqual( + getRemoteChanges([...commonFiles], [...commonFiles, ...dirs, ...newFiles]), + pipe(newFiles, nonEmptyArray.map(toAdded), option.some), + ); + + fc.assert(fc.property(dirsArb, commonFilesArb, newFilesArb, newToAdded)); + }); + + it('should classify files with a different version as "updated"', () => { + const dirsArb = fc.array(remoteDirInfoArb); + const commonFilesArb = fc.array(remoteFileInfoArb); + const changedFilesArb = fc.nonEmptyArray(remoteFileInfoArb); + + const changedToUpdated = ( + dirs: Array, + commonFiles: Array, + changedFiles: NonEmptyArray, + ) => + assert.deepEqual( + getRemoteChanges( + [...commonFiles, ...changedFiles], + [...commonFiles, ...dirs, ...changedFiles.map(incrementVersion)], + ), + pipe(changedFiles, nonEmptyArray.map(toUpdated), option.some), + ); + + fc.assert(fc.property(dirsArb, commonFilesArb, changedFilesArb, changedToUpdated)); + }); + + it('should do all three at the same time', () => { + const dirsArb = fc.array(remoteDirInfoArb); + const commonFilesArb = fc.array(remoteFileInfoArb); + const missingFilesArb = fc.nonEmptyArray(remoteFileInfoArb); + const newFilesArb = fc.nonEmptyArray(remoteFileInfoArb); + const changedFilesArb = fc.nonEmptyArray(remoteFileInfoArb); + + const removedNewChanged = ( + dirs: Array, + commonFiles: Array, + missingFiles: NonEmptyArray, + newFiles: NonEmptyArray, + changedFiles: NonEmptyArray, + ) => + assert.deepEqual( + pipe( + getRemoteChanges( + [...commonFiles, ...missingFiles, ...changedFiles], + [...commonFiles, ...dirs, ...newFiles, ...changedFiles.map(incrementVersion)], + ), + option.map(nonEmptyArray.sort(ordFsNode)), + ), + pipe( + [ + nonEmptyArray.map(toRemoved)(missingFiles), + nonEmptyArray.map(toAdded)(newFiles), + nonEmptyArray.map(toUpdated)(changedFiles), + ], + nonEmptyArray.concatAll(nonEmptyArray.getSemigroup()), + nonEmptyArray.sort(ordFsNode), + option.some, + ), + ); + + fc.assert( + fc.property( + dirsArb, + commonFilesArb, + missingFilesArb, + newFilesArb, + changedFilesArb, + removedNewChanged, + ), + ); + }); +});