From f28f82cd908e5a8776564c23b78ebc9f598bac2f Mon Sep 17 00:00:00 2001 From: Viktor Podzigun Date: Sun, 14 Apr 2024 17:31:20 +0200 Subject: [PATCH] Added FileListStateReducer --- src/FileListStateReducer.mjs | 178 +++++++++++ test/FileListStateReducer.test.mjs | 467 +++++++++++++++++++++++++++++ test/all.mjs | 1 + 3 files changed, 646 insertions(+) create mode 100644 src/FileListStateReducer.mjs create mode 100644 test/FileListStateReducer.test.mjs diff --git a/src/FileListStateReducer.mjs b/src/FileListStateReducer.mjs new file mode 100644 index 0000000..3018f26 --- /dev/null +++ b/src/FileListStateReducer.mjs @@ -0,0 +1,178 @@ +/** + * @typedef {import("./api/FileListDir").FileListDir} FileListDir + * @typedef {import("./api/FileListItem").FileListItem} FileListItem + * @typedef {import("./sort/FileListSort").FileListSort} FileListSort + * @typedef {import("./FileListActions").FileListAction} FileListAction + * @typedef {import("./FileListState").FileListState} FileListState + */ +import FileListItem from "./api/FileListItem.mjs"; +import FileListSort from "./sort/FileListSort.mjs"; +import FileListState from "./FileListState.mjs"; + +/** + * @param {FileListState} state + * @param {any} action + * @returns {FileListState} + */ +function FileListStateReducer(state, action) { + /** @type {FileListAction} */ + const a = action; + switch (a.action) { + case "FileListParamsChangedAction": + return { + ...state, + offset: a.offset, + index: a.index, + selectedNames: a.selectedNames, + }; + + case "FileListDirChangedAction": { + const processed = processDir(a.currDir, state.sort); + const newIndex = () => { + if (a.dir === FileListItem.up.name) { + let focusedDir = stripPrefix(state.currDir.path, a.currDir.path); + focusedDir = stripPrefix(focusedDir, "/"); + focusedDir = stripPrefix(focusedDir, "\\"); + + return Math.max( + processed.items.findIndex((i) => i.name === focusedDir), + 0 + ); + } + return 0; + }; + + return { + ...state, + offset: 0, + index: newIndex(), + currDir: processed, + selectedNames: new Set(), + }; + } + case "FileListDirUpdatedAction": { + const processed = processDir(a.currDir, state.sort); + const currIndex = state.offset + state.index; + const newIndex = (() => { + const currItem = FileListState.currentItem(state); + if (currItem) { + const index = processed.items.findIndex( + (i) => i.name === currItem.name + ); + return index < 0 + ? Math.min(currIndex, a.currDir.items.length) + : index; + } + return 0; + })(); + const [offset, index] = + newIndex === currIndex ? [state.offset, state.index] : [0, newIndex]; + + const newSelected = () => { + const names = new Set(); + processed.items.forEach((i) => { + if (state.selectedNames.has(i.name)) { + names.add(i.name); + } + }); + return names; + }; + + return { + ...state, + offset, + index, + currDir: processed, + selectedNames: + state.selectedNames.size > 0 ? newSelected() : state.selectedNames, + }; + } + case "FileListItemCreatedAction": { + const processed = processDir(a.currDir, state.sort); + const newIndex = processed.items.findIndex((i) => i.name === a.name); + const [offset, index] = + newIndex < 0 ? [state.offset, state.index] : [0, newIndex]; + + return { + ...state, + offset, + index, + currDir: processed, + }; + } + case "FileListSortAction": { + const nextSort = FileListSort.nextSort(state.sort, a.mode); + const processed = processDir(state.currDir, nextSort); + const currItem = FileListState.currentItem(state); + const newIndex = currItem + ? processed.items.findIndex((i) => i.name === currItem.name) + : -1; + const [offset, index] = + newIndex < 0 ? [state.offset, state.index] : [0, newIndex]; + + return { + ...state, + offset, + index, + currDir: processed, + sort: nextSort, + }; + } + case "FileListDiskSpaceUpdatedAction": + return { + ...state, + diskSpace: a.diskSpace, + }; + + default: + return state; + } +} + +/** + * @param {string} s + * @param {string} prefix + * @returns {string} + */ +function stripPrefix(s, prefix) { + return s.startsWith(prefix) ? s.substring(prefix.length) : s; +} + +/** + * @param {FileListDir} currDir + * @param {FileListSort} sort + * @returns {FileListDir} + */ +function processDir(currDir, sort) { + /** + * @returns {FileListItem[]} + */ + function sortCurrItems() { + const dirs = currDir.items.filter( + (i) => i.name !== FileListItem.up.name && i.isDir + ); + const files = currDir.items.filter( + (i) => i.name !== FileListItem.up.name && !i.isDir + ); + const sortedDirs = sortItems(dirs, sort); + const sortedFiles = sortItems(files, sort); + + return currDir.isRoot + ? [...sortedDirs, ...sortedFiles] + : [FileListItem.up, ...sortedDirs, ...sortedFiles]; + } + + return { ...currDir, items: sortCurrItems() }; +} + +/** + * @param {FileListItem[]} items + * @param {FileListSort} sort + * @returns {FileListItem[]} + */ +function sortItems(items, sort) { + const sorted = FileListSort.sortItems(items, sort.mode); + return sort.asc ? sorted : sorted.reverse(); +} + +export default FileListStateReducer; diff --git a/test/FileListStateReducer.test.mjs b/test/FileListStateReducer.test.mjs new file mode 100644 index 0000000..43d114a --- /dev/null +++ b/test/FileListStateReducer.test.mjs @@ -0,0 +1,467 @@ +/** + * @typedef {import("../src/api/FileListDir").FileListDir} FileListDir + * @typedef {import("../src/FileListActions").FileListAction} FileListAction + */ +import assert from "node:assert/strict"; +import FileListItem from "../src/api/FileListItem.mjs"; +import SortMode from "../src/sort/SortMode.mjs"; +import FileListState from "../src/FileListState.mjs"; +import FileListStateReducer from "../src/FileListStateReducer.mjs"; + +const { describe, it } = await (async () => { + // @ts-ignore + const module = process.isBun ? "bun:test" : "node:test"; + // @ts-ignore + return process.isBun // @ts-ignore + ? Promise.resolve({ describe: (_, fn) => fn(), it: test }) + : import(module); +})(); + +describe("FileListStateReducer.test.mjs", () => { + it("should return current state if action is not supported", () => { + //given + const state = FileListState(); + + //when & then + assert.deepEqual( + FileListStateReducer(state, "test_action") === state, + true + ); + assert.deepEqual( + FileListStateReducer(state, { action: "unknown" }) === state, + true + ); + }); + + it("should set params when FileListParamsChangedAction", () => { + //given + const state = FileListState(); + /** @type {FileListAction} */ + const action = { + action: "FileListParamsChangedAction", + offset: 1, + index: 2, + selectedNames: new Set(["test"]), + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: action.offset, + index: action.index, + selectedNames: action.selectedNames, + }); + }); + + it("should set sorted items when FileListDirChangedAction(root)", () => { + //given + const state = { ...FileListState(), selectedNames: new Set(["file 1"]) }; + /** @type {FileListDir} */ + const currDir = { + path: "/", + isRoot: true, + items: [ + FileListItem("file 2"), + FileListItem("file 1"), + FileListItem("dir 2", true), + FileListItem("dir 1", true), + ], + }; + /** @type {FileListAction} */ + const action = { + action: "FileListDirChangedAction", + dir: FileListItem.currDir.name, + currDir, + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + currDir: { + ...currDir, + items: [ + FileListItem("dir 1", true), + FileListItem("dir 2", true), + FileListItem("file 1"), + FileListItem("file 2"), + ], + }, + selectedNames: new Set(), + }); + }); + + it("should add .. to items and set index when FileListDirChangedAction(non-root)", () => { + //given + /** @type {FileListDir} */ + const stateDir = { path: "/root/sub-dir/dir 2", isRoot: false, items: [] }; + const state = { + ...FileListState(), + currDir: stateDir, + selectedNames: new Set(["file 1"]), + }; + /** @type {FileListDir} */ + const currDir = { + path: "/root/sub-dir", + isRoot: false, + items: [ + FileListItem("file 2"), + FileListItem("file 1"), + FileListItem("dir 2", true), + FileListItem("dir 1", true), + ], + }; + /** @type {FileListAction} */ + const action = { + action: "FileListDirChangedAction", + dir: FileListItem.up.name, + currDir, + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + index: 2, + currDir: { + ...currDir, + items: [ + FileListItem.up, + FileListItem("dir 1", true), + FileListItem("dir 2", true), + FileListItem("file 1"), + FileListItem("file 2"), + ], + }, + selectedNames: new Set(), + }); + }); + + it("should update state and keep current item when FileListDirUpdatedAction", () => { + //given + const state = { + ...FileListState(), + offset: 1, + currDir: { + path: "/root/sub-dir/dir 2", + isRoot: false, + items: [FileListItem.up, FileListItem("file 1")], + }, + selectedNames: new Set(["test", "dir 1"]), + }; + /** @type {FileListDir} */ + const currDir = { + path: "/root/sub-dir", + isRoot: false, + items: [FileListItem("file 1"), FileListItem("dir 1", true)], + }; + /** @type {FileListAction} */ + const action = { action: "FileListDirUpdatedAction", currDir }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: 0, + index: 2, + currDir: { + ...currDir, + items: [ + FileListItem.up, + FileListItem("dir 1", true), + FileListItem("file 1"), + ], + }, + selectedNames: new Set(["dir 1"]), + }); + }); + + it("should update state and keep current index when FileListDirUpdatedAction", () => { + //given + const state = { + ...FileListState(), + offset: 1, + currDir: { + path: "/root/sub-dir/dir 2", + isRoot: false, + items: [FileListItem.up, FileListItem("file 1")], + }, + selectedNames: new Set(["test", "file 1"]), + }; + /** @type {FileListDir} */ + const currDir = { + path: "/root/sub-dir", + isRoot: false, + items: [FileListItem("dir 1", true)], + }; + /** @type {FileListAction} */ + const action = { action: "FileListDirUpdatedAction", currDir }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: 1, + index: 0, + currDir: { + ...currDir, + items: [FileListItem.up, FileListItem("dir 1", true)], + }, + selectedNames: new Set(), + }); + }); + + it("should update state and reset index when FileListDirUpdatedAction", () => { + //given + const state = { + ...FileListState(), + offset: 1, + index: 1, + currDir: { + path: "/root/sub-dir/dir 2", + isRoot: false, + items: [ + FileListItem.up, + FileListItem("file 1"), + FileListItem("dir 1", true), + ], + }, + selectedNames: new Set(["file 1"]), + }; + /** @type {FileListDir} */ + const currDir = { path: "/root/sub-dir", isRoot: false, items: [] }; + /** @type {FileListAction} */ + const action = { action: "FileListDirUpdatedAction", currDir }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: 0, + index: 0, + currDir: { + ...currDir, + items: [FileListItem.up], + }, + selectedNames: new Set(), + }); + }); + + it("should update state and set default index when FileListDirUpdatedAction", () => { + //given + const state = { + ...FileListState(), + offset: 1, + index: 1, + currDir: { path: "/root/sub-dir/dir 2", isRoot: true, items: [] }, + }; + /** @type {FileListDir} */ + const currDir = { + path: "/root/sub-dir", + isRoot: false, + items: [ + FileListItem("file 1"), + FileListItem("Fixes"), + FileListItem("Food", true), + FileListItem("dir 1", true), + ], + }; + /** @type {FileListAction} */ + const action = { action: "FileListDirUpdatedAction", currDir }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: 0, + index: 0, + currDir: { + ...currDir, + items: [ + FileListItem.up, + FileListItem("dir 1", true), + FileListItem("Food", true), + FileListItem("file 1"), + FileListItem("Fixes"), + ], + }, + selectedNames: new Set(), + }); + }); + + it("should update state when FileListItemCreatedAction", () => { + //given + const stateDir = { path: "/", isRoot: true, items: [] }; + const state = { + ...FileListState(), + offset: 1, + currDir: stateDir, + selectedNames: new Set(["test1"]), + }; + const dir = "dir 2"; + /** @type {FileListDir} */ + const currDir = { + ...stateDir, + items: [ + FileListItem("file 2"), + FileListItem("File 1"), + FileListItem(dir, true), + FileListItem("Dir 1", true), + ], + }; + /** @type {FileListAction} */ + const action = { action: "FileListItemCreatedAction", name: dir, currDir }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: 0, + index: 1, + currDir: { + ...currDir, + items: [ + FileListItem("Dir 1", true), + FileListItem("dir 2", true), + FileListItem("File 1"), + FileListItem("file 2"), + ], + }, + }); + }); + + it("should keep current sate if item not found when FileListItemCreatedAction", () => { + //given + const stateDir = { + path: "/", + isRoot: true, + items: [ + FileListItem("Dir 1", true), + FileListItem("dir 2", true), + FileListItem("File 1"), + FileListItem("file 2"), + ], + }; + const state = { + ...FileListState(), + offset: 1, + currDir: stateDir, + selectedNames: new Set(["test1"]), + }; + /** @type {FileListAction} */ + const action = { + action: "FileListItemCreatedAction", + name: "non-existing", + currDir: stateDir, + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, state); + }); + + it("should update state when FileListSortAction", () => { + //given + const state = { + ...FileListState(), + offset: 1, + currDir: { + path: "/", + isRoot: false, + items: [ + FileListItem.up, + FileListItem("file 2"), + FileListItem("File 1"), + FileListItem("dir 2", true), + FileListItem("Dir 1", true), + ], + }, + selectedNames: new Set(["test1"]), + }; + /** @type {FileListAction} */ + const action = { + action: "FileListSortAction", + mode: SortMode.Name, + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + offset: 0, + index: 3, + currDir: { + ...state.currDir, + items: [ + FileListItem.up, + FileListItem("dir 2", true), + FileListItem("Dir 1", true), + FileListItem("file 2"), + FileListItem("File 1"), + ], + }, + sort: { ...state.sort, asc: false }, + }); + }); + + it("should keep current offset/index if item not found when FileListSortAction", () => { + //given + const state = { + ...FileListState(), + offset: 1, + currDir: { path: "/", isRoot: true, items: [] }, + }; + /** @type {FileListAction} */ + const action = { + action: "FileListSortAction", + mode: SortMode.Name, + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { + ...state, + sort: { ...state.sort, asc: false }, + }); + }); + + it("should update state when FileListDiskSpaceUpdatedAction", () => { + //given + const state = FileListState(); + assert.deepEqual(state.diskSpace, undefined); + /** @type {FileListAction} */ + const action = { + action: "FileListDiskSpaceUpdatedAction", + diskSpace: 123.45, + }; + + //when + const result = FileListStateReducer(state, action); + + //then + assert.deepEqual(result, { ...state, diskSpace: 123.45 }); + }); +}); diff --git a/test/all.mjs b/test/all.mjs index 4e9acea..228219f 100644 --- a/test/all.mjs +++ b/test/all.mjs @@ -1,5 +1,6 @@ await import("./FileListActions.test.mjs"); await import("./FileListState.test.mjs"); +await import("./FileListStateReducer.test.mjs"); await import("./api/FileListItem.test.mjs");