Skip to content

Commit

Permalink
test/oopsy: add some basic oopsy unit tests (#5875)
Browse files Browse the repository at this point in the history
This verifies that ids are unique and share a common prefix in a file
which, as you might imagine, was not previously true.
  • Loading branch information
quisquous authored Oct 27, 2023
1 parent fe7ccf2 commit 15d64b5
Show file tree
Hide file tree
Showing 42 changed files with 411 additions and 235 deletions.
2 changes: 1 addition & 1 deletion .mocharc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
colors: true,
reporter: 'progress',
exclude: [
// Run via test_raidboss_data.js.
// Run via test_data_files.js.
'test/helper/*',
],
loader: [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"*.md": "markdownlint",
"*.py": "python -m pylint --errors-only",
"ui/(raidboss|oopsyraidsy)/data/**": [
"ts-node test/test_raidboss_data.ts"
"ts-node test/test_data_files.ts"
]
},
"dependencies": {
Expand Down
10 changes: 6 additions & 4 deletions test/helper/test_data_runner.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { TestMochaGlobal } from '../test_raidboss_data';
import { TestMochaGlobal } from '../test_data_files';

import testOopsyFiles from './test_oopsy';
import testTimelineFiles from './test_timeline';
import testTriggerFiles from './test_trigger';

// This file is added to mocha by test_raidboss_data.js when Mocha is being
// This file is added to mocha by test_data_files.ts when Mocha is being
// run programmatically. This makes it possible for lint-staged to run
// tests on individual files via `node test_raidboss_data.js filename`.
// tests on individual files via `node test_data_files.ts filename`.
//
// In the case of normal Mocha execution that finds all the files in test/
// and runs them, this file will not be run. Instead, test_raidboss_data.js
// and runs them, this file will not be run. Instead, test_data_files.ts
// will call these test functions below itself.

const annotatedGlobal: TestMochaGlobal = global;

testTriggerFiles(annotatedGlobal.triggerFiles ?? []);
testTimelineFiles(annotatedGlobal.timelineFiles ?? []);
testOopsyFiles(annotatedGlobal.oopsyFiles ?? []);
167 changes: 167 additions & 0 deletions test/helper/test_oopsy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// This test loads an individual oopsy file and validates things about it.

import path from 'path';

import { assert } from 'chai';

// TODO: verify that no mistake map field maps to the same id twice
// TODO: verify usage of matches vs capture: true in oopsy triggers

import { LooseOopsyTrigger, LooseOopsyTriggerSet, OopsyMistakeMapFields } from '../../types/oopsy';

export const oopsyMistakeMapKeys: readonly (keyof OopsyMistakeMapFields)[] = [
'damageWarn',
'damageFail',
'gainsEffectWarn',
'gainsEffectFail',
'shareWarn',
'shareFail',
'soloWarn',
'soloFail',
] as const;

const testOopsyFile = (file: string, info: OopsyTriggerSetInfo) => {
let triggerSet: LooseOopsyTriggerSet;

before(async () => {
// const contents = fs.readFileSync(file).toString();

// Normalize path
const importPath = `../../${path.relative(process.cwd(), file).replace('.ts', '.js')}`;

// Dynamic imports don't have a type, so add type assertion.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
triggerSet = (await import(importPath)).default as LooseOopsyTriggerSet;
});

// Dummy test so that failures in before show up with better text.
it('should load properly', () => {/* noop */});

it('has valid id and prefix', () => {
let prefix: undefined | string;
let brokenPrefixes = false;

const verifyId = (id: string): void => {
if (prefix === undefined) {
prefix = id;
return;
}

const prevSeenFile = info.triggerId[id];
if (prevSeenFile !== undefined)
assert.fail(`duplicate id: '${id}' seen in ${prevSeenFile}`);
info.triggerId[id] = file;

// Find common prefix.
let idx = 0;
const len = Math.min(prefix.length, id.length);
for (idx = 0; idx < len; ++idx) {
if (prefix[idx] !== id[idx])
break;
}
if (idx === 0) {
assert.fail(`${file}: No common id prefix in '${prefix}' and '${id}'`);
brokenPrefixes = true;
return;
}
prefix = prefix.slice(0, idx);
};

for (const field of oopsyMistakeMapKeys) {
for (const id of Object.keys(triggerSet[field] ?? {})) {
verifyId(id);
}
}

for (const trigger of triggerSet.triggers ?? []) {
if (trigger.id === undefined) {
assert.fail(`Missing id field in trigger ${trigger.netRegex?.source ?? '???'}`);
continue;
}

verifyId(trigger.id);
}

// If there's at least two ids, then the prefix must be a full word.
// e.g. you can have two triggers like "Prefix Thing 1" and "Prefix Thing 2"
// you cannot have two triggers like "O4N Thing 1" and "O4S Thing 2",
// as the prefix "O4" is not a full word (and have a space after it,
// as "Prefix " does. This is a bit rigid, but prevents many typos.
if (!brokenPrefixes && prefix !== undefined && prefix.length > 0) {
// if prefix includes more than one word, just remove latter letters.
if (prefix.includes(' '))
prefix = prefix.slice(0, prefix.lastIndexOf(' ') + 1);
if (!prefix.endsWith(' '))
assert.fail(`id prefix '${prefix}' is not a full word, must end in a space`);
}
});

it('has sorted trigger fields', () => {
// This is the order in which they are run.
const triggerOrder: (keyof LooseOopsyTrigger)[] = [
'id',
'comment',
'condition',
'delaySeconds',
'suppressSeconds',
'deathReason',
'mistake',
'run',
];

for (const trigger of triggerSet.triggers ?? []) {
const id = trigger.id;
// This will be an error in another test.
if (id === undefined)
continue;

let lastIdx = -1;

const keys = Object.keys(trigger);

for (const field of triggerOrder) {
if (!(field in trigger))
continue;

const thisIdx = keys.indexOf(field);
if (thisIdx === -1)
continue;
if (thisIdx <= lastIdx) {
assert.fail(
`in ${id}, field '${keys[lastIdx] ?? '???'}' must precede '${keys[thisIdx] ?? '???'}'`,
);
}

lastIdx = thisIdx;
}
}
});

it('has valid zone id', () => {
if (!('zoneId' in triggerSet))
assert.fail(`missing zone id`);
else if (typeof triggerSet.zoneId === 'undefined')
assert.fail(`unknown zone id`);

if ('zoneRegex' in triggerSet)
assert.fail(`use zoneId instead of zoneRegex`);
});
};

type OopsyTriggerSetInfo = {
// id -> filename map
triggerId: { [id: string]: string };
};

const testOopsyFiles = (oopsyFiles: string[]): void => {
const info: OopsyTriggerSetInfo = {
triggerId: {},
};
describe('oopsy test', () => {
for (const file of oopsyFiles) {
describe(`${file}`, () => testOopsyFile(file, info));
}
});
};

export default testOopsyFiles;
9 changes: 9 additions & 0 deletions test/test_raidboss_data.ts → test/test_data_files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import Mocha from 'mocha';

import { walkDirSync } from '../util/file_utils';

import testOopsyFiles from './helper/test_oopsy';
import testTimelineFiles from './helper/test_timeline';
import testTriggerFiles from './helper/test_trigger';

export type TestMochaGlobal = typeof global & {
triggerFiles?: string[];
manifestFiles?: string[];
timelineFiles?: string[];
oopsyFiles?: string[];
};

// This file runs in one of two ways:
Expand All @@ -31,6 +33,7 @@ const mocha = new Mocha();

const timelineFiles: string[] = [];
const triggerFiles: string[] = [];
const oopsyFiles: string[] = [];

const processInputs = (inputPath: string[]) => {
inputPath.forEach((path: string) => {
Expand All @@ -46,6 +49,10 @@ const processInputs = (inputPath: string[]) => {
triggerFiles.push(filepath);
return;
}
if (/\/oopsyraidsy\/data\/.*\.[jt]s/.test(filepath)) {
oopsyFiles.push(filepath);
return;
}
});
});
};
Expand All @@ -64,6 +71,7 @@ processInputs(inputs);
if (insideMocha) {
testTriggerFiles(triggerFiles);
testTimelineFiles(timelineFiles);
testOopsyFiles(oopsyFiles);
} else {
const annotatedGlobal: TestMochaGlobal = global;

Expand All @@ -72,6 +80,7 @@ if (insideMocha) {
// passed via globals. We can't add files after Mocha has started, unfortunately.
annotatedGlobal.timelineFiles = timelineFiles;
annotatedGlobal.triggerFiles = triggerFiles;
annotatedGlobal.oopsyFiles = oopsyFiles;
mocha.addFile(path.posix.join(path.relative(process.cwd(), './test/helper/test_data_runner.ts')));

mocha.loadFilesAsync()
Expand Down
11 changes: 7 additions & 4 deletions types/oopsy.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,7 @@ type RequiredFieldsAsUnion<Type> = {
[key in keyof Type]-?: Record<string, never> extends Pick<Type, key> ? never : key;
}[keyof Type];

type SimpleOopsyTriggerSet<Data extends OopsyData> = {
zoneId: ZoneIdType | ZoneIdType[];
zoneLabel?: LocaleText;
export type OopsyMistakeMapFields = {
damageWarn?: MistakeMap;
damageFail?: MistakeMap;
gainsEffectWarn?: MistakeMap;
Expand All @@ -126,9 +124,14 @@ type SimpleOopsyTriggerSet<Data extends OopsyData> = {
shareFail?: MistakeMap;
soloWarn?: MistakeMap;
soloFail?: MistakeMap;
triggers?: OopsyTrigger<Data>[];
};

type SimpleOopsyTriggerSet<Data extends OopsyData> = {
zoneId: ZoneIdType | ZoneIdType[];
zoneLabel?: LocaleText;
triggers?: OopsyTrigger<Data>[];
} & OopsyMistakeMapFields;

// If Data contains required properties that are not on OopsyData, require initData
export type OopsyTriggerSet<Data extends OopsyData = OopsyData> =
& SimpleOopsyTriggerSet<Data>
Expand Down
2 changes: 1 addition & 1 deletion ui/oopsyraidsy/data/02-arr/trial/titan-ex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const triggerSet: OopsyTriggerSet<Data> = {
},
triggers: [
{
id: 'TitanEx Landslide',
id: 'TitanEx Landslide Pushed Off',
type: 'Ability',
netRegex: NetRegexes.abilityFull({ id: '5BB', ...playerDamageFields }),
deathReason: (_data, matches) => {
Expand Down
20 changes: 10 additions & 10 deletions ui/oopsyraidsy/data/03-hw/raid/a6n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@ import { OopsyTriggerSet } from '../../../../../types/oopsy';
const triggerSet: OopsyTriggerSet<OopsyData> = {
zoneId: ZoneId.AlexanderTheCuffOfTheSon,
damageWarn: {
'Minefield': '170D', // Circle AoE, mines.
'Mine': '170E', // Mine explosion.
'Supercharge': '1713', // Mirage charge.
'Height Error': '171D', // Incorrect panel for Height.
'Earth Missile': '1726', // Circle AoE, fire puddles.
'A6N Minefield': '170D', // Circle AoE, mines.
'A6N Mine': '170E', // Mine explosion.
'A6N Supercharge': '1713', // Mirage charge.
'A6N Height Error': '171D', // Incorrect panel for Height.
'A6N Earth Missile': '1726', // Circle AoE, fire puddles.
},
damageFail: {
'Ultra Flash': '1722', // Room-wide death AoE, if not LoS'd.
'A6N Ultra Flash': '1722', // Room-wide death AoE, if not LoS'd.
},
shareWarn: {
'Ice Missile': '1727', // Ice headmarker AoE circles.
'A6N Ice Missile': '1727', // Ice headmarker AoE circles.
},
shareFail: {
'Single Buster': '1717', // Single laser Attachment. Non-tanks are *probably* dead.
'A6N Single Buster': '1717', // Single laser Attachment. Non-tanks are *probably* dead.
},
soloWarn: {
'Double Buster': '1718', // Twin laser Attachment.
'Enumeration': '171E', // Enumeration circle.
'A6N Double Buster': '1718', // Twin laser Attachment.
'A6N Enumeration': '171E', // Enumeration circle.
},
};

Expand Down
6 changes: 3 additions & 3 deletions ui/oopsyraidsy/data/03-hw/trial/sophia-ex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ const triggerSet: OopsyTriggerSet<OopsyData> = {
'SophiaEx Thunder II': '19B0', // untelegraphed front cleave
'SophiaEx Aero III': '19AE', // "get out"
'SophiaEx Thunder III': '19AC', // "get under"
'Sophia Ex Aion Teleos Execute 1': '19B1', // Thunder II duplication
'Sophia Ex Aion Teleos Execute 2': '19AF', // Aero III duplication
'Sophia Ex Aion Teleos Execute 3': '19AD', // Thunder III duplication
'SophiaEx Aion Teleos Execute 1': '19B1', // Thunder II duplication
'SophiaEx Aion Teleos Execute 2': '19AF', // Aero III duplication
'SophiaEx Aion Teleos Execute 3': '19AD', // Thunder III duplication
'SophiaEx Gnosis': '19C2', // knockback
'SophiaEx The Third Demiurge Ring of Pain': '19BA', // circle that leaves a frost puddle
'SophiaEx The Third Demiurge Gnostic Spear': '19B9', // 270 degree untelegraphed cleave
Expand Down
2 changes: 1 addition & 1 deletion ui/oopsyraidsy/data/03-hw/trial/thordan-ex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const triggerSet: OopsyTriggerSet<OopsyData> = {
},
triggers: [
{
id: 'ThordanEx Grinnaux Faith Unmoving',
id: 'ThordanEX Grinnaux Faith Unmoving',
type: 'Ability',
netRegex: NetRegexes.ability({ id: '149B' }),
deathReason: (_data, matches) => {
Expand Down
4 changes: 2 additions & 2 deletions ui/oopsyraidsy/data/04-sb/alliance/orbonne_monastery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ const triggerSet: OopsyTriggerSet<Data> = {
'Orbonne Ultima Demi-Belias Time Eruption 1': '38D0', // fast/slow clocks
'Orbonne Ultima Demi-Belias Time Eruption 2': '38D1', // fast/slow clocks
'Orbonne Ultima Demi-Hashmal Towerfall': '38D7', // control tower falling over
'Orbonna Ultima Demi-Hashmal Extreme Edge 1': '38DA', // left/right cleave dash
'Orbonna Ultima Demi-Hashmal Extreme Edge 2': '38DB', // left/right cleave dash
'Orbonne Ultima Demi-Hashmal Extreme Edge 1': '38DA', // left/right cleave dash
'Orbonne Ultima Demi-Hashmal Extreme Edge 2': '38DB', // left/right cleave dash
'Orbonne Ultima Demi-Belias Eruption': '37C8', // headmarker with chasing telegraphed circle aoes
'Orbonne Ultima Dominion Ray Of Light': '38B7', // lingering line aoe with Eastward/Westward March
'Orbonne Ultima Embrace Initial': '38B9', // hidden blue traps being placed
Expand Down
11 changes: 1 addition & 10 deletions ui/oopsyraidsy/data/04-sb/dungeon/bardams_mettle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,6 @@ const triggerSet: OopsyTriggerSet<Data> = {
'Bardam Double Smash': '26A', // Circle AoE, Mettling Dhara trash
'Bardam Transonic Blast': '1262', // Circle AoE, Steppe Eagle trash
'Bardam Wild Horn': '2208', // Frontal cleave, Khun Gurvel trash
'Bardam Heavy Strike 1': '2578', // 1 of 3 270-degree ring AoEs, Bardam, second boss
'Bardam Heavy Strike 2': '2579', // 2 of 3 270-degree ring AoEs, Bardam, second boss
'Bardam Heavy Strike 3': '257A', // 3 of 3 270-degree ring AoEs, Bardam, second boss
'Bardam Tremblor 1': '257B', // 1 of 2 concentric ring AoEs, Bardam, second boss
'Bardam Tremblor 2': '257C', // 2 of 2 concentric ring AoEs, Bardam, second boss
'Bardam Throwing Spear': '257F', // Checkerboard AoE, Throwing Spear, second boss
'Bardam Bardam\'s Ring': '2581', // Donut AoE headmarkers, Bardam, second boss
'Bardam Comet': '257D', // Targeted circle AoEs, Bardam, second boss
'Bardam Comet Impact': '2580', // Circle AoEs, Star Shard, second boss
'Bardam Iron Sphere Attack': '16B6', // Contact damage, Iron Sphere trash, before third boss
'Bardam Tornado': '247E', // Circle AoE, Khun Shavara trash
'Bardam Pinion': '1F11', // Line AoE, Yol Feather, third boss
Expand Down Expand Up @@ -88,7 +79,7 @@ const triggerSet: OopsyTriggerSet<Data> = {
// Gaze attack, Warrior of Bardam, second boss
abilityWarn({ id: 'Bardam Empty Gaze', abilityId: '1F04' }),
// Donut AoE headmarkers, Bardam, second boss
abilityWarn({ id: 'Bardam\'s Ring', abilityId: '2581' }),
abilityWarn({ id: 'Bardam Bardam\'s Ring', abilityId: '2581' }),
// Targeted circle AoEs, Bardam, second boss
abilityWarn({ id: 'Bardam Comet', abilityId: '257D' }),
// Circle AoEs, Star Shard, second boss
Expand Down
Loading

0 comments on commit 15d64b5

Please sign in to comment.