Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/domain/FileSystem/Change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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>;

export const remoteChangeType = tagged.build<RemoteChangeType>();

export type LocalChangeType =
| tagged.Tagged<'noChange'>
| tagged.Tagged<'noChange'> // fixme: seems to be unused - remove
| tagged.Tagged<'updated'>
| tagged.Tagged<'removed'>
| tagged.Tagged<'added'>;
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/domain/FileSystem/Conflict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
),
})),
Expand Down
1 change: 1 addition & 0 deletions src/domain/FileSystem/HashInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
Expand Down
51 changes: 51 additions & 0 deletions src/domain/FileSystem/Path.tests.ts
Original file line number Diff line number Diff line change
@@ -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<PfsPath> = fc
.tuple(fc.array(legalDirNameArb), legalFileNameArb)
.map(([path, file]) => `./${path.join('/')}/${file}`)
.map(iots.brandFromLiteral<string, PfsPathBrand>);

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))),
);
});
});
});
2 changes: 1 addition & 1 deletion src/domain/ProjectSync/apiStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
150 changes: 150 additions & 0 deletions src/domain/ProjectSync/getLocalChanges.tests.ts
Original file line number Diff line number Diff line change
@@ -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<LocalFileInfo>()('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<LocalFileInfo>({
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<LocalFileInfo>,
missingFiles: NonEmptyArray<LocalFileInfo>,
) =>
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<LocalFileInfo>,
newFiles: NonEmptyArray<LocalFileInfo>,
) =>
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<LocalFileInfo>,
changedFiles: NonEmptyArray<LocalFileInfo>,
) =>
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<LocalFileInfo>,
missingFiles: NonEmptyArray<LocalFileInfo>,
newFiles: NonEmptyArray<LocalFileInfo>,
changedFiles: NonEmptyArray<LocalFileInfo>,
) =>
assert.deepEqual(
pipe(
getLocalChanges(
[...commonFiles, ...missingFiles, ...changedFiles],
[...commonFiles, ...newFiles, ...changedFiles.map(modifyHash)],
),
option.map(nonEmptyArray.sort<LocalFileChange>(ordFsNode)),
),
pipe(
[
nonEmptyArray.map(toRemoved)(missingFiles),
nonEmptyArray.map(toAdded)(newFiles),
nonEmptyArray.map(toUpdated)(changedFiles),
],
nonEmptyArray.concatAll(nonEmptyArray.getSemigroup()),
nonEmptyArray.sort<LocalFileChange>(ordFsNode),
option.some,
),
);

fc.assert(
fc.property(commonFilesArb, missingFilesArb, newFilesArb, changedFilesArb, removedNewChanged),
);
});
});
Loading