import { describe, expect, it } from 'vitest'; import type { GameData, ResearchState, TechDef } from '../contracts'; import { gateFor, indexTech, isUnlocked, techProgress, techStatus } from './research'; const TECH: TechDef[] = [ { id: 'disc-artifact-bottling', era: 'disc', cost: { 'analog-pack': 10 }, unlocks: ['artifact-bottler'] }, { id: 'reel-silver-seams', era: 'reel', cost: { 'analog-pack': 5 }, unlocks: ['extract-silver-frames'] }, { id: 'stream-software-decoding', era: 'stream', cost: { 'spatial-pack': 4, 'analog-pack': 2 }, unlocks: ['software-decoder', 'mosh-reactor'] }, ]; const DATA = { items: [], machines: [], recipes: [], tech: TECH, commissions: [] } as unknown as GameData; const index = indexTech(DATA); const research = (over: Partial = {}): ResearchState => ({ active: null, progress: {}, unlocked: [], ...over }); describe('the v4 gating rule', () => { it('locks an id that any tech claims', () => { expect(isUnlocked(index, research(), 'artifact-bottler')).toBe(false); expect(isUnlocked(index, research(), 'mosh-reactor')).toBe(false); }); it('leaves ids referenced by no tech always available', () => { // "ids referenced by no tech are always available" — belts must never padlock. expect(isUnlocked(index, research(), 'belt')).toBe(true); expect(isUnlocked(index, research(), 'quantizer')).toBe(true); }); it('unlocks every id a completed tech claims', () => { const r = research({ unlocked: ['stream-software-decoding'] }); expect(isUnlocked(index, r, 'software-decoder')).toBe(true); expect(isUnlocked(index, r, 'mosh-reactor')).toBe(true); expect(isUnlocked(index, r, 'artifact-bottler')).toBe(false); // different tech }); it('treats absent research as nothing unlocked, not as everything unlocked', () => { // SIM has no research yet; the safe reading of the rule is that gates hold. expect(isUnlocked(index, undefined, 'artifact-bottler')).toBe(false); expect(isUnlocked(index, undefined, 'belt')).toBe(true); }); it('names the tech a locked machine is waiting on, for the REQUIRES tooltip', () => { expect(gateFor(index, research(), 'artifact-bottler')?.id).toBe('disc-artifact-bottling'); expect(gateFor(index, research(), 'belt')).toBeNull(); expect(gateFor(index, research({ unlocked: ['disc-artifact-bottling'] }), 'artifact-bottler')) .toBeNull(); }); }); describe('indexTech', () => { it('groups techs by era in era order, skipping empty eras', () => { expect(indexTech(DATA).byEra.map((g) => g.era)).toEqual(['reel', 'disc', 'stream']); }); it('keeps an era DATA invents rather than dropping its techs', () => { const odd = { ...DATA, tech: [...TECH, { id: 'x', era: 'vhs', cost: {}, unlocks: [] } as unknown as TechDef] }; const eras = indexTech(odd as GameData).byEra.map((g) => g.era); expect(eras).toContain('vhs'); expect(eras.at(-1)).toBe('vhs'); // last, not jumping the queue }); }); describe('techStatus / techProgress', () => { it('reports done, active and available', () => { expect(techStatus(research({ unlocked: ['reel-silver-seams'] }), TECH[1])).toBe('done'); expect(techStatus(research({ active: 'reel-silver-seams' }), TECH[1])).toBe('active'); expect(techStatus(research(), TECH[1])).toBe('available'); }); it('is 1 when done and 0 when not being worked on', () => { expect(techProgress(research({ unlocked: ['reel-silver-seams'] }), TECH[1])).toBe(1); expect(techProgress(research({ active: 'disc-artifact-bottling' }), TECH[1])).toBe(0); }); it('averages across every pack the tech costs', () => { // stream-software-decoding wants 4 spatial + 2 analog = 6 packs; 3 delivered = 0.5 const r = research({ active: 'stream-software-decoding', progress: { 'spatial-pack': 2, 'analog-pack': 1 } }); expect(techProgress(r, TECH[2])).toBeCloseTo(0.5); }); it('never exceeds 1 when the sim over-delivers a pack', () => { const r = research({ active: 'reel-silver-seams', progress: { 'analog-pack': 99 } }); expect(techProgress(r, TECH[1])).toBe(1); }); it('is 0 with no research state at all', () => { expect(techProgress(undefined, TECH[1])).toBe(0); expect(techStatus(undefined, TECH[1])).toBe('available'); }); });