- dealDamage returns new Character instead of mutating in-place - SelfDamageForbidden uses reference equality (this === target) - Negative damage throws at the boundary - Removed duplicate Health.maxHealthForLevel (Level.ts is source of truth) - Allium spec uses max(0, ...) for health floor precision - New property: NegativeDamageForbidden (11 total properties)
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
import fc from 'fast-check';
|
|
import { describe, it } from 'vitest';
|
|
import { Character } from './Character.ts';
|
|
import { Level } from './Level.ts';
|
|
|
|
describe('DamageAndHealth', () => {
|
|
describe('DamageReducesHealth', () => {
|
|
it('property: health is reduced by the damage amount', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 1000 }),
|
|
fc.integer({ min: 0, max: 10000 }),
|
|
(health, damage) => {
|
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
|
const target = Character.createWithHealth({
|
|
name: 'target',
|
|
level: Level.create(1),
|
|
health,
|
|
});
|
|
const expected = Math.max(0, health - damage);
|
|
const result = attacker.dealDamage(target, damage);
|
|
return result.health.value === expected;
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('HealthNonNegative', () => {
|
|
it('property: health never goes below zero after damage', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 1000 }),
|
|
fc.integer({ min: 0, max: 10000 }),
|
|
(health, damage) => {
|
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
|
const target = Character.createWithHealth({
|
|
name: 'target',
|
|
level: Level.create(1),
|
|
health,
|
|
});
|
|
const result = attacker.dealDamage(target, damage);
|
|
return result.health.value >= 0;
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('DeathAtZeroHealth', () => {
|
|
it('property: character dies when health reaches zero', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 1000 }),
|
|
fc.integer({ min: 0, max: 10000 }),
|
|
(health, damage) => {
|
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
|
const target = Character.createWithHealth({
|
|
name: 'target',
|
|
level: Level.create(1),
|
|
health,
|
|
});
|
|
const result = attacker.dealDamage(target, damage);
|
|
const expected = Math.max(0, health - damage);
|
|
if (expected === 0) {
|
|
return result.status.kind === 'dead';
|
|
}
|
|
return result.status.kind === 'alive';
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SelfDamageForbidden', () => {
|
|
it('property: a character cannot deal damage to itself — returns same reference, state unchanged', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 1000 }),
|
|
fc.integer({ min: 0, max: 10000 }),
|
|
(health, damage) => {
|
|
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
|
const healthBefore = c.health.value;
|
|
const statusBefore = c.status.kind;
|
|
const result = c.dealDamage(c, damage);
|
|
// Should return the same reference
|
|
return (
|
|
result === c &&
|
|
result.health.value === healthBefore &&
|
|
result.status.kind === statusBefore
|
|
);
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('DeadCannotTakeDamage', () => {
|
|
it('property: dead characters cannot take damage — returns same reference, state unchanged', () => {
|
|
fc.assert(
|
|
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
|
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
|
const target = Character.create({ name: 'target', level: Level.create(1) });
|
|
// Kill the target first — capture the returned (dead) character
|
|
const deadTarget = attacker.dealDamage(target, 10000);
|
|
const healthBefore = deadTarget.health.value;
|
|
const statusBefore = deadTarget.status.kind;
|
|
// Then try to deal more damage to the dead character
|
|
const result = attacker.dealDamage(deadTarget, damage);
|
|
return (
|
|
result === deadTarget &&
|
|
result.health.value === healthBefore &&
|
|
result.status.kind === statusBefore
|
|
);
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('NegativeDamageForbidden', () => {
|
|
it('property: negative damage throws an error', () => {
|
|
fc.assert(
|
|
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
|
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
|
const target = Character.create({ name: 'target', level: Level.create(1) });
|
|
let threw = false;
|
|
try {
|
|
attacker.dealDamage(target, negativeDamage);
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
return threw;
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|