Skip to content

Commit

Permalink
Adding state machine tests. More conventional naming. Todo list added.
Browse files Browse the repository at this point in the history
  • Loading branch information
Neloreck committed Nov 1, 2023
1 parent bb8a1b4 commit 34173ca
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 54 deletions.
3 changes: 3 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## List of todo tasks

- Add event emitter abstraction instead of interfaces + Java style
16 changes: 8 additions & 8 deletions src/agent/AbstractAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ export abstract class AbstractAgent implements IAgent {
}

this.idleState.addListener(this);
this.fsm.addPlanEventListener(this);
this.fsm.addEventListener(this);
}

/**
* Handle update tick.
*/
public update(): void {
if (!this.fsm.hasStates()) {
this.fsm.pushStack(this.idleState);
if (!this.fsm.hasAny()) {
this.fsm.push(this.idleState);
}

this.unit.update();
Expand All @@ -48,13 +48,13 @@ export abstract class AbstractAgent implements IAgent {

public onPlanCreated(plan: Queue<AbstractAction>): void {
this.unit.goapPlanFound(plan);
this.fsm.popStack();
this.fsm.pushStack(new RunActionState(this.fsm, plan));
this.fsm.pop();
this.fsm.push(new RunActionState(this.fsm, plan));
}

public onImportantUnitGoalChange(state: State): void {
state.importance = Infinity;
this.fsm.pushStack(this.idleState);
this.fsm.push(this.idleState);
}

public onImportantUnitStackResetChange(): void {
Expand All @@ -63,8 +63,8 @@ export abstract class AbstractAgent implements IAgent {
action.reset();
}

this.fsm.clearStack();
this.fsm.pushStack(this.idleState);
this.fsm.clear();
this.fsm.push(this.idleState);
}

/**
Expand Down
153 changes: 153 additions & 0 deletions src/state_machine/FiniteStateMachine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, it, jest } from "@jest/globals";

import { GenericAction } from "#/fixtures/mocks";

import { AbstractAction } from "@/AbstractAction";
import { IFiniteStateMachinePlanEventListener } from "@/event/IFiniteStateMachinePlanEventListener";
import { AbstractPlanner } from "@/planner/AbstractPlanner";
import { FiniteStateMachine } from "@/state_machine/FiniteStateMachine";
import { IdleState } from "@/state_machine/IdleState";
import { MoveToState } from "@/state_machine/MoveToState";
import { RunActionState } from "@/state_machine/RunActionState";
import { Queue } from "@/types";
import { IUnit } from "@/unit/IUnit";

describe("FiniteStateMachine class", () => {
it("should correctly initialize", () => {
const fsm: FiniteStateMachine = new FiniteStateMachine();

expect(fsm.getStack()).toEqual([]);
expect(fsm.hasAny()).toBe(false);
});

it("should correctly initialize handle pop/push/get", () => {
const fsm: FiniteStateMachine = new FiniteStateMachine();
const moveToState: MoveToState = new MoveToState(new GenericAction(1));
const actState: RunActionState = new RunActionState(fsm, [new GenericAction(2), new GenericAction(3)]);
const idleState: IdleState = new IdleState({} as AbstractPlanner);

expect(fsm.getStack()).toEqual([]);
expect(fsm.pop()).toBeUndefined();
expect(fsm.hasAny()).toBe(false);

fsm.push(idleState);
fsm.push(actState);
fsm.push(moveToState);

expect(fsm.hasAny()).toBe(true);
expect(fsm.getStack()).toEqual([idleState, actState, moveToState]);
expect(fsm.pop()).toBe(moveToState);
expect(fsm.getStack()).toEqual([idleState, actState]);
expect(fsm.pop()).toBe(actState);
expect(fsm.getStack()).toEqual([idleState]);
expect(fsm.pop()).toBe(idleState);
expect(fsm.getStack()).toEqual([]);
expect(fsm.pop()).toBeUndefined();
expect(fsm.getStack()).toEqual([]);

fsm.push(idleState);
fsm.push(actState);

expect(fsm.hasAny()).toBe(true);
expect(fsm.getStack()).toEqual([idleState, actState]);

fsm.clear();

expect(fsm.hasAny()).toBe(false);
expect(fsm.getStack()).toEqual([]);
});

it("should correctly update when empty", () => {
const fsm: FiniteStateMachine = new FiniteStateMachine();
const eventListener: IFiniteStateMachinePlanEventListener = { onPlanFailed: jest.fn(), onPlanFinished: jest.fn() };
const unit: IUnit = {} as IUnit;

fsm.addEventListener(eventListener);

fsm.update(unit);
fsm.update(unit);
fsm.update(unit);

expect(eventListener.onPlanFinished).not.toHaveBeenCalled();
expect(eventListener.onPlanFailed).not.toHaveBeenCalled();
});

it("should correctly update and handle perform action emit event", () => {
const fsm: FiniteStateMachine = new FiniteStateMachine();
const eventListener: IFiniteStateMachinePlanEventListener = { onPlanFailed: jest.fn(), onPlanFinished: jest.fn() };
const actState: RunActionState = new RunActionState(fsm, [new GenericAction(2), new GenericAction(3)]);
const unit: IUnit = {} as IUnit;

fsm.addEventListener(eventListener);
fsm.push(actState);

jest.spyOn(actState, "execute").mockImplementation(() => true);

fsm.update(unit);
fsm.update(unit);

expect(eventListener.onPlanFinished).not.toHaveBeenCalled();
expect(eventListener.onPlanFailed).not.toHaveBeenCalled();
expect(fsm.getStack()).toEqual([actState]);

jest.spyOn(actState, "execute").mockImplementation(() => false);

fsm.update(unit);

expect(eventListener.onPlanFinished).toHaveBeenCalledTimes(1);
expect(eventListener.onPlanFinished).toHaveBeenCalled();
expect(eventListener.onPlanFailed).not.toHaveBeenCalled();
expect(fsm.getStack()).toEqual([]);
});

it("should correctly update and handle errors", () => {
const fsm: FiniteStateMachine = new FiniteStateMachine();
const listener: IFiniteStateMachinePlanEventListener = { onPlanFailed: jest.fn(), onPlanFinished: jest.fn() };
const unit: IUnit = {} as IUnit;

const somePlan: Queue<AbstractAction> = [new GenericAction(5), new GenericAction(32)];
const anotherActState: RunActionState = new RunActionState(fsm, somePlan);
const actState: RunActionState = new RunActionState(fsm, [new GenericAction(2), new GenericAction(3)]);
const idleState: IdleState = new IdleState({} as AbstractPlanner);

fsm.addEventListener(listener);
fsm.push(idleState);
fsm.push(actState);
fsm.push(anotherActState);

jest.spyOn(anotherActState, "execute").mockImplementation(() => {
throw new Error("test-error");
});

expect(fsm.getStack()).toEqual([idleState, actState, anotherActState]);

fsm.update(unit);

expect(listener.onPlanFinished).not.toHaveBeenCalled();
expect(listener.onPlanFailed).toHaveBeenCalledWith(somePlan);
expect(fsm.getStack()).toEqual([idleState, actState]);

fsm.push(anotherActState);
fsm.update(unit);

expect(listener.onPlanFailed).toHaveBeenCalledTimes(2);

fsm.removeEventListener(listener);

fsm.push(anotherActState);
fsm.update(unit);

expect(listener.onPlanFailed).toHaveBeenCalledTimes(2);
});

it("should correctly update and emit events", () => {
const fsm: FiniteStateMachine = new FiniteStateMachine();
const moveToState: MoveToState = new MoveToState(new GenericAction(1));
const actState: RunActionState = new RunActionState(fsm, [new GenericAction(2), new GenericAction(3)]);
const idleState: IdleState = new IdleState({} as AbstractPlanner);

fsm.push(idleState);
fsm.push(actState);
fsm.push(moveToState);
});
});
65 changes: 35 additions & 30 deletions src/state_machine/FiniteStateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AbstractAction } from "@/AbstractAction";
import { IFiniteStateMachinePlanEventListener } from "@/event/IFiniteStateMachinePlanEventListener";
import { IFiniteStateMachineState } from "@/state_machine/IFiniteStateMachineState";
import { RunActionState } from "@/state_machine/RunActionState";
import { Maybe, Queue, Stack } from "@/types";
import { Definable, Maybe, Queue, Stack } from "@/types";
import { IUnit } from "@/unit/IUnit";
import { removeFromArray, stackPeek } from "@/utils/array";

Expand All @@ -11,59 +11,39 @@ export class FiniteStateMachine {
private planEventListeners: Array<IFiniteStateMachinePlanEventListener> = [];

/**
* Run through all action in the specific states.
* If an Exception occurs (mainly in RunActionState) the FSM assumes the plan failed.
* If an action state returns false the FSM assumes the plan finished.
*
* @param unit - unit whose actions are getting cycled.
* @returns state machine states stack
*/
public update(unit: IUnit): void {
try {
if (this.states.length > 0 && !stackPeek(this.states).execute(unit)) {
const state: IFiniteStateMachineState = this.states.pop();

if (state instanceof RunActionState) {
this.dispatchNewPlanFinishedEvent();
}
}
} catch (error) {
const state: Maybe<IFiniteStateMachineState> = this.states.pop();

if (state instanceof RunActionState) {
this.dispatchNewPlanFailedEvent(state.getCurrentActions());
}

// todo: Print error.
}
public getStack(): Readonly<Stack<IFiniteStateMachineState>> {
return this.states;
}

/**
* Pushes value into stack.
*
* @param state - state to push
*/
public pushStack(state: IFiniteStateMachineState): void {
public push(state: IFiniteStateMachineState): void {
this.states.push(state);
}

/**
* @returns peek element and pops it from the stack
*/
public popStack(): IFiniteStateMachineState {
public pop(): Definable<IFiniteStateMachineState> {
return this.states.pop();
}

/**
* Clear states stack.
*/
public clearStack(): void {
public clear(): void {
this.states.length = 0;
}

/**
* @returns if any states exist in the execution stack
*/
public hasStates(): boolean {
public hasAny(): boolean {
return this.states.length > 0;
}

Expand All @@ -72,7 +52,7 @@ export class FiniteStateMachine {
*
* @param listener - object to subscribe for listening
*/
public addPlanEventListener(listener: IFiniteStateMachinePlanEventListener): void {
public addEventListener(listener: IFiniteStateMachinePlanEventListener): void {
this.planEventListeners.push(listener);
}

Expand All @@ -81,7 +61,7 @@ export class FiniteStateMachine {
*
* @param listener - object to unsubscribe from listening
*/
public removePlanEventListener(listener: IFiniteStateMachinePlanEventListener): void {
public removeEventListener(listener: IFiniteStateMachinePlanEventListener): void {
removeFromArray(this.planEventListeners, listener);
}

Expand All @@ -104,4 +84,29 @@ export class FiniteStateMachine {
listener.onPlanFinished();
}
}

/**
* Run through all action in the specific states.
* If an Exception occurs (mainly in RunActionState) the FSM assumes the plan failed.
* If an action state returns false the FSM assumes the plan finished.
*
* @param unit - unit whose actions are getting cycled
*/
public update(unit: IUnit): void {
try {
if (this.states.length && !stackPeek(this.states).execute(unit)) {
if (this.states.pop() instanceof RunActionState) {
this.dispatchNewPlanFinishedEvent();
}
}
} catch (error) {
const state: Maybe<IFiniteStateMachineState> = this.states.pop();

if (state instanceof RunActionState) {
this.dispatchNewPlanFailedEvent(state.getCurrentPlan());
}

// todo: Print error.
}
}
}
50 changes: 50 additions & 0 deletions src/state_machine/IdleState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it, jest } from "@jest/globals";

import { GenericAction } from "#/fixtures/mocks";

import { AbstractAction } from "@/AbstractAction";
import { IPlanCreatedEventListener } from "@/event/IPlanCreatedEventListener";
import { IPlanner } from "@/planner/IPlanner";
import { IdleState } from "@/state_machine/IdleState";
import { Queue } from "@/types";
import { IUnit } from "@/unit/IUnit";

describe("IdleState class", () => {
it("should correctly initialize", () => {
const planner: IPlanner = { plan: jest.fn(() => null) } as IPlanner;
const state: IdleState = new IdleState(planner);
const unit: IUnit = {} as IUnit;

expect(state.execute(unit)).toBe(true);
expect(planner.plan).toHaveBeenCalledTimes(1);
expect(planner.plan).toHaveBeenCalledWith(unit);
});

it("should correctly emit events when plan is created", () => {
const plan: Queue<AbstractAction> = [new GenericAction(1), new GenericAction(2)];
const planner: IPlanner = { plan: jest.fn(() => null) } as IPlanner;
const listener: IPlanCreatedEventListener = { onPlanCreated: jest.fn() };
const state: IdleState = new IdleState(planner);
const unit: IUnit = {} as IUnit;

state.addListener(listener);

expect(state.execute(unit)).toBe(true);
expect(listener.onPlanCreated).not.toHaveBeenCalled();

jest.spyOn(planner, "plan").mockImplementation(() => plan);

expect(state.execute(unit)).toBe(true);
expect(listener.onPlanCreated).toHaveBeenCalledTimes(1);
expect(listener.onPlanCreated).toHaveBeenCalledWith(plan);

expect(state.execute(unit)).toBe(true);
expect(listener.onPlanCreated).toHaveBeenCalledTimes(2);
expect(listener.onPlanCreated).toHaveBeenNthCalledWith(2, plan);

state.removeListener(listener);

expect(state.execute(unit)).toBe(true);
expect(listener.onPlanCreated).toHaveBeenCalledTimes(2);
});
});
Loading

0 comments on commit 34173ca

Please sign in to comment.