chore: tests for effects and middleware
This commit is contained in:
parent
7601a97ec9
commit
8155747cac
|
|
@ -0,0 +1,417 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
addEffect,
|
||||
addEntityEffect,
|
||||
addItemEffect,
|
||||
onEntityEffectUpkeep,
|
||||
onEntityPostureDamage,
|
||||
onPlayerItemEffectUpkeep,
|
||||
onItemPlay,
|
||||
onItemDiscard,
|
||||
getAliveEnemies,
|
||||
getCombatEntity,
|
||||
} from '@/samples/slay-the-spire-like/system/combat/effects';
|
||||
import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
|
||||
import type { EffectData } from '@/samples/slay-the-spire-like/system/types';
|
||||
|
||||
function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData {
|
||||
return { id, name: id, description: '', lifecycle };
|
||||
}
|
||||
|
||||
function createCombatEntity(hp = 10, maxHp = 10): CombatEntity {
|
||||
return {
|
||||
effects: {},
|
||||
hp,
|
||||
maxHp,
|
||||
isAlive: hp > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
|
||||
return {
|
||||
...createCombatEntity(hp, maxHp),
|
||||
energy: 3,
|
||||
maxEnergy: 3,
|
||||
deck: { cards: {}, regions: { drawPile: { id: 'drawPile', axes: [], childIds: [], partMap: {} }, hand: { id: 'hand', axes: [], childIds: [], partMap: {} }, discardPile: { id: 'discardPile', axes: [], childIds: [], partMap: {} }, exhaustPile: { id: 'exhaustPile', axes: [], childIds: [], partMap: {} } } },
|
||||
itemEffects: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
|
||||
return {
|
||||
...createCombatEntity(hp, maxHp),
|
||||
id,
|
||||
enemy: { id, name: id, description: '' },
|
||||
intents: {},
|
||||
currentIntentId: '',
|
||||
};
|
||||
}
|
||||
|
||||
function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState {
|
||||
return {
|
||||
player: createPlayerEntity(playerHp),
|
||||
enemies,
|
||||
inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() },
|
||||
phase: 'playerTurn',
|
||||
turnNumber: 1,
|
||||
result: null,
|
||||
loot: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('combat/effects', () => {
|
||||
describe('addEffect', () => {
|
||||
it('should add a new effect to an empty table', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'temporary');
|
||||
|
||||
addEffect(table, effect, 3);
|
||||
|
||||
expect(table['strength']).toBeDefined();
|
||||
expect(table['strength'].data).toBe(effect);
|
||||
expect(table['strength'].stacks).toBe(3);
|
||||
});
|
||||
|
||||
it('should stack with existing effect of same id', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'lingering');
|
||||
|
||||
addEffect(table, effect, 2);
|
||||
addEffect(table, effect, 3);
|
||||
|
||||
expect(table['strength'].stacks).toBe(5);
|
||||
});
|
||||
|
||||
it('should remove effect when stacks reach 0', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'temporary');
|
||||
|
||||
addEffect(table, effect, 3);
|
||||
addEffect(table, effect, -3);
|
||||
|
||||
expect(table['strength']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not add effect when stacks is 0', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'temporary');
|
||||
|
||||
addEffect(table, effect, 0);
|
||||
|
||||
expect(table['strength']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle negative stacks', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('weak', 'temporary');
|
||||
|
||||
addEffect(table, effect, -2);
|
||||
|
||||
expect(table['weak'].stacks).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEntityEffect', () => {
|
||||
it('should add effect to entity.effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const effect = createEffect('vulnerable', 'lingering');
|
||||
|
||||
addEntityEffect(entity, effect, 2);
|
||||
|
||||
expect(entity.effects['vulnerable'].stacks).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addItemEffect', () => {
|
||||
it('should add effect to player.itemEffects[itemKey]', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 3);
|
||||
|
||||
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(3);
|
||||
});
|
||||
|
||||
it('should initialize itemEffects entry if not present', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||
|
||||
addItemEffect(player, 'new-item', effect, 1);
|
||||
|
||||
expect(player.itemEffects['new-item']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should stack with existing item effect', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 2);
|
||||
addItemEffect(player, 'sword-1', effect, 3);
|
||||
|
||||
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onEntityEffectUpkeep', () => {
|
||||
it('should remove temporary effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const tempEffect = createEffect('temp-shield', 'temporary');
|
||||
|
||||
addEntityEffect(entity, tempEffect, 5);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['temp-shield']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should decrement lingering effects by 1', () => {
|
||||
const entity = createCombatEntity();
|
||||
const lingeringEffect = createEffect('poison', 'lingering');
|
||||
|
||||
addEntityEffect(entity, lingeringEffect, 3);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['poison'].stacks).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove lingering effects when stacks reach 0', () => {
|
||||
const entity = createCombatEntity();
|
||||
const lingeringEffect = createEffect('poison', 'lingering');
|
||||
|
||||
addEntityEffect(entity, lingeringEffect, 1);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['poison']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect permanent effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const permEffect = createEffect('max-hp-up', 'permanent');
|
||||
|
||||
addEntityEffect(entity, permEffect, 5);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['max-hp-up'].stacks).toBe(5);
|
||||
});
|
||||
|
||||
it('should not affect instant effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const instantEffect = createEffect('instant-damage', 'instant');
|
||||
|
||||
addEntityEffect(entity, instantEffect, 10);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['instant-damage'].stacks).toBe(10);
|
||||
});
|
||||
|
||||
it('should increment lingering effects with negative stacks', () => {
|
||||
const entity = createCombatEntity();
|
||||
const lingeringEffect = createEffect('regen', 'lingering');
|
||||
|
||||
addEntityEffect(entity, lingeringEffect, -3);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['regen'].stacks).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onEntityPostureDamage', () => {
|
||||
it('should reduce posture effects by damage amount', () => {
|
||||
const entity = createCombatEntity();
|
||||
const postureEffect = createEffect('block', 'posture');
|
||||
|
||||
addEntityEffect(entity, postureEffect, 10);
|
||||
onEntityPostureDamage(entity, 4);
|
||||
|
||||
expect(entity.effects['block'].stacks).toBe(6);
|
||||
});
|
||||
|
||||
it('should not reduce posture effects below 0', () => {
|
||||
const entity = createCombatEntity();
|
||||
const postureEffect = createEffect('block', 'posture');
|
||||
|
||||
addEntityEffect(entity, postureEffect, 3);
|
||||
onEntityPostureDamage(entity, 10);
|
||||
|
||||
expect(entity.effects['block']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect non-posture effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const postureEffect = createEffect('block', 'posture');
|
||||
const permEffect = createEffect('strength', 'permanent');
|
||||
|
||||
addEntityEffect(entity, postureEffect, 5);
|
||||
addEntityEffect(entity, permEffect, 3);
|
||||
onEntityPostureDamage(entity, 2);
|
||||
|
||||
expect(entity.effects['block'].stacks).toBe(3);
|
||||
expect(entity.effects['strength'].stacks).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle zero damage', () => {
|
||||
const entity = createCombatEntity();
|
||||
const postureEffect = createEffect('block', 'posture');
|
||||
|
||||
addEntityEffect(entity, postureEffect, 5);
|
||||
onEntityPostureDamage(entity, 0);
|
||||
|
||||
expect(entity.effects['block'].stacks).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onPlayerItemEffectUpkeep', () => {
|
||||
it('should remove itemTemporary effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 5);
|
||||
onPlayerItemEffectUpkeep(player);
|
||||
|
||||
expect(player.itemEffects['sword-1']['adjacent-buff']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect itemPermanent effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('adjacent-buff', 'itemPermanent');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 5);
|
||||
onPlayerItemEffectUpkeep(player);
|
||||
|
||||
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
|
||||
});
|
||||
|
||||
it('should not affect itemUntilPlay effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('charged', 'itemUntilPlay');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 3);
|
||||
onPlayerItemEffectUpkeep(player);
|
||||
|
||||
expect(player.itemEffects['sword-1']['charged'].stacks).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onItemPlay', () => {
|
||||
it('should remove itemUntilPlay effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('charged', 'itemUntilPlay');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 3);
|
||||
onItemPlay(player, 'sword-1');
|
||||
|
||||
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect other lifecycle effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const permEffect = createEffect('passive', 'itemPermanent');
|
||||
const playEffect = createEffect('charged', 'itemUntilPlay');
|
||||
|
||||
addItemEffect(player, 'sword-1', permEffect, 5);
|
||||
addItemEffect(player, 'sword-1', playEffect, 3);
|
||||
onItemPlay(player, 'sword-1');
|
||||
|
||||
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
|
||||
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should do nothing for item with no effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
|
||||
expect(() => onItemPlay(player, 'nonexistent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onItemDiscard', () => {
|
||||
it('should remove itemUntilDiscard effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('discard-buff', 'itemUntilDiscard');
|
||||
|
||||
addItemEffect(player, 'sword-1', effect, 3);
|
||||
onItemDiscard(player, 'sword-1');
|
||||
|
||||
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect other lifecycle effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
const permEffect = createEffect('passive', 'itemPermanent');
|
||||
const discardEffect = createEffect('discard-buff', 'itemUntilDiscard');
|
||||
|
||||
addItemEffect(player, 'sword-1', permEffect, 5);
|
||||
addItemEffect(player, 'sword-1', discardEffect, 3);
|
||||
onItemDiscard(player, 'sword-1');
|
||||
|
||||
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
|
||||
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should do nothing for item with no effects', () => {
|
||||
const player = createPlayerEntity();
|
||||
|
||||
expect(() => onItemDiscard(player, 'nonexistent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAliveEnemies', () => {
|
||||
it('should yield only alive enemies', () => {
|
||||
const state = createCombatState(30, [
|
||||
createEnemyEntity('slime-1', 10, 10),
|
||||
createEnemyEntity('slime-2', 0, 10),
|
||||
createEnemyEntity('slime-3', 5, 10),
|
||||
]);
|
||||
|
||||
const alive = [...getAliveEnemies(state)];
|
||||
|
||||
expect(alive.length).toBe(2);
|
||||
expect(alive[0].id).toBe('slime-1');
|
||||
expect(alive[1].id).toBe('slime-3');
|
||||
});
|
||||
|
||||
it('should return empty for no enemies', () => {
|
||||
const state = createCombatState(30, []);
|
||||
|
||||
const alive = [...getAliveEnemies(state)];
|
||||
|
||||
expect(alive.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty when all enemies are dead', () => {
|
||||
const state = createCombatState(30, [
|
||||
createEnemyEntity('slime-1', 0, 10),
|
||||
createEnemyEntity('slime-2', 0, 10),
|
||||
]);
|
||||
|
||||
const alive = [...getAliveEnemies(state)];
|
||||
|
||||
expect(alive.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCombatEntity', () => {
|
||||
it('should return player for "player" key', () => {
|
||||
const state = createCombatState(30);
|
||||
|
||||
const entity = getCombatEntity(state, 'player');
|
||||
|
||||
expect(entity).toBe(state.player);
|
||||
});
|
||||
|
||||
it('should return enemy by id', () => {
|
||||
const enemy = createEnemyEntity('boss-1', 50, 50);
|
||||
const state = createCombatState(30, [enemy]);
|
||||
|
||||
const entity = getCombatEntity(state, 'boss-1');
|
||||
|
||||
expect(entity).toBe(enemy);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent enemy', () => {
|
||||
const state = createCombatState(30, [createEnemyEntity('slime-1')]);
|
||||
|
||||
const entity = getCombatEntity(state, 'nonexistent');
|
||||
|
||||
expect(entity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware';
|
||||
|
||||
describe('createMiddlewareChain', () => {
|
||||
describe('basic execution', () => {
|
||||
it('should return context when no middlewares and no fallback', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
const result = await chain.execute({ value: 42 });
|
||||
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it('should call fallback when no middlewares', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }, string>(
|
||||
async ctx => `value is ${ctx.value}`
|
||||
);
|
||||
const result = await chain.execute({ value: 42 });
|
||||
|
||||
expect(result).toBe('value is 42');
|
||||
});
|
||||
|
||||
it('should pass context to fallback', async () => {
|
||||
const chain = createMiddlewareChain<{ a: number; b: number }, number>(
|
||||
async ctx => ctx.a + ctx.b
|
||||
);
|
||||
const result = await chain.execute({ a: 3, b: 7 });
|
||||
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single middleware', () => {
|
||||
it('should execute a single middleware', async () => {
|
||||
const chain = createMiddlewareChain<{ count: number }>();
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.count *= 2;
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ count: 5 });
|
||||
|
||||
expect(result.count).toBe(10);
|
||||
});
|
||||
|
||||
it('should allow middleware to modify return value', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||
async ctx => ctx.value
|
||||
);
|
||||
chain.use(async (ctx, next) => {
|
||||
const result = await next();
|
||||
return result * 2;
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 21 });
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should allow middleware to short-circuit without calling next', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
chain.use(async (_ctx, _next) => {
|
||||
return { value: 999 };
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 1 });
|
||||
|
||||
expect(result.value).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple middlewares', () => {
|
||||
it('should execute middlewares in order', async () => {
|
||||
const order: number[] = [];
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
order.push(1);
|
||||
const result = await next();
|
||||
order.push(4);
|
||||
return result;
|
||||
});
|
||||
chain.use(async (_ctx, next) => {
|
||||
order.push(2);
|
||||
const result = await next();
|
||||
order.push(3);
|
||||
return result;
|
||||
});
|
||||
|
||||
await chain.execute({ value: 0 });
|
||||
|
||||
expect(order).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should accumulate modifications through the chain', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.value += 1;
|
||||
return next();
|
||||
});
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.value *= 2;
|
||||
return next();
|
||||
});
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.value += 3;
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 0 });
|
||||
|
||||
expect(result.value).toBe(5);
|
||||
});
|
||||
|
||||
it('should allow middleware to modify result on the way back', async () => {
|
||||
const chain = createMiddlewareChain<{ base: number }, number>(
|
||||
async ctx => ctx.base
|
||||
);
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
const result = await next();
|
||||
return result + 10;
|
||||
});
|
||||
chain.use(async (_ctx, next) => {
|
||||
const result = await next();
|
||||
return result * 2;
|
||||
});
|
||||
|
||||
const result = await chain.execute({ base: 5 });
|
||||
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it('should allow middleware to short-circuit in the middle', async () => {
|
||||
const executed: number[] = [];
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
executed.push(1);
|
||||
return next();
|
||||
});
|
||||
chain.use(async () => {
|
||||
executed.push(2);
|
||||
return { value: -1 };
|
||||
});
|
||||
chain.use(async (_ctx, next) => {
|
||||
executed.push(3);
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 100 });
|
||||
|
||||
expect(result.value).toBe(-1);
|
||||
expect(executed).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested next calls', () => {
|
||||
it('should advance index on each next call, skipping remaining middlewares', async () => {
|
||||
const chain = createMiddlewareChain<{ counter: number }>();
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
await next();
|
||||
await next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ counter: 0 });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow middleware to call next conditionally', async () => {
|
||||
const chain = createMiddlewareChain<{ skip: boolean; value: number }>();
|
||||
|
||||
chain.use(async (ctx, next) => {
|
||||
if (ctx.skip) {
|
||||
return { value: -1 };
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.value += 10;
|
||||
return next();
|
||||
});
|
||||
|
||||
const resultA = await chain.execute({ skip: true, value: 0 });
|
||||
const resultB = await chain.execute({ skip: false, value: 0 });
|
||||
|
||||
expect(resultA.value).toBe(-1);
|
||||
expect(resultB.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle middleware that awaits next multiple times with a fallback', async () => {
|
||||
const log: string[] = [];
|
||||
const chain = createMiddlewareChain<{ value: number }, string[]>(
|
||||
async _ctx => log
|
||||
);
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
log.push('before');
|
||||
await next();
|
||||
log.push('after-first');
|
||||
await next();
|
||||
log.push('after-second');
|
||||
return log;
|
||||
});
|
||||
chain.use(async (_ctx, next) => {
|
||||
log.push('mw2');
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 0 });
|
||||
|
||||
expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']);
|
||||
});
|
||||
|
||||
it('should return fallback result on second next call when no more middlewares remain', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||
async ctx => ctx.value * 10
|
||||
);
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
await next();
|
||||
return await next();
|
||||
});
|
||||
chain.use(async (_ctx, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 5 });
|
||||
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
|
||||
it('should return fallback result on second next call when no more middlewares remain', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||
async ctx => ctx.value * 10
|
||||
);
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
await next();
|
||||
return await next();
|
||||
});
|
||||
chain.use(async (_ctx, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 5 });
|
||||
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('async behavior', () => {
|
||||
it('should handle async middlewares', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async (ctx, next) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
ctx.value += 1;
|
||||
return next();
|
||||
});
|
||||
chain.use(async (ctx, next) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
ctx.value += 2;
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 0 });
|
||||
|
||||
expect(result.value).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle async fallback', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||
async ctx => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return ctx.value * 10;
|
||||
}
|
||||
);
|
||||
|
||||
const result = await chain.execute({ value: 5 });
|
||||
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate errors from middleware', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async () => {
|
||||
throw new Error('middleware error');
|
||||
});
|
||||
|
||||
await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error');
|
||||
});
|
||||
|
||||
it('should propagate errors from fallback', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||
async () => {
|
||||
throw new Error('fallback error');
|
||||
}
|
||||
);
|
||||
|
||||
await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error');
|
||||
});
|
||||
|
||||
it('should allow middleware to catch errors from downstream', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
try {
|
||||
return await next();
|
||||
} catch {
|
||||
return { value: -1 };
|
||||
}
|
||||
});
|
||||
chain.use(async () => {
|
||||
throw new Error('downstream error');
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 1 });
|
||||
|
||||
expect(result.value).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return type override', () => {
|
||||
it('should support different TReturn type than TContext', async () => {
|
||||
const chain = createMiddlewareChain<{ name: string }, string>(
|
||||
async ctx => `Hello, ${ctx.name}!`
|
||||
);
|
||||
|
||||
const result = await chain.execute({ name: 'World' });
|
||||
|
||||
expect(result).toBe('Hello, World!');
|
||||
});
|
||||
|
||||
it('should allow middleware to transform return type', async () => {
|
||||
const chain = createMiddlewareChain<{ items: number[] }, number>(
|
||||
async ctx => ctx.items.reduce((a, b) => a + b, 0)
|
||||
);
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
const sum = await next();
|
||||
return sum * 2;
|
||||
});
|
||||
|
||||
const result = await chain.execute({ items: [1, 2, 3] });
|
||||
|
||||
expect(result).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reusability', () => {
|
||||
it('should reset index on each execute call', async () => {
|
||||
const chain = createMiddlewareChain<{ count: number }>();
|
||||
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.count += 1;
|
||||
return next();
|
||||
});
|
||||
|
||||
const resultA = await chain.execute({ count: 0 });
|
||||
const resultB = await chain.execute({ count: 0 });
|
||||
|
||||
expect(resultA.count).toBe(1);
|
||||
expect(resultB.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should share middlewares across execute calls', async () => {
|
||||
const chain = createMiddlewareChain<{ log: string[] }>();
|
||||
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.log.push('always');
|
||||
return next();
|
||||
});
|
||||
|
||||
await chain.execute({ log: [] });
|
||||
await chain.execute({ log: [] });
|
||||
|
||||
expect(chain).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty context object', async () => {
|
||||
const chain = createMiddlewareChain<Record<string, never>>();
|
||||
const result = await chain.execute({});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle middleware that returns a completely different object', async () => {
|
||||
const chain = createMiddlewareChain<{ x: number }, { y: string }>(
|
||||
async () => ({ y: 'default' })
|
||||
);
|
||||
|
||||
chain.use(async (_ctx, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ x: 42 });
|
||||
|
||||
expect(result).toEqual({ y: 'default' });
|
||||
});
|
||||
|
||||
it('should handle middleware that mutates context without returning', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>(
|
||||
async ctx => ctx
|
||||
);
|
||||
|
||||
chain.use(async (ctx, next) => {
|
||||
ctx.value = 100;
|
||||
return next();
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 0 });
|
||||
|
||||
expect(result.value).toBe(100);
|
||||
});
|
||||
|
||||
it('should return undefined when middleware does not call next or return', async () => {
|
||||
const chain = createMiddlewareChain<{ value: number }>();
|
||||
|
||||
chain.use(async (ctx) => {
|
||||
ctx.value = 100;
|
||||
});
|
||||
|
||||
const result = await chain.execute({ value: 0 });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue