diff --git a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.spec.ts b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.spec.ts new file mode 100644 index 00000000..0b8fd5e6 --- /dev/null +++ b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { calculatePath } from './text-scribbled.business'; +import { AVG_CHAR_WIDTH } from './text-scribbled.const'; + +describe('calculatePath', () => { + it('should return a non-empty path starting with M', () => { + const path = calculatePath(200, 50, 'test-id'); + expect(path).toBeTypeOf('string'); + expect(path.startsWith('M')).toBe(true); + expect(path.length).toBeGreaterThan(0); + }); + + it('should include at least one "C" or second "M" if width allows it', () => { + const path = calculatePath(300, 50, 'example'); + expect(/(C| M )/.test(path)).toBe(true); + }); + + it('should not generate path segments beyond the given width', () => { + const width = 100; + const path = calculatePath(width, 50, 'another-id'); + + const commands = path.split(' '); + const coords = commands + .filter(c => c.includes(',')) + .map(coord => { + const [x] = coord.split(',').map(Number); + return x; + }); + + coords.forEach(x => { + expect(x).toBeLessThanOrEqual(width); + }); + }); + + it('should eventually stop if the available width is too small', () => { + const width = AVG_CHAR_WIDTH * 2; // not enough for more than 1 char + const path = calculatePath(width, 50, 'tiny'); + const count = (path.match(/C/g) || []).length; + expect(count).toBeLessThanOrEqual(1); + }); + + it('should return empty or minimal path if SEED_PHRASE offset exceeds its length', () => { + const id = 'zzzzzzzzzzzzzzzzzzzzzzzzzzzz'; // large sum of char codes + const path = calculatePath(200, 50, id); + expect(path.startsWith('M')).toBe(true); + // It might not render any curves, just initial M + const segments = path.split(' '); + const hasCurves = segments.some(s => s === 'C'); + // It can be empty if SEED_PHRASE was too short after slicing + expect(typeof hasCurves).toBe('boolean'); + }); + + describe('calculatePath respects width and height boundaries', () => { + const testCases = [ + { width: 2000, height: 50, id: 'big-space' }, + { width: 10, height: 50, id: 'tiny-space' }, + { width: 100, height: 50, id: 'medium-space' }, + ]; + + testCases.forEach(({ width, height, id }) => { + it(`should keep all coordinates within bounds (width=${width}, height=${height})`, () => { + const path = calculatePath(width, height, id); + const commands = path.split(' '); + + const coordinates = commands + .filter(c => c.includes(',')) + .map(pair => { + const [xStr, yStr] = pair.split(','); + return { + x: parseFloat(xStr), + y: parseFloat(yStr), + }; + }); + + coordinates.forEach(({ x, y }) => { + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(width); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(height); + }); + }); + }); + }); +}); diff --git a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.ts b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.ts index 601d7ecb..fbf9a907 100644 --- a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.ts +++ b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.business.ts @@ -1,94 +1,13 @@ import { AVG_CHAR_WIDTH, + MAX_START_OFFSET, SEED_PHRASE, - SPACE_WIDTH, } from './text-scribbled.const'; - -export const seededRandom = (seed: number) => { - // Let's get a random value in between -1 and 1 - // And let's multiply it by 10000 to get a bigger number (more precision) - const x = Math.sin(seed) * 10000; - - // Le's extract the decimal part of the number - // a number in between 0 and 1 - return x - Math.floor(x); -}; - -// 30 characters is enough to get a good random offset phrase[X] -// in the past it was phrase.length, but that can lead to issues -// if the offset start at the end of the phrase then we can get a frozen text when we make it bigger. -const MAX_START_OFFSET = 30; - -// We need to add some random offset to start the text at a different position -// BUT we cannot use here just a random number because it will change every time -// the component is re-rendered, so we need to use a deterministic way to get the offset -// based on the Id of the shape -// 👇 Based on the Id deterministic offset -// a bit weird, maybe just a random useEffect [] -export const getOffsetFromId = (id: string, max: number) => { - let sum = 0; - for (let i = 0; i < id.length; i++) { - sum += id.charCodeAt(i); - } - return sum % max; -}; - -export const rounded = (value: number) => Math.round(value * 2) / 2; - -export const addBlankSpaceToPath = ( - currentX: number, - maxWidth: number, - height: number -) => { - currentX += SPACE_WIDTH; - - // We don't want to go out of the area, if not transformer won't work well - const adjustedEndX = Math.min(currentX, maxWidth - 1); - - return { - pathSlice: `M ${adjustedEndX},${Math.trunc(height / 2)}`, - newCurrentX: currentX, - }; -}; - -const drawCharScribble = ( - char: string, - i: number, - currentX: number, - maxWidth: number, - height: number -) => { - // Max Y variation on the scribble - const amplitude = height / 3; - const charWidth = AVG_CHAR_WIDTH; - // Let's generate a psuedo-random number based on the char and the index - const seed = char.charCodeAt(0) + i * 31; - - const controlX1 = currentX + charWidth / 2; - const controlY1 = Math.trunc( - rounded( - // Generate a pseudo random number between -amplitude and amplitude - height / 2 + (seededRandom(seed) * amplitude - amplitude / 2) - ) - ); - - const controlX2 = currentX + charWidth; - const controlY2 = Math.trunc( - rounded(height / 2 + (seededRandom(seed + 1) * amplitude - amplitude / 2)) - ); - - // Let's truc it to avoid edge cases with the max - const endX = Math.trunc(currentX + charWidth); - const endY = Math.trunc(height / 2); - - // We don't want to go out of the area, if not transformer won't work well - const adjustedEndX = Math.min(endX, maxWidth - 1); - - return { - pathSegment: `C ${controlX1},${controlY1} ${controlX2},${controlY2} ${adjustedEndX},${endY}`, - endX, - }; -}; +import { + addBlankSpaceToPath, + drawCharScribble, + getOffsetFromId, +} from './text-scribbled.utils'; export const calculatePath = (width: number, height: number, id: string) => { //console.log('** calculatePath', width, height, id); @@ -137,8 +56,5 @@ export const calculatePath = (width: number, height: number, id: string) => { if (currentX + AVG_CHAR_WIDTH >= width) break; } - const result = path.join(' '); - console.log('** calculatePath result', result); - return path.join(' '); }; diff --git a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.const.ts b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.const.ts index 8e0157dc..ac51891a 100644 --- a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.const.ts +++ b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.const.ts @@ -4,6 +4,11 @@ export const AVG_CHAR_WIDTH = 10; // Blank space width is 1.5 times the average character width export const SPACE_WIDTH = AVG_CHAR_WIDTH * 1.5; +// 30 characters is enough to get a good random offset phrase[X] +// in the past it was phrase.length, but that can lead to issues +// if the offset start at the end of the phrase then we can get a frozen text when we make it bigger. +export const MAX_START_OFFSET = 30; + // We use this as a seed to generate the random values for the path // We use this as a seed to generate the random values for the path export const SEED_PHRASE = diff --git a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.utils.spec.ts b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.utils.spec.ts new file mode 100644 index 00000000..49347642 --- /dev/null +++ b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.utils.spec.ts @@ -0,0 +1,95 @@ +import { + seededRandom, + getOffsetFromId, + rounded, + addBlankSpaceToPath, + drawCharScribble, +} from './text-scribbled.utils'; + +describe('seededRandom', () => { + it('should return a number between 0 and 1', () => { + const result = seededRandom(42); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThan(1); + }); + + it('should return the same result for the same seed', () => { + const a = seededRandom(123); + const b = seededRandom(123); + expect(a).toBe(b); + }); +}); + +describe('getOffsetFromId', () => { + it('should return a number less than max', () => { + const result = getOffsetFromId('test', 10); + expect(result).toBeLessThan(10); + }); + + it('should be deterministic', () => { + const a = getOffsetFromId('hello', 50); + const b = getOffsetFromId('hello', 50); + expect(a).toBe(b); + }); +}); + +describe('rounded', () => { + it('should round to nearest 0.5', () => { + expect(rounded(1.2)).toBe(1); + expect(rounded(1.3)).toBe(1.5); + expect(rounded(1.75)).toBe(2); + }); +}); + +describe('addBlankSpaceToPath', () => { + it('should return a pathSlice and newCurrentX', () => { + const result = addBlankSpaceToPath(10, 100, 50); + expect(result).toHaveProperty('pathSlice'); + expect(result).toHaveProperty('newCurrentX'); + }); + + it('should not exceed maxWidth - 1', () => { + const result = addBlankSpaceToPath(200, 210, 50); + const x = parseFloat(result.pathSlice.split(' ')[1]); + expect(x).toBeLessThanOrEqual(209); + }); +}); + +describe('drawCharScribble', () => { + it('should return a valid path segment', () => { + const result = drawCharScribble('A', 0, 0, 100, 50); + expect(result.pathSegment).toMatch(/^C \d+,\d+ \d+,\d+ \d+,\d+$/); + expect(result).toHaveProperty('endX'); + }); + + it('should respect maxWidth constraint', () => { + const result = drawCharScribble('Z', 3, 95, 100, 50); + const parts = result.pathSegment.split(' '); + const endX = parseInt(parts[3].split(',')[0], 10); + expect(endX).toBeLessThanOrEqual(99); + }); + + it('should respect maxWidth constraint for multiple random chars and dimensions', () => { + const randomChar = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + return chars[Math.floor(Math.random() * chars.length)]; + }; + + const testCases = Array.from({ length: 10 }, () => ({ + char: randomChar(), + index: Math.floor(Math.random() * 10), + currentX: Math.floor(Math.random() * 50), + maxWidth: 80 + Math.floor(Math.random() * 50), // values between 80 and 129 + height: 30 + Math.floor(Math.random() * 30), // values between 30 and 59 + })); + + testCases.forEach(({ char, index, currentX, maxWidth, height }) => { + const result = drawCharScribble(char, index, currentX, maxWidth, height); + const parts = result.pathSegment.split(' '); + const [, , , end] = parts; + const endX = parseInt(end.split(',')[0], 10); + + expect(endX).toBeLessThanOrEqual(maxWidth - 1); + }); + }); +}); diff --git a/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.utils.ts b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.utils.ts new file mode 100644 index 00000000..48af0193 --- /dev/null +++ b/src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.utils.ts @@ -0,0 +1,82 @@ +import { AVG_CHAR_WIDTH, SPACE_WIDTH } from './text-scribbled.const'; + +export const seededRandom = (seed: number) => { + // Let's get a random value in between -1 and 1 + // And let's multiply it by 10000 to get a bigger number (more precision) + const x = Math.sin(seed) * 10000; + + // Le's extract the decimal part of the number + // a number in between 0 and 1 + return x - Math.floor(x); +}; + +// We need to add some random offset to start the text at a different position +// BUT we cannot use here just a random number because it will change every time +// the component is re-rendered, so we need to use a deterministic way to get the offset +// based on the Id of the shape +// 👇 Based on the Id deterministic offset +// a bit weird, maybe just a random useEffect [] +export const getOffsetFromId = (id: string, max: number) => { + let sum = 0; + for (let i = 0; i < id.length; i++) { + sum += id.charCodeAt(i); + } + return sum % max; +}; + +export const rounded = (value: number) => Math.round(value * 2) / 2; + +export const addBlankSpaceToPath = ( + currentX: number, + maxWidth: number, + height: number +) => { + currentX += SPACE_WIDTH; + + // We don't want to go out of the area, if not transformer won't work well + const adjustedEndX = Math.min(currentX, maxWidth - 1); + + return { + pathSlice: `M ${adjustedEndX},${Math.trunc(height / 2)}`, + newCurrentX: currentX, + }; +}; + +export const drawCharScribble = ( + char: string, + i: number, + currentX: number, + maxWidth: number, + height: number +) => { + // Max Y variation on the scribble + const amplitude = height / 3; + const charWidth = AVG_CHAR_WIDTH; + // Let's generate a psuedo-random number based on the char and the index + const seed = char.charCodeAt(0) + i * 31; + + const controlX1 = currentX + charWidth / 2; + const controlY1 = Math.trunc( + rounded( + // Generate a pseudo random number between -amplitude and amplitude + height / 2 + (seededRandom(seed) * amplitude - amplitude / 2) + ) + ); + + const controlX2 = currentX + charWidth; + const controlY2 = Math.trunc( + rounded(height / 2 + (seededRandom(seed + 1) * amplitude - amplitude / 2)) + ); + + // Let's truc it to avoid edge cases with the max + const endX = Math.trunc(currentX + charWidth); + const endY = Math.trunc(height / 2); + + // We don't want to go out of the area, if not transformer won't work well + const adjustedEndX = Math.min(endX, maxWidth - 1); + + return { + pathSegment: `C ${controlX1},${controlY1} ${controlX2},${controlY2} ${adjustedEndX},${endY}`, + endX, + }; +};