Some checks failed
tests / Unit tests (Node 22) (push) Failing after 2m13s
Expands the unit test suite from 320 to 380 tests (+60) and adds a
Gitea Actions CI workflow. Closes all six follow-up recommendations
from the test-architecture validation report.
New tests (tests/unit/):
- ollamaClient.test.ts — Ollama SDK wrapper, options passthrough
- litellmClient.test.ts — OpenAI SDK wrapper, model fallback
- personaLoader.test.ts — Zod validation + cache invalidation
- foundryReward.test.ts — Tool plugin: lookup, errors, partial grants
- xpAwarder.test.ts — Bulk XP awards + per-player skip reasons
- redisErrorPath.test.ts — Singleton error handler does not crash
- messageRouterRunLLMTurn.test.ts — 18 cases for the runtime heart:
narrative-only path, tool dispatch, filter correction, retry loop
guard, missed-skill-check heuristic, typing indicator interval,
LLM error fallback, archive on resolve.
Coverage (line %):
- harness/litellmClient.ts 0 → 100
- harness/ollamaClient.ts 0 → 100
- harness/tools/foundryReward.ts 0 → 100
- session/xpAwarder.ts 0 → 100
- persona/loader.ts 0 → 100
- db/redis.ts 0 → 100
- bot/handlers/messageRouter.ts 0 → 39.86 (runLLMTurn now covered)
Tooling:
- package.json: + test:coverage, test:watch scripts
- devDep: @vitest/coverage-v8@^3.1.0
- tests/README.md: conventions, anti-patterns, template map
- .gitignore: exclude coverage/
- .gitea/workflows/test.yml: Node 22, npm cache, tsc --noEmit gate
Documentation (from earlier /bmad-document-project run, now committed):
- docs/index.md
- docs/project-overview.md
- docs/architecture.md
- docs/deployment-guide.md
- docs/api-contracts.md
- docs/data-models.md
- docs/source-tree-analysis.md
- docs/component-inventory.md
- docs/development-guide.md
- _bmad-output/test-artifacts/automate-validation-report.md
Co-Authored-By: Claude <noreply@anthropic.com>
206 lines
6.8 KiB
TypeScript
206 lines
6.8 KiB
TypeScript
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
// ── registry mocks ───────────────────────────────────────────────────────────
|
|
const { mockGet: mockCharacterGet } = vi.hoisted(() => ({
|
|
mockGet: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/session/characterRegistry.js', () => ({
|
|
characterRegistry: { get: mockCharacterGet },
|
|
}));
|
|
|
|
const { mockModifyExperience, mockGiveItem } = vi.hoisted(() => ({
|
|
mockModifyExperience: vi.fn(),
|
|
mockGiveItem: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/vtt/foundryClient.js', () => ({
|
|
modifyExperience: mockModifyExperience,
|
|
giveItem: mockGiveItem,
|
|
}));
|
|
|
|
import { dispatchTool } from '../../src/harness/toolDispatcher.js';
|
|
import { mockSession } from '../fixtures/spec.js';
|
|
|
|
function makeThread() {
|
|
return { send: vi.fn().mockResolvedValue({ id: 'msg-1' }) };
|
|
}
|
|
|
|
const playerSession = {
|
|
...mockSession,
|
|
players: {
|
|
'user-1': { discordId: 'user-1', dndName: 'Aelindra' },
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockModifyExperience.mockResolvedValue(undefined);
|
|
mockGiveItem.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe('dispatchTool — foundry_reward', () => {
|
|
it('awards both XP and item to a registered Foundry-linked player', async () => {
|
|
mockCharacterGet.mockResolvedValue({
|
|
discordId: 'user-1',
|
|
dndName: 'Aelindra',
|
|
source: 'foundry',
|
|
foundryActorUuid: 'Actor.abc',
|
|
});
|
|
|
|
const result = await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: {
|
|
player_discord_name: 'Aelindra',
|
|
xp_amount: 50,
|
|
item_name: 'Potion of Healing',
|
|
reason: 'Caught the thief.',
|
|
},
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(result.systemMessage).toContain('[FOUNDRY REWARD]');
|
|
expect(result.systemMessage).toContain('Aelindra');
|
|
expect(result.systemMessage).toContain('Potion of Healing');
|
|
expect(result.systemMessage).toContain('50 XP');
|
|
expect(result.systemMessage).toContain('Caught the thief.');
|
|
expect(mockModifyExperience).toHaveBeenCalledWith('Actor.abc', 50);
|
|
expect(mockGiveItem).toHaveBeenCalledWith('Actor.abc', 'Potion of Healing');
|
|
});
|
|
|
|
it('matches player name case-insensitively', async () => {
|
|
mockCharacterGet.mockResolvedValue({
|
|
discordId: 'user-1',
|
|
dndName: 'Aelindra',
|
|
source: 'foundry',
|
|
foundryActorUuid: 'Actor.abc',
|
|
});
|
|
|
|
await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'aelindra', xp_amount: 10, reason: 'good roleplay' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(mockModifyExperience).toHaveBeenCalledWith('Actor.abc', 10);
|
|
});
|
|
|
|
it('awards only XP when item_name is omitted', async () => {
|
|
mockCharacterGet.mockResolvedValue({
|
|
discordId: 'user-1', dndName: 'Aelindra', source: 'foundry', foundryActorUuid: 'Actor.abc',
|
|
});
|
|
|
|
await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Aelindra', xp_amount: 25, reason: 'milestone' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(mockModifyExperience).toHaveBeenCalledWith('Actor.abc', 25);
|
|
expect(mockGiveItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('awards only an item when xp_amount is zero', async () => {
|
|
mockCharacterGet.mockResolvedValue({
|
|
discordId: 'user-1', dndName: 'Aelindra', source: 'foundry', foundryActorUuid: 'Actor.abc',
|
|
});
|
|
|
|
await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Aelindra', xp_amount: 0, item_name: 'Gold Piece', reason: 'tip' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(mockGiveItem).toHaveBeenCalledWith('Actor.abc', 'Gold Piece');
|
|
expect(mockModifyExperience).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips XP when xp_amount is missing', async () => {
|
|
mockCharacterGet.mockResolvedValue({
|
|
discordId: 'user-1', dndName: 'Aelindra', source: 'foundry', foundryActorUuid: 'Actor.abc',
|
|
});
|
|
|
|
await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Aelindra', item_name: 'Ring', reason: 'find' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(mockGiveItem).toHaveBeenCalledWith('Actor.abc', 'Ring');
|
|
expect(mockModifyExperience).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a "no player" system message and does not call Foundry when the player is not in the session', async () => {
|
|
const result = await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Nobody', xp_amount: 5, reason: 'typo' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(result.systemMessage).toContain('No player found matching "Nobody"');
|
|
expect(mockCharacterGet).not.toHaveBeenCalled();
|
|
expect(mockModifyExperience).not.toHaveBeenCalled();
|
|
expect(mockGiveItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a "no character record" message when the player has no Foundry UUID', async () => {
|
|
mockCharacterGet.mockResolvedValue({
|
|
discordId: 'user-1', dndName: 'Aelindra', source: 'custom', /* no foundryActorUuid */
|
|
});
|
|
|
|
const result = await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Aelindra', xp_amount: 5, reason: 'try' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(result.systemMessage).toContain('No character record found for this player');
|
|
expect(mockModifyExperience).not.toHaveBeenCalled();
|
|
expect(mockGiveItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a "no character record" message when the player has no profile at all', async () => {
|
|
mockCharacterGet.mockResolvedValue(null);
|
|
|
|
const result = await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Aelindra', xp_amount: 5, reason: 'try' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(result.systemMessage).toContain('No character record found for this player');
|
|
expect(mockModifyExperience).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('catches errors from characterRegistry and returns the friendly error', async () => {
|
|
mockCharacterGet.mockRejectedValue(new Error('redis down'));
|
|
|
|
const result = await dispatchTool(
|
|
{
|
|
tool: 'foundry_reward',
|
|
args: { player_discord_name: 'Aelindra', xp_amount: 5, reason: 'try' },
|
|
},
|
|
{ session: playerSession, thread: makeThread() as any },
|
|
);
|
|
|
|
expect(result.systemMessage).toContain('Character records are inaccessible');
|
|
expect(mockModifyExperience).not.toHaveBeenCalled();
|
|
});
|
|
});
|