refactor: Extract color block calculation logic to utility module

Extract color block calculation from ProgressMonitor component to
colorBlockHelpers utility module for better testability and reusability.

Changes:
- Created colorBlockHelpers.ts with calculateColorBlocks() and findCurrentBlockIndex()
- Added comprehensive unit tests (11 test cases, all passing)
- Updated ProgressMonitor to use new utility functions
- Reduced component complexity by removing embedded business logic

Benefits:
- Logic can be tested in isolation
- Can be reused elsewhere if needed
- Cleaner component code
- Better separation of concerns

Fixes #44

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-27 17:21:42 +01:00
parent d213ab49e2
commit 101f46e627
3 changed files with 311 additions and 28 deletions

View file

@ -12,6 +12,10 @@ import { usePatternStore } from "../../stores/usePatternStore";
import { ChartBarIcon } from "@heroicons/react/24/solid";
import { MachineStatus } from "../../types/machine";
import { calculatePatternTime } from "../../utils/timeCalculation";
import {
calculateColorBlocks,
findCurrentBlockIndex,
} from "../../utils/colorBlockHelpers";
import {
Card,
CardHeader,
@ -23,7 +27,6 @@ import { ProgressStats } from "./ProgressStats";
import { ProgressSection } from "./ProgressSection";
import { ColorBlockList } from "./ColorBlockList";
import { ProgressActions } from "./ProgressActions";
import type { ColorBlock } from "./types";
export function ProgressMonitor() {
// Machine store
@ -69,36 +72,14 @@ export function ProgressMonitor() {
: 0;
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => {
if (!displayPattern || !displayPattern.penStitches) return [];
const blocks: ColorBlock[] = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of displayPattern.penStitches.colorBlocks) {
const thread = displayPattern.threads[penBlock.colorIndex];
blocks.push({
colorIndex: penBlock.colorIndex,
threadHex: thread?.hex || "#000000",
threadCatalogNumber: thread?.catalogNumber ?? null,
threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null,
threadChart: thread?.chart ?? null,
startStitch: penBlock.startStitchIndex,
endStitch: penBlock.endStitchIndex,
stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
});
}
return blocks;
}, [displayPattern]);
const colorBlocks = useMemo(
() => calculateColorBlocks(displayPattern),
[displayPattern],
);
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = colorBlocks.findIndex(
(block) =>
currentStitch >= block.startStitch && currentStitch < block.endStitch,
);
const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch);
// Calculate time based on color blocks (matches Brother app calculation)
const { totalMinutes, elapsedMinutes } = useMemo(() => {

View file

@ -0,0 +1,241 @@
import { describe, it, expect } from "vitest";
import {
calculateColorBlocks,
findCurrentBlockIndex,
} from "./colorBlockHelpers";
import type { PesPatternData } from "../formats/import/client";
describe("colorBlockHelpers", () => {
describe("calculateColorBlocks", () => {
it("should return empty array when displayPattern is null", () => {
const result = calculateColorBlocks(null);
expect(result).toEqual([]);
});
it("should return empty array when penStitches is undefined", () => {
const pattern = {
penStitches: undefined,
} as unknown as PesPatternData;
const result = calculateColorBlocks(pattern);
expect(result).toEqual([]);
});
it("should calculate color blocks from PEN data", () => {
const pattern: Partial<PesPatternData> = {
threads: [
{
color: 1,
hex: "#FF0000",
brand: "Brother",
catalogNumber: "001",
description: "Red",
chart: "A",
},
{
color: 2,
hex: "#00FF00",
brand: "Brother",
catalogNumber: "002",
description: "Green",
chart: "B",
},
],
penStitches: {
colorBlocks: [
{
startStitchIndex: 0,
endStitchIndex: 100,
colorIndex: 0,
startStitch: 0,
endStitch: 100,
},
{
startStitchIndex: 100,
endStitchIndex: 250,
colorIndex: 1,
startStitch: 100,
endStitch: 250,
},
],
stitches: [],
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
},
};
const result = calculateColorBlocks(pattern as PesPatternData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
colorIndex: 0,
threadHex: "#FF0000",
threadCatalogNumber: "001",
threadBrand: "Brother",
threadDescription: "Red",
threadChart: "A",
startStitch: 0,
endStitch: 100,
stitchCount: 100,
});
expect(result[1]).toEqual({
colorIndex: 1,
threadHex: "#00FF00",
threadCatalogNumber: "002",
threadBrand: "Brother",
threadDescription: "Green",
threadChart: "B",
startStitch: 100,
endStitch: 250,
stitchCount: 150,
});
});
it("should use fallback values when thread data is missing", () => {
const pattern: Partial<PesPatternData> = {
threads: [],
penStitches: {
colorBlocks: [
{
startStitchIndex: 0,
endStitchIndex: 50,
colorIndex: 0,
startStitch: 0,
endStitch: 50,
},
],
stitches: [],
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
},
};
const result = calculateColorBlocks(pattern as PesPatternData);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
colorIndex: 0,
threadHex: "#000000", // Fallback for missing thread
threadCatalogNumber: null,
threadBrand: null,
threadDescription: null,
threadChart: null,
startStitch: 0,
endStitch: 50,
stitchCount: 50,
});
});
it("should handle null thread metadata fields", () => {
const pattern: Partial<PesPatternData> = {
threads: [
{
color: 1,
hex: "#0000FF",
brand: null,
catalogNumber: null,
description: null,
chart: null,
},
],
penStitches: {
colorBlocks: [
{
startStitchIndex: 0,
endStitchIndex: 30,
colorIndex: 0,
startStitch: 0,
endStitch: 30,
},
],
stitches: [],
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
},
};
const result = calculateColorBlocks(pattern as PesPatternData);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
colorIndex: 0,
threadHex: "#0000FF",
threadCatalogNumber: null,
threadBrand: null,
threadDescription: null,
threadChart: null,
startStitch: 0,
endStitch: 30,
stitchCount: 30,
});
});
});
describe("findCurrentBlockIndex", () => {
const colorBlocks = [
{
colorIndex: 0,
threadHex: "#FF0000",
threadCatalogNumber: "001",
threadBrand: "Brother",
threadDescription: "Red",
threadChart: "A",
startStitch: 0,
endStitch: 100,
stitchCount: 100,
},
{
colorIndex: 1,
threadHex: "#00FF00",
threadCatalogNumber: "002",
threadBrand: "Brother",
threadDescription: "Green",
threadChart: "B",
startStitch: 100,
endStitch: 250,
stitchCount: 150,
},
{
colorIndex: 2,
threadHex: "#0000FF",
threadCatalogNumber: "003",
threadBrand: "Brother",
threadDescription: "Blue",
threadChart: "C",
startStitch: 250,
endStitch: 400,
stitchCount: 150,
},
];
it("should find block containing stitch at start boundary", () => {
expect(findCurrentBlockIndex(colorBlocks, 0)).toBe(0);
expect(findCurrentBlockIndex(colorBlocks, 100)).toBe(1);
expect(findCurrentBlockIndex(colorBlocks, 250)).toBe(2);
});
it("should find block containing stitch in middle", () => {
expect(findCurrentBlockIndex(colorBlocks, 50)).toBe(0);
expect(findCurrentBlockIndex(colorBlocks, 150)).toBe(1);
expect(findCurrentBlockIndex(colorBlocks, 300)).toBe(2);
});
it("should return -1 for stitch before first block", () => {
expect(findCurrentBlockIndex(colorBlocks, -1)).toBe(-1);
});
it("should return -1 for stitch at or after last block end", () => {
expect(findCurrentBlockIndex(colorBlocks, 400)).toBe(-1);
expect(findCurrentBlockIndex(colorBlocks, 500)).toBe(-1);
});
it("should return -1 for empty color blocks array", () => {
expect(findCurrentBlockIndex([], 50)).toBe(-1);
});
it("should find block with single color block", () => {
const singleBlock = [colorBlocks[0]];
expect(findCurrentBlockIndex(singleBlock, 0)).toBe(0);
expect(findCurrentBlockIndex(singleBlock, 50)).toBe(0);
expect(findCurrentBlockIndex(singleBlock, 99)).toBe(0);
expect(findCurrentBlockIndex(singleBlock, 100)).toBe(-1);
});
});
});

View file

@ -0,0 +1,61 @@
/**
* Color Block Helpers
*
* Utility functions for calculating color block information from pattern data.
* Extracted from ProgressMonitor component for better testability and reusability.
*/
import type { PesPatternData } from "../formats/import/client";
import type { ColorBlock } from "../components/ProgressMonitor/types";
/**
* Calculate color blocks from decoded PEN pattern data
*
* Transforms PEN color blocks into enriched ColorBlock objects with thread metadata.
* Returns an empty array if pattern or penStitches data is unavailable.
*
* @param displayPattern - The PES pattern data containing penStitches and threads
* @returns Array of ColorBlock objects with thread information and stitch counts
*/
export function calculateColorBlocks(
displayPattern: PesPatternData | null,
): ColorBlock[] {
if (!displayPattern || !displayPattern.penStitches) return [];
const blocks: ColorBlock[] = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of displayPattern.penStitches.colorBlocks) {
const thread = displayPattern.threads[penBlock.colorIndex];
blocks.push({
colorIndex: penBlock.colorIndex,
threadHex: thread?.hex || "#000000",
threadCatalogNumber: thread?.catalogNumber ?? null,
threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null,
threadChart: thread?.chart ?? null,
startStitch: penBlock.startStitchIndex,
endStitch: penBlock.endStitchIndex,
stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
});
}
return blocks;
}
/**
* Find the index of the color block containing a specific stitch
*
* @param colorBlocks - Array of color blocks to search
* @param currentStitch - The stitch index to find
* @returns The index of the containing block, or -1 if not found
*/
export function findCurrentBlockIndex(
colorBlocks: ColorBlock[],
currentStitch: number,
): number {
return colorBlocks.findIndex(
(block) =>
currentStitch >= block.startStitch && currentStitch < block.endStitch,
);
}