Add tests for client (#33)

* fix(fs/local): force closing fds and add backoffs, unit tests

* test(client/web): add unit tests
This commit is contained in:
Hexxa 2021-01-30 10:01:38 +08:00 committed by GitHub
parent 1ff1e2024e
commit ea3400aca6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 301 additions and 70 deletions

View file

@ -1,13 +1,20 @@
import { List, Map } from "immutable";
import { mock, instance } from "ts-mockito";
import { mock, instance, anyString, when } from "ts-mockito";
import { initWithWorker } from "../core_state";
import { makePromise, makeNumberResponse } from "../../test/helpers";
import { Updater } from "../browser";
import { ICoreState, initWithWorker, mockState } from "../core_state";
import {
makePromise,
makeNumberResponse,
mockUpdate,
} from "../../test/helpers";
import { Updater, Browser } from "../browser";
import { MockUsersClient } from "../../client/users_mock";
import { FilesClient } from "../../client/files_mock";
import { MetadataResp } from "../../client";
import { MockWorker } from "../../worker/interface";
import { UsersClient } from "../../client/users";
import { FilesClient } from "../../client/files";
import { FilesClient as MockFilesClient } from "../../client/files_mock";
import { MetadataResp, UploadInfo } from "../../client";
import { MockWorker, UploadEntry } from "../../worker/interface";
import { UploadMgr } from "../../worker/upload_mgr";
describe("Browser", () => {
const mockWorkerClass = mock(MockWorker);
@ -41,7 +48,7 @@ describe("Browser", () => {
];
const usersClient = new MockUsersClient("");
const filesClient = new FilesClient("");
const filesClient = new MockFilesClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
@ -102,7 +109,7 @@ describe("Browser", () => {
];
const usersClient = new MockUsersClient("");
const filesClient = new FilesClient("");
const filesClient = new MockFilesClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
@ -163,7 +170,7 @@ describe("Browser", () => {
];
const usersClient = new MockUsersClient("");
const filesClient = new FilesClient("");
const filesClient = new MockFilesClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
@ -190,4 +197,72 @@ describe("Browser", () => {
});
}
});
xtest("Browser: deleteUploading", async () => {
interface TestCase {
deleteFile: string;
preState: ICoreState;
postState: ICoreState;
}
const tcs: any = [
{
deleteFile: "./path/file",
preState: {
browser: {
uploadings: List<UploadInfo>([
{
realFilePath: "./path/file",
size: 1,
uploaded: 0,
},
]),
update: mockUpdate,
},
},
postState: {
browser: {
uploadings: List<UploadInfo>(),
update: mockUpdate,
},
},
},
];
const setState = (patch: any, state: ICoreState): ICoreState => {
state.panel.browser = patch.browser;
return state;
};
const mockFilesClientClass = mock(FilesClient);
when(mockFilesClientClass.deleteUploading(anyString())).thenResolve({
status: 200,
statusText: "",
data: "",
});
// TODO: the return should dpends on test case
when(mockFilesClientClass.listUploadings()).thenResolve({
status: 200,
statusText: "",
data: { uploadInfos: Array<UploadInfo>() },
});
const mockUsersClientClass = mock(UsersClient);
const mockFilesClient = instance(mockFilesClientClass);
const mockUsersClient = instance(mockUsersClientClass);
tcs.forEach((tc: TestCase) => {
const preState = setState(tc.preState, mockState());
const postState = setState(tc.postState, mockState());
// const existingFileName = preState.panel.browser.uploadings.get(0).realFilePath;
const infos:Map<string, UploadEntry> = Map();
UploadMgr._setInfos(infos);
const component = new Browser(preState.panel.browser);
Updater.init(preState.panel.browser);
Updater.setClients(mockUsersClient, mockFilesClient);
component.deleteUploading(tc.deleteFile);
expect(Updater.props).toEqual(postState.panel.browser);
});
});
});

View file

@ -0,0 +1,49 @@
import { Set } from "immutable";
import { ICoreState, mockState } from "../core_state";
import { Panes, Updater } from "../panes";
import { mockUpdate } from "../../test/helpers";
describe("Panes", () => {
test("Panes: closePane", async () => {
interface TestCase {
preState: ICoreState;
postState: ICoreState;
}
const tcs: any = [
{
preState: {
panes: {
displaying: "settings",
paneNames: Set<string>(["settings", "login"]),
update: mockUpdate,
},
},
postState: {
panes: {
displaying: "",
paneNames: Set<string>(["settings", "login"]),
update: mockUpdate,
},
},
},
];
const setState = (patch: any, state: ICoreState): ICoreState => {
state.panel.panes = patch.panes;
return state;
};
tcs.forEach((tc: TestCase) => {
const preState = setState(tc.preState, mockState());
const postState = setState(tc.postState, mockState());
const component = new Panes(preState.panel.panes);
Updater.init(preState.panel.panes);
component.closePane();
expect(Updater.props).toEqual(postState.panel.panes);
});
});
});

View file

@ -0,0 +1,52 @@
import { Set } from "immutable";
// import { mock, instance } from "ts-mockito";
import { ICoreState, mockState } from "../core_state";
import { RootFrame } from "../root_frame";
import { Updater } from "../panes";
describe("RootFrame", () => {
test("component: showSettings", async () => {
interface TestCase {
preState: ICoreState;
postState: ICoreState;
}
const mockUpdate = (apply: (prevState: ICoreState) => ICoreState): void => {};
const tcs: any = [
{
preState: {
displaying: "",
panes: {
displaying: "",
paneNames: Set<string>(["settings", "login"]),
},
update: mockUpdate,
},
postState: {
displaying: "settings",
panes: {
displaying: "settings",
paneNames: Set<string>(["settings", "login"]),
},
update: mockUpdate,
},
},
];
const setState = (patch: any, state: ICoreState): ICoreState => {
return { ...state, panel: { ...state.panel, ...patch } };
};
tcs.forEach((tc: TestCase) => {
const preState = setState(tc.preState, mockState());
const postState = setState(tc.postState, mockState());
const component = new RootFrame(preState.panel);
Updater.init(preState.panel.panes);
component.showSettings();
expect(Updater.props).toEqual(postState.panel.panes);
});
});
});

View file

@ -46,7 +46,7 @@ function getItemPath(dirPath: string, itemName: string): string {
}
export class Updater {
private static props: Props;
static props: Props;
private static usersClient: IUsersClient;
private static filesClient: IFilesClient;
@ -160,8 +160,12 @@ export class Updater {
static addUploadFiles = (fileList: FileList, len: number) => {
for (let i = 0; i < len; i++) {
const filePath = getItemPath(
Updater.props.dirPath.join("/"),
fileList[i].name
);
// do not wait for the promise
UploadMgr.add(fileList[i], fileList[i].name);
UploadMgr.add(fileList[i], filePath);
}
Updater.setUploadings(UploadMgr.list());
};
@ -215,9 +219,6 @@ export class Browser extends React.Component<Props, State, {}> {
});
}
// showPane = () => {
// this.setState({ show: !this.state.show });
// };
onInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ inputValue: ev.target.value });
};
@ -245,7 +246,15 @@ export class Browser extends React.Component<Props, State, {}> {
};
onMkDir = () => {
Updater.mkDir(this.state.inputValue)
if (this.state.inputValue === "") {
alert("folder name can not be empty");
}
const dirPath = getItemPath(
this.props.dirPath.join("/"),
this.state.inputValue
);
Updater.mkDir(dirPath)
.then(() => {
this.setState({ inputValue: "" });
return Updater.setItems(this.props.dirPath);
@ -433,7 +442,7 @@ export class Browser extends React.Component<Props, State, {}> {
<button
onClick={() => this.select(item.name)}
className={`white-font ${isSelected ? "blue0-bg" : ""}`}
style={{width: "8rem", display: "inline-block"}}
style={{ width: "8rem", display: "inline-block" }}
>
{isSelected ? "Deselect" : "Select"}
</button>
@ -467,7 +476,7 @@ export class Browser extends React.Component<Props, State, {}> {
type="button"
onClick={() => this.select(item.name)}
className={`white-font ${isSelected ? "blue0-bg" : ""}`}
style={{width: "8rem", display: "inline-block"}}
style={{ width: "8rem", display: "inline-block" }}
>
{isSelected ? "Deselect" : "Select"}
</button>

View file

@ -3,11 +3,19 @@ import { List, Set } from "immutable";
import BgWorker from "../worker/upload.bg.worker";
import { FgWorker } from "../worker/upload.fgworker";
import { Props as PanelProps } from "./panel";
import { Props as PanelProps } from "./root_frame";
import { Item } from "./browser";
import { UploadInfo } from "../client";
import { UploadMgr, IWorker } from "../worker/upload_mgr";
export class BaseUpdater {
public static props: any;
public static init = (props: any) => (BaseUpdater.props = { ...props });
public static apply = (prevState: ICoreState): ICoreState => {
throw Error("apply is not implemented");
};
}
export interface IContext {
update: (targetStatePatch: any) => void;
}
@ -25,11 +33,8 @@ export function initWithWorker(worker: IWorker): ICoreState {
export function init(): ICoreState {
const scripts = Array.from(document.querySelectorAll("script"));
if (!Worker) {
alert("web worker is not supported");
}
const worker = Worker == null ? new FgWorker() : new BgWorker();
const worker = new BgWorker();
UploadMgr.init(worker);
return initState();
}
@ -65,3 +70,31 @@ export function initState(): ICoreState {
},
};
}
export function mockState(): ICoreState {
return {
ctx: undefined,
isVertical: false,
panel: {
displaying: "browser",
authPane: {
authed: false,
},
browser: {
isVertical: false,
dirPath: List<string>(["."]),
items: List<Item>([]),
uploadings: List<UploadInfo>([]),
uploadValue: "",
uploadFiles: List<File>([]),
},
panes: {
displaying: "",
paneNames: Set<string>(["settings", "login"]),
login: {
authed: false,
},
},
},
};
}

View file

@ -13,7 +13,7 @@ export interface Props {
}
export class Updater {
private static props: Props;
static props: Props;
static init = (props: Props) => (Updater.props = { ...props });
@ -44,17 +44,15 @@ export class Updater {
export interface State {}
export class Panes extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
constructor(p: Props) {
super(p);
Updater.init(p);
this.update = p.update;
}
closePane = () => {
if (this.props.displaying !== "login") {
Updater.displayPane("");
this.update(Updater.updateState);
this.props.update(Updater.updateState);
}
};
@ -66,8 +64,8 @@ export class Panes extends React.Component<Props, State, {}> {
}
const panesMap: Map<string, JSX.Element> = Map({
settings: <PaneSettings login={this.props.login} update={this.update} />,
login: <AuthPane authed={this.props.login.authed} update={this.update} />,
settings: <PaneSettings login={this.props.login} update={this.props.update} />,
login: <AuthPane authed={this.props.login.authed} update={this.props.update} />,
});
const panes = panesMap.keySeq().map(

View file

@ -1,24 +1,22 @@
import * as React from "react";
import { ICoreState } from "./core_state";
import { ICoreState, BaseUpdater } from "./core_state";
import { Browser, Props as BrowserProps } from "./browser";
import { AuthPane, Props as AuthPaneProps } from "./pane_login";
import { Props as PaneLoginProps } from "./pane_login";
import { Panes, Props as PanesProps, Updater as PanesUpdater } from "./panes";
export interface Props {
displaying: string;
browser: BrowserProps;
authPane: AuthPaneProps;
authPane: PaneLoginProps;
panes: PanesProps;
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
}
export class Updater {
private static props: Props;
static init = (props: Props) => (Updater.props = { ...props });
static setPanel = (prevState: ICoreState): ICoreState => {
public static props: Props;
public static init = (props: Props) => (BaseUpdater.props = { ...props });
public static apply = (prevState: ICoreState): ICoreState => {
return {
...prevState,
panel: { ...prevState.panel, ...Updater.props },
@ -27,20 +25,19 @@ export class Updater {
}
export interface State {}
export class Panel extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
export class RootFrame extends React.Component<Props, State, {}> {
constructor(p: Props) {
super(p);
Updater.init(p);
this.update = p.update;
}
showSettings = () => {
PanesUpdater.displayPane("settings");
this.update(PanesUpdater.updateState);
this.props.update(PanesUpdater.updateState);
};
render() {
const update = this.props.update;
return (
<div className="theme-white desktop">
<div id="bg" className="bg bg-img font-m">
@ -48,7 +45,7 @@ export class Panel extends React.Component<Props, State, {}> {
displaying={this.props.panes.displaying}
paneNames={this.props.panes.paneNames}
login={this.props.authPane}
update={this.update}
update={update}
/>
<div
@ -78,16 +75,18 @@ export class Panel extends React.Component<Props, State, {}> {
dirPath={this.props.browser.dirPath}
items={this.props.browser.items}
uploadings={this.props.browser.uploadings}
update={this.update}
update={update}
uploadFiles={this.props.browser.uploadFiles}
uploadValue={this.props.browser.uploadValue}
isVertical={this.props.browser.isVertical}
/>
</div>
<div className="container-center black0-font tail margin-t-xl margin-b-xl">
<a href="https://github.com/ihexxa/quickshare">Quickshare</a> -
sharing in simple way.
</div>
</div>
</div>
);

View file

@ -1,40 +1,30 @@
import * as React from "react";
import { ICoreState, init } from "./core_state";
import { Panel } from "./panel";
import { RootFrame } from "./root_frame";
export interface Props {}
export interface State extends ICoreState {}
export class UpdaterBase {
private static props: any;
static init = (props: any) => (UpdaterBase.props = {...props});
}
export class StateMgr extends React.Component<Props, State, {}> {
constructor(p: Props) {
super(p);
this.state = init();
}
// TODO: any can be eliminated by adding union type of children states
update = (updater: (prevState:ICoreState) => ICoreState): void => {
console.log("before", this.state)
this.setState(updater(this.state));
console.log("after", this.state)
update = (apply: (prevState: ICoreState) => ICoreState): void => {
this.setState(apply(this.state));
};
render() {
return (
<div>
<Panel
authPane = {this.state.panel.authPane}
displaying={this.state.panel.displaying}
update={this.update}
browser={this.state.panel.browser}
panes={this.state.panel.panes}
/>
</div>
<RootFrame
authPane={this.state.panel.authPane}
displaying={this.state.panel.displaying}
update={this.update}
browser={this.state.panel.browser}
panes={this.state.panel.panes}
/>
);
}
}

View file

@ -1,4 +1,5 @@
import { Response } from "../client";
import { ICoreState } from "../components/core_state";
export const makePromise = (ret: any): Promise<any> => {
return new Promise<any>((resolve) => {
@ -13,3 +14,5 @@ export const makeNumberResponse = (status: number): Promise<Response> => {
data: {},
});
};
export const mockUpdate = (apply: (prevState: ICoreState) => ICoreState): void => {};

View file

@ -14,7 +14,7 @@ export class UploadWorker {
private file: File = undefined;
private filePath: string = undefined;
private uploader: FileUploader = undefined;
sendEvent = (resp: FileWorkerResp):void => {
sendEvent = (resp: FileWorkerResp): void => {
// TODO: make this abstract
throw new Error("not implemented");
};
@ -30,6 +30,8 @@ export class UploadWorker {
stopUploader = () => {
if (this.uploader != null) {
this.uploader.stop();
this.file = undefined;
this.filePath = undefined;
}
};
getFilePath = (): string => {
@ -49,6 +51,7 @@ export class UploadWorker {
// find the first qualified task
const syncReq = req as SyncReq;
const infoArray = syncReq.infos;
for (let i = 0; i < infoArray.length; i++) {
if (
infoArray[i].runnable &&
@ -59,6 +62,11 @@ export class UploadWorker {
this.startUploader(infoArray[i].file, infoArray[i].filePath);
}
break;
} else if (
!infoArray[i].runnable &&
infoArray[i].filePath == this.filePath
) {
this.stopUploader();
}
}
break;

View file

@ -8,6 +8,7 @@ const speedDownRatio = 0.5;
const speedUpRatio = 1.05;
const createRetryLimit = 2;
const uploadRetryLimit = 1024;
const backoffMax = 2000;
export interface IFileUploader {
stop: () => void;
@ -71,6 +72,13 @@ export class FileUploader {
this.client = client;
};
backOff = async (): Promise<void> => {
return new Promise((resolve) => {
const delay = Math.floor(Math.random() * backoffMax);
setTimeout(resolve, delay);
});
};
start = async (): Promise<boolean> => {
let resp: Response;
@ -81,6 +89,7 @@ export class FileUploader {
return await this.upload();
}
} catch (e) {
await this.backOff();
console.error(e);
}
}
@ -153,6 +162,8 @@ export class FileUploader {
);
break;
}
await this.backOff();
}
} catch (e) {
this.errMsgs.push(e.toString());

View file

@ -99,6 +99,16 @@ func (fs *LocalFS) translate(name string) (string, error) {
}
func (fs *LocalFS) Create(path string) error {
fs.opensMtx.Lock()
defer fs.opensMtx.Unlock()
if len(fs.opens) > fs.opensLimit {
err := fs.closeOpens(true, map[string]bool{})
if err != nil {
return fmt.Errorf("too many opens and fail to clean: %w", err)
}
return ErrTooManyOpens
}
fullpath, err := fs.translate(path)
if err != nil {
return err
@ -109,12 +119,6 @@ func (fs *LocalFS) Create(path string) error {
return err
}
fs.opensMtx.Lock()
defer fs.opensMtx.Unlock()
if len(fs.opens) > fs.opensLimit {
return ErrTooManyOpens
}
fs.opens[fullpath] = &fileInfo{
lastAccess: time.Now(),
fd: fd,