feat: recreate Crafting Tab with Fabricator and Enchanter sub-tabs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m18s

- Add fabricator-recipes.ts with 12 recipes across earth/metal/crystal/sand mana types
- Add FabricatorSubTab with mana-type filtering, recipe cards, materials inventory
- Add EnchanterSubTab integrating existing 3-phase flow (Design → Prepare → Apply)
- Add CraftingTab main component with clsx-based sub-tab system (matches DisciplinesTab pattern)
- Wire into tabs barrel export and page.tsx with lazy loading + DebugName wrapper
- Add 17 tests covering exports, displayNames, recipe data integrity, helpers, file sizes
- All files under 400 lines
This commit is contained in:
2026-05-20 02:32:37 +02:00
parent 9882578627
commit 1c7fc8c551
10 changed files with 840 additions and 2 deletions
@@ -0,0 +1,180 @@
import { describe, it, expect } from 'vitest';
// ─── Test: CraftingTab barrel export ──────────────────────────────────────────
describe('CraftingTab module structure', () => {
it('exports CraftingTab from its module', async () => {
const mod = await import('./CraftingTab');
expect(mod.CraftingTab).toBeDefined();
expect(typeof mod.CraftingTab).toBe('function');
});
it('CraftingTab has correct displayName', async () => {
const { CraftingTab } = await import('./CraftingTab');
expect(CraftingTab.displayName).toBe('CraftingTab');
});
});
// ─── Test: CraftingTab in tabs barrel index ────────────────────────────────────
describe('Tab barrel export', () => {
it('includes CraftingTab in the tabs index', async () => {
const mod = await import('@/components/game/tabs');
expect(mod.CraftingTab).toBeDefined();
expect(typeof mod.CraftingTab).toBe('function');
});
});
// ─── Test: FabricatorSubTab ────────────────────────────────────────────────────
describe('FabricatorSubTab module', () => {
it('exports FabricatorSubTab', async () => {
const mod = await import('./CraftingTab/FabricatorSubTab');
expect(mod.FabricatorSubTab).toBeDefined();
expect(typeof mod.FabricatorSubTab).toBe('function');
});
it('FabricatorSubTab has correct displayName', async () => {
const { FabricatorSubTab } = await import('./CraftingTab/FabricatorSubTab');
expect(FabricatorSubTab.displayName).toBe('FabricatorSubTab');
});
});
// ─── Test: EnchanterSubTab ─────────────────────────────────────────────────────
describe('EnchanterSubTab module', () => {
it('exports EnchanterSubTab', async () => {
const mod = await import('./CraftingTab/EnchanterSubTab');
expect(mod.EnchanterSubTab).toBeDefined();
expect(typeof mod.EnchanterSubTab).toBe('function');
});
it('EnchanterSubTab has correct displayName', async () => {
const { EnchanterSubTab } = await import('./CraftingTab/EnchanterSubTab');
expect(EnchanterSubTab.displayName).toBe('EnchanterSubTab');
});
});
// ─── Test: Fabricator recipes data ─────────────────────────────────────────────
describe('Fabricator recipes data', () => {
it('exports FABRICATOR_RECIPES', async () => {
const mod = await import('@/lib/game/data/fabricator-recipes');
expect(mod.FABRICATOR_RECIPES).toBeDefined();
expect(Object.keys(mod.FABRICATOR_RECIPES).length).toBeGreaterThan(0);
});
it('all recipes have required fields', async () => {
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
expect(recipe.id).toBeTruthy();
expect(recipe.name).toBeTruthy();
expect(recipe.description).toBeTruthy();
expect(recipe.manaType).toBeTruthy();
expect(recipe.equipmentTypeId).toBeTruthy();
expect(recipe.slot).toBeTruthy();
expect(recipe.materials).toBeDefined();
expect(recipe.manaCost).toBeGreaterThan(0);
expect(recipe.craftTime).toBeGreaterThan(0);
expect(recipe.rarity).toBeTruthy();
expect(recipe.gearTrait).toBeTruthy();
}
});
it('only uses valid solid/structural mana types', async () => {
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
const validManaTypes = new Set(['earth', 'metal', 'crystal', 'sand']);
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
expect(validManaTypes.has(recipe.manaType)).toBe(true);
}
});
it('no fire, water, air, light, dark, death, stellar, or void gear recipes', async () => {
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
const invalidTypes = new Set(['fire', 'water', 'air', 'light', 'dark', 'death', 'stellar', 'void']);
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
expect(invalidTypes.has(recipe.manaType)).toBe(false);
}
});
it('getRecipesByManaType filters correctly', async () => {
const { getRecipesByManaType, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
const earthRecipes = getRecipesByManaType('earth');
expect(earthRecipes.length).toBeGreaterThan(0);
for (const r of earthRecipes) {
expect(r.manaType).toBe('earth');
}
const empty = getRecipesByManaType('nonexistent');
expect(empty).toEqual([]);
});
it('getRecipeById returns correct recipe', async () => {
const { getRecipeById, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
const firstId = Object.values(FABRICATOR_RECIPES)[0].id;
const recipe = getRecipeById(firstId);
expect(recipe).toBeDefined();
expect(recipe!.id).toBe(firstId);
const missing = getRecipeById('nonexistent');
expect(missing).toBeUndefined();
});
it('canCraftRecipe returns correct availability', async () => {
const { canCraftRecipe, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
const recipe = Object.values(FABRICATOR_RECIPES)[0];
// Empty materials => cannot craft
const result1 = canCraftRecipe(recipe, {}, 0);
expect(result1.canCraft).toBe(false);
expect(Object.keys(result1.missingMaterials).length).toBeGreaterThan(0);
expect(result1.missingMana).toBeGreaterThan(0);
// Sufficient materials and mana => can craft
const sufficientMats: Record<string, number> = {};
for (const [matId, amount] of Object.entries(recipe.materials)) {
sufficientMats[matId] = amount;
}
const result2 = canCraftRecipe(recipe, sufficientMats, recipe.manaCost);
expect(result2.canCraft).toBe(true);
expect(Object.keys(result2.missingMaterials)).toHaveLength(0);
expect(result2.missingMana).toBe(0);
});
});
// ─── Test: File size limits ────────────────────────────────────────────────────
describe('File size limits (400 lines max)', () => {
it('CraftingTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const content = fs.readFileSync(
path.join(__dirname, 'CraftingTab.tsx'),
'utf-8',
);
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
it('FabricatorSubTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const content = fs.readFileSync(
path.join(__dirname, 'CraftingTab', 'FabricatorSubTab.tsx'),
'utf-8',
);
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
it('EnchanterSubTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const content = fs.readFileSync(
path.join(__dirname, 'CraftingTab', 'EnchanterSubTab.tsx'),
'utf-8',
);
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
});