Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/components/battle-calc/battle-calc-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ export class BattleCalcView implements TW2WindowView<WestCalcWindowTab>, Compone

getMainDiv(): JQuery {
if (!this.battleCalc.isAvailable()) {
return $('<div>Battle calc core script is not loaded, try refreshing the game.</div>');
// kick off a (re)load in the background so reopening this window recovers it
this.battleCalc.load();
return $('<div>Battle calc core script is not loaded yet, reopen this window in a few seconds.</div>');
}

const { west } = this.window;
Expand Down
116 changes: 116 additions & 0 deletions src/components/battle-calc/battle-calc.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { BattleCalc } from './battle-calc';
import { BattleCalcWindow, BattleCoreCalc } from './battle-calc.types';
import { Config } from '../config/config';
import { ErrorTracker } from '../error-tracker/error-tracker';
import { Logger } from '../logger/logger';
import { Mock } from 'ts-mocks';

type Behavior = 'done' | 'fail' | 'pending';

/**
* Minimal stand-in for the jqXHR returned by `$.getScript`, with controllable
* resolution so we can drive the success / failure / in-flight branches.
*/
function fakeJqXHR(behavior: Behavior): unknown {
const xhr = {
done(cb: () => void) {
if (behavior === 'done') {
cb();
}
return xhr;
},
fail(cb: () => void) {
if (behavior === 'fail') {
cb();
}
return xhr;
},
};
return xhr;
}

describe('BattleCalc', () => {
const website = 'https://tw-calc.net';
const coreScriptUrl = website + '/js/battle-calculator-core.js';

let battleCalc: BattleCalc;
let loggerMock: Mock<Logger>;
let getScript: jasmine.Spy;
let setTimeoutSpy: jasmine.Spy;
let win: BattleCalcWindow & { BattleCalc?: BattleCoreCalc };

function build(behavior: Behavior): void {
getScript = jasmine.createSpy('getScript').and.callFake(() => fakeJqXHR(behavior));
// run scheduled retries synchronously so the whole chain resolves in the test
setTimeoutSpy = jasmine.createSpy('setTimeout').and.callFake((cb: () => void) => {
cb();
return 0;
});
loggerMock = new Mock<Logger>({ error: Mock.ANY_FUNC });

win = {
$: { getScript } as unknown as BattleCalcWindow['$'],
setTimeout: setTimeoutSpy as unknown as Window['setTimeout'],
} as BattleCalcWindow;

battleCalc = new BattleCalc(
win,
new Mock<Config>({ website }).Object,
loggerMock.Object,
new Mock<ErrorTracker>({ track: Mock.ANY_FUNC }).Object,
);
}

it('loads the battle calc core script on init', () => {
build('done');

battleCalc.init();

expect(getScript).toHaveBeenCalledTimes(1);
expect(getScript).toHaveBeenCalledWith(coreScriptUrl);
});

it('retries up to 3 times and logs each failure', () => {
build('fail');

battleCalc.init();

expect(getScript).toHaveBeenCalledTimes(3);
expect(loggerMock.Object.error).toHaveBeenCalledTimes(3);
// backoff is only scheduled between attempts, not after the final one
expect(setTimeoutSpy).toHaveBeenCalledTimes(2);
});

it('does not start a second load while one is already in progress', () => {
build('pending');

battleCalc.load();
battleCalc.load();

expect(getScript).toHaveBeenCalledTimes(1);
});

it('does not load when the core is already available', () => {
build('done');
win.BattleCalc = { coreCalc: () => ({}) } as unknown as BattleCoreCalc;

battleCalc.load();

expect(getScript).not.toHaveBeenCalled();
});

it('getInstance throws when the core is not loaded', () => {
build('pending');

expect(() => battleCalc.getInstance()).toThrowError('Battle calc core is not loaded yet!');
});

it('getInstance returns the core once available', () => {
build('done');
const core = { coreCalc: () => ({}) } as unknown as BattleCoreCalc;
win.BattleCalc = core;

expect(battleCalc.isAvailable()).toBe(true);
expect(battleCalc.getInstance()).toBe(core);
});
});
37 changes: 36 additions & 1 deletion src/components/battle-calc/battle-calc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,39 @@ import { Component } from '../component.types';
import { Config } from '../config/config';
import { ErrorTracker } from '../error-tracker/error-tracker';
import { inject, singleton } from 'tsyringe';
import { Logger } from '../logger/logger';
import { MarkRequired } from 'ts-essentials';

const MAX_LOAD_ATTEMPTS = 3;

@singleton()
export class BattleCalc implements Component {
private loading = false;

constructor(
@inject('window') private window: BattleCalcWindow,
private config: Config,
private readonly logger: Logger,
public readonly errorTracker: ErrorTracker,
) {}

@CatchErrors('BattleCalc.init')
init(): void {
this.window.$.getScript(this.config.website + '/js/battle-calculator-core.js');
this.load();
}

/**
* Loads the battle calc core script from the server. A single failed or cancelled
* request used to leave the calc unavailable for the whole session, so failures are
* retried with a backoff. Safe to call again (e.g. when a view finds it unavailable):
* it is a no-op while already loaded or loading.
*/
load(): void {
if (this.isAvailable() || this.loading) {
return;
}
this.loading = true;
this.attemptLoad(1);
}

isAvailable(): boolean {
Expand All @@ -30,6 +50,21 @@ export class BattleCalc implements Component {
}
return window['BattleCalc'];
}

private attemptLoad(attempt: number): void {
this.window.$.getScript(this.config.website + '/js/battle-calculator-core.js')
.done(() => {
this.loading = false;
})
.fail(() => {
this.logger.error(`failed to load battle calc core (attempt ${attempt}/${MAX_LOAD_ATTEMPTS})`);
if (attempt < MAX_LOAD_ATTEMPTS) {
this.window.setTimeout(() => this.attemptLoad(attempt + 1), attempt * 2000);
} else {
this.loading = false;
}
});
}
}

function isAvailable(window: BattleCalcWindow): window is MarkRequired<BattleCalcWindow, 'BattleCalc'> {
Expand Down
4 changes: 3 additions & 1 deletion src/components/battle-calc/character-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export class CharacterView implements TW2WindowView<WestCalcWindowTab> {

getMainDiv(): JQuery {
if (!this.battleCalc.isAvailable()) {
return $('<div>Battle calc core script is not loaded, try refreshing the game.</div>');
// kick off a (re)load in the background so reopening this window recovers it
this.battleCalc.load();
return $('<div>Battle calc core script is not loaded yet, reopen this window in a few seconds.</div>');
}

const html = $('<div id="BattleCalcCharacterView"></div>');
Expand Down
74 changes: 74 additions & 0 deletions src/components/chests/chests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Chests } from './chests';
import { Config } from '../config/config';
import { ErrorTracker } from '../error-tracker/error-tracker';
import { Logger } from '../logger/logger';
import { Mock } from 'ts-mocks';
import { TheWestWindow } from '../../@types/the-west';

/**
* Builds a stand-in for the game's `ItemUse.doIt` whose `toString()` returns a
* controlled source, so we can exercise the string-rewriting patch with both
* quote styles without depending on how the test bundler emits a real function.
*/
function doItWithSource(source: string): () => void {
const fn = function () {} as unknown as { toString: () => string };
fn.toString = () => source;
return fn as unknown as () => void;
}

describe('Chests', () => {
let chests: Chests;
let loggerMock: Mock<Logger>;
let errorTrackerMock: Mock<ErrorTracker>;
let itemUse: { doIt: () => void; doItOrigin?: () => void };

function setup(source: string): void {
itemUse = { doIt: doItWithSource(source) };
// eval() inside init() resolves the bare `ItemUse` identifier from the
// global scope, so the injected window and the global must share the object.
(window as unknown as { ItemUse: typeof itemUse }).ItemUse = itemUse;

loggerMock = new Mock<Logger>({ log: Mock.ANY_FUNC, error: Mock.ANY_FUNC });
errorTrackerMock = new Mock<ErrorTracker>({ track: Mock.ANY_FUNC });

const windowStub = { ItemUse: itemUse } as unknown as TheWestWindow;
chests = new Chests(windowStub, loggerMock.Object, new Mock<Config>().Object, errorTrackerMock.Object);
}

afterEach(() => {
delete (window as unknown as { ItemUse?: unknown }).ItemUse;
});

it('patches the chest handler when the item_used anchor uses single quotes', () => {
setup("function (itemId, itemCount) { EventHandler.signal('item_used', [itemId]); }");

chests.init();

const patched = itemUse.doIt.toString();
expect(patched).toContain('TW_Calc.trackChest(itemId,res);');
expect(patched.indexOf('TW_Calc.trackChest')).toBeLessThan(patched.indexOf('EventHandler.signal'));
expect(errorTrackerMock.Object.track).not.toHaveBeenCalled();
});

it('patches the chest handler when the item_used anchor uses double quotes', () => {
setup('function (itemId, itemCount) { EventHandler.signal("item_used", [itemId]); }');

chests.init();

const patched = itemUse.doIt.toString();
expect(patched).toContain('TW_Calc.trackChest(itemId,res);');
expect(errorTrackerMock.Object.track).not.toHaveBeenCalled();
});

it('tracks an error and leaves the handler untouched when the anchor is missing', () => {
setup("function (itemId, itemCount) { EventHandler.signal('something_else', [itemId]); }");
const original = itemUse.doIt;

chests.init();

// no broken eval, no rewrite — the original handler is preserved
expect(itemUse.doIt).toBe(original);
expect(itemUse.doIt.toString()).not.toContain('TW_Calc.trackChest');
expect(errorTrackerMock.Object.track).toHaveBeenCalled();
});
});
8 changes: 7 additions & 1 deletion src/components/chests/chests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ export class Chests implements Component {
let newStr: string | undefined;
try {
const str = originalFn.toString();
const pos = str.indexOf("EventHandler.signal('item_used'");
// quote-agnostic anchor: some minified builds emit the signal with double quotes
const match = str.match(/EventHandler\.signal\(['"]item_used['"]/);
if (!match || match.index === undefined) {
// fail loudly & harmlessly instead of building broken code from a -1 position
throw new Error('item_used anchor not found in ItemUse.doIt');
}
const pos = match.index;
const body = str.substr(0, pos) + 'TW_Calc.trackChest(itemId,res);' + str.substr(pos);
this.logger.log('patching the chest handler...', body);
newStr = 'ItemUse.doIt = ' + body;
Expand Down
Loading