@jeremyckahn/farmhand
Version:
A farming game
564 lines (469 loc) • 14.8 kB
JavaScript
import React from 'react'
import { render } from '@testing-library/react'
import { fieldMode } from '../../enums.js'
import { testItem, testCrop } from '../../test-utils/index.js'
import { INFINITE_STORAGE_LIMIT } from '../../constants.js'
import { noop } from '../../utils/noop.js'
import { Field, FieldContent, isInHoverRange, MemoPlot } from './Field.js'
// Mock Plot component to test MemoPlot memoization behavior
vitest.mock('../Plot/index.js', () => {
const mockPlot = vitest.fn(({ x, y, isInHoverRange }) => (
<div
className="Plot"
data-x={x}
data-y={y}
data-in-hover-range={isInHoverRange}
/>
))
return { default: mockPlot }
})
// Mock complex dependencies
vitest.mock('../../data/maps.js')
vitest.mock('../../data/items.js')
vitest.mock('../../data/levels.js', () => ({ levels: [] }))
vitest.mock('../../data/shop-inventory.js')
vitest.mock('../../img/index.js', () => ({
tools: {
'watering-can': 'mock-watering-can.png',
hoe: 'mock-hoe.png',
scythe: 'mock-scythe.png',
shovel: 'mock-shovel.png',
},
craftedItems: {},
items: {},
plotStates: {},
wines: {},
animals: {},
pixel: 'mock-pixel.png',
}))
vitest.mock('react-zoom-pan-pinch', () => ({
TransformWrapper: ({ children }) => (
<div className="transform-wrapper">
{children({
scale: 1,
previousScale: 1,
resetTransform: () => {},
zoomIn: () => {},
zoomOut: () => {},
})}
</div>
),
TransformComponent: ({ children }) => (
<div className="transform-component">{children}</div>
),
}))
const defaultFieldProps = {
columns: 2,
experience: 0,
hoveredPlotRangeSize: 0,
handleCombineEnabledChange: noop,
handleFieldActionRangeChange: noop,
rows: 3,
field: [
[null, null],
[null, null],
[null, null],
],
fieldMode: fieldMode.OBSERVE,
inventory: [],
inventoryLimit: INFINITE_STORAGE_LIMIT,
isCombineEnabled: false,
purchasedCombine: 0,
purchasedField: 0,
}
const defaultFieldContentProps = {
columns: 2,
experience: 1,
handleCombineEnabledChange: noop,
field: [
[null, null],
[null, null],
[null, null],
],
hoveredPlot: {},
hoveredPlotRangeSize: 0,
fieldMode: fieldMode.OBSERVE,
isCombineEnabled: false,
purchasedCombine: 0,
rows: 3,
setHoveredPlot: noop,
}
describe('Field', () => {
test('renders', () => {
render(<Field {...defaultFieldProps} />)
expect(document.querySelector('.Field')).toBeInTheDocument()
})
test('applies inventory full class when inventory is full', () => {
const fullInventoryProps = {
...defaultFieldProps,
inventoryLimit: 1,
inventory: [testItem({ quantity: 1 })],
fieldMode: fieldMode.HARVEST,
}
render(<Field {...fullInventoryProps} />)
expect(
document.querySelector('.Field.is-inventory-full')
).toBeInTheDocument()
})
test('does not apply inventory full class when inventory has space', () => {
const props = {
...defaultFieldProps,
inventoryLimit: 10,
inventory: [testItem({ quantity: 1 })],
}
render(<Field {...props} />)
expect(
document.querySelector('.Field.is-inventory-full')
).not.toBeInTheDocument()
})
test('renders field content', () => {
render(<Field {...defaultFieldProps} />)
expect(document.querySelector('.row-wrapper')).toBeInTheDocument()
})
test('applies correct field mode class', () => {
render(<Field {...defaultFieldProps} fieldMode={fieldMode.PLANT} />)
expect(document.querySelector('.Field')).toHaveClass('plant-mode')
})
})
describe('FieldContent', () => {
test('renders field grid', () => {
render(<FieldContent {...defaultFieldContentProps} />)
expect(document.querySelector('.row-wrapper')).toBeInTheDocument()
})
test('renders correct number of rows', () => {
render(<FieldContent {...defaultFieldContentProps} />)
const rows = document.querySelectorAll('.row')
expect(rows).toHaveLength(3)
})
test('renders correct number of plots per row', () => {
render(<FieldContent {...defaultFieldContentProps} />)
const firstRow = document.querySelector('.row')
const plots = firstRow?.querySelectorAll('.Plot')
expect(plots).toHaveLength(2)
})
test('renders field with crops', () => {
const fieldWithCrops = [
[testCrop({ itemId: 'carrot' }), null],
[null, testCrop({ itemId: 'corn' })],
[null, null],
]
render(
<FieldContent {...defaultFieldContentProps} field={fieldWithCrops} />
)
expect(document.querySelectorAll('.Plot')).toHaveLength(6) // 2 columns × 3 rows
})
test('handles different field modes', () => {
render(
<FieldContent {...defaultFieldContentProps} fieldMode={fieldMode.WATER} />
)
expect(document.querySelector('.row-wrapper')).toBeInTheDocument()
})
test('handles combine mode when enabled', () => {
render(
<FieldContent
{...defaultFieldContentProps}
isCombineEnabled={true}
fieldMode={fieldMode.HARVEST}
/>
)
expect(document.querySelector('.row-wrapper')).toBeInTheDocument()
})
})
describe('MemoPlot', () => {
// Import the mocked Plot component to access its call count
let MockedPlot
beforeEach(async () => {
const PlotModule = await import('../Plot/index.js')
MockedPlot = PlotModule.default
// @ts-expect-error - MockedPlot is a vitest mock function
MockedPlot.mockClear()
})
const defaultPlotProps = {
experience: 0,
plotContent: null,
x: 0,
y: 0,
fieldMode: fieldMode.OBSERVE,
hoveredPlot: { x: null, y: null },
hoveredPlotRangeSize: 0,
setHoveredPlot: noop,
}
test('passes isInHoverRange calculation to Plot component', () => {
const props = {
...defaultPlotProps,
fieldMode: fieldMode.PLANT,
hoveredPlot: { x: 1, y: 1 },
hoveredPlotRangeSize: 1,
x: 1,
y: 1,
}
render(<MemoPlot {...props} />)
expect(MockedPlot).toHaveBeenCalledWith(
expect.objectContaining({
isInHoverRange: true,
hoveredPlot: { x: 1, y: 1 },
plotContent: null,
setHoveredPlot: noop,
x: 1,
y: 1,
}),
{}
)
})
test('passes false for isInHoverRange when out of range', () => {
const props = {
...defaultPlotProps,
fieldMode: fieldMode.PLANT,
hoveredPlot: { x: 0, y: 0 },
hoveredPlotRangeSize: 1,
x: 5,
y: 5,
}
render(<MemoPlot {...props} />)
expect(MockedPlot).toHaveBeenCalledWith(
expect.objectContaining({
isInHoverRange: false,
}),
{}
)
})
test('memoizes when props that do not affect rendering remain the same', () => {
const { rerender } = render(<MemoPlot {...defaultPlotProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
// Re-render with same props - should not call Plot again due to memoization
rerender(<MemoPlot {...defaultPlotProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
})
test('re-renders when plotContent changes', () => {
const { rerender } = render(<MemoPlot {...defaultPlotProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
// Change plotContent - should trigger re-render
const newProps = {
...defaultPlotProps,
plotContent: testCrop({ itemId: 'carrot' }),
}
rerender(<MemoPlot {...newProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(2)
})
test('re-renders when hoveredPlotRangeSize changes', () => {
const { rerender } = render(<MemoPlot {...defaultPlotProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
// Change hoveredPlotRangeSize - should trigger re-render
const newProps = {
...defaultPlotProps,
hoveredPlotRangeSize: 2,
}
rerender(<MemoPlot {...newProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(2)
})
test('re-renders when isInHoverRange result changes', () => {
const initialProps = {
...defaultPlotProps,
fieldMode: fieldMode.PLANT,
hoveredPlot: { x: 1, y: 1 },
hoveredPlotRangeSize: 1,
x: 1,
y: 1, // In range initially
}
const { rerender } = render(<MemoPlot {...initialProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
expect(MockedPlot).toHaveBeenLastCalledWith(
expect.objectContaining({ isInHoverRange: true }),
{}
)
// Move plot out of range - should trigger re-render
const newProps = {
...initialProps,
x: 5,
y: 5, // Out of range now
}
rerender(<MemoPlot {...newProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(2)
expect(MockedPlot).toHaveBeenLastCalledWith(
expect.objectContaining({ isInHoverRange: false }),
{}
)
})
test('does not re-render when non-memoized props change but result is the same', () => {
const initialProps = {
...defaultPlotProps,
fieldMode: fieldMode.PLANT,
hoveredPlot: { x: 1, y: 1 },
hoveredPlotRangeSize: 1,
x: 1,
y: 1,
experience: 0,
}
const { rerender } = render(<MemoPlot {...initialProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
// Change experience (which affects isInHoverRange calculation)
// but result should be the same
const newProps = {
...initialProps,
experience: 100,
}
rerender(<MemoPlot {...newProps} />)
// Should still only be called once due to memoization
expect(MockedPlot).toHaveBeenCalledTimes(1)
})
test('handles null hoveredPlot coordinates correctly', () => {
const props = {
...defaultPlotProps,
fieldMode: fieldMode.PLANT,
hoveredPlot: { x: null, y: null },
hoveredPlotRangeSize: 1,
x: 1,
y: 1,
}
render(<MemoPlot {...props} />)
expect(MockedPlot).toHaveBeenCalledWith(
expect.objectContaining({
isInHoverRange: false, // Should be false when hoveredPlot has null coordinates
}),
{}
)
})
test('re-renders when hoveredPlot changes from null to valid coordinates', () => {
const initialProps = {
...defaultPlotProps,
fieldMode: fieldMode.PLANT,
hoveredPlot: { x: null, y: null },
hoveredPlotRangeSize: 1,
x: 1,
y: 1,
}
const { rerender } = render(<MemoPlot {...initialProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
expect(MockedPlot).toHaveBeenLastCalledWith(
expect.objectContaining({ isInHoverRange: false }),
{}
)
// Change hoveredPlot from null to valid coordinates
const newProps = {
...initialProps,
hoveredPlot: { x: 1, y: 1 },
}
rerender(<MemoPlot {...newProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(2)
expect(MockedPlot).toHaveBeenLastCalledWith(
expect.objectContaining({ isInHoverRange: true }),
{}
)
})
test('does not re-render in OBSERVE mode regardless of hover changes', () => {
const initialProps = {
...defaultPlotProps,
fieldMode: fieldMode.OBSERVE,
hoveredPlot: { x: 1, y: 1 },
hoveredPlotRangeSize: 1,
x: 1,
y: 1,
}
const { rerender } = render(<MemoPlot {...initialProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
// Change hoveredPlot coordinates - should not trigger re-render in OBSERVE mode
// because isInHoverRange always returns false in OBSERVE mode
const newProps = {
...initialProps,
hoveredPlot: { x: 2, y: 2 },
}
rerender(<MemoPlot {...newProps} />)
expect(MockedPlot).toHaveBeenCalledTimes(1)
})
})
describe('isInHoverRange utility', () => {
test('returns true when plot is in range', () => {
const props = {
experience: 0,
fieldMode: fieldMode.PLANT,
hoveredPlotRangeSize: 1,
hoveredPlot: { x: 2, y: 2 },
x: 3,
y: 2,
}
expect(isInHoverRange(props)).toBe(true)
})
test('returns false when plot is out of range', () => {
const props = {
experience: 0,
fieldMode: fieldMode.PLANT,
hoveredPlotRangeSize: 1,
hoveredPlot: { x: 0, y: 0 },
x: 5,
y: 5,
}
expect(isInHoverRange(props)).toBe(false)
})
test('handles range size of 0', () => {
const props = {
experience: 0,
fieldMode: fieldMode.PLANT,
hoveredPlotRangeSize: 0,
hoveredPlot: { x: 2, y: 2 },
x: 2,
y: 2,
}
expect(isInHoverRange(props)).toBe(true)
})
test('handles different range sizes', () => {
const props = {
experience: 0,
fieldMode: fieldMode.PLANT,
hoveredPlotRangeSize: 2,
hoveredPlot: { x: 5, y: 5 },
x: 7,
y: 7,
}
expect(isInHoverRange(props)).toBe(true)
})
test('returns false for plots at exact boundary', () => {
const props = {
experience: 0,
fieldMode: fieldMode.PLANT,
hoveredPlotRangeSize: 1,
hoveredPlot: { x: 0, y: 0 },
x: 2,
y: 0,
}
expect(isInHoverRange(props)).toBe(false)
})
})
// Integration tests for common field operations
describe('Field integration tests', () => {
test('displays field in observe mode', () => {
render(<Field {...defaultFieldProps} fieldMode={fieldMode.OBSERVE} />)
expect(document.querySelector('.Field')).toBeInTheDocument()
// OBSERVE mode doesn't add any specific CSS class, just verify field renders
expect(document.querySelector('.Field')).not.toHaveClass('observe-mode')
})
test('displays field in plant mode', () => {
render(<Field {...defaultFieldProps} fieldMode={fieldMode.PLANT} />)
expect(document.querySelector('.Field')).toHaveClass('plant-mode')
})
test('displays field in water mode', () => {
render(<Field {...defaultFieldProps} fieldMode={fieldMode.WATER} />)
expect(document.querySelector('.Field')).toHaveClass('water-mode')
})
test('displays field in harvest mode', () => {
render(<Field {...defaultFieldProps} fieldMode={fieldMode.HARVEST} />)
expect(document.querySelector('.Field')).toHaveClass('harvest-mode')
})
test('renders field with mixed plot content', () => {
const mixedField = [
[testCrop({ itemId: 'carrot' }), null],
[testCrop({ itemId: 'corn' }), testCrop({ itemId: 'potato' })],
[null, null],
]
render(<Field {...defaultFieldProps} field={mixedField} />)
expect(document.querySelectorAll('.Plot')).toHaveLength(6)
})
test('handles large field sizes', () => {
const largeField = Array(10)
.fill(null)
.map(() => Array(10).fill(null))
render(
<Field {...defaultFieldProps} field={largeField} rows={10} columns={10} />
)
expect(document.querySelectorAll('.Plot')).toHaveLength(100)
})
})