Skip to content

Commit 851a5c0

Browse files
authored
Merge pull request #753 from Lemoncode/730-low-wireframes---create-scribbled-text-component
unit tests added
2 parents c4f22a5 + a4355e6 commit 851a5c0

File tree

5 files changed

+272
-90
lines changed

5 files changed

+272
-90
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { calculatePath } from './text-scribbled.business';
3+
import { AVG_CHAR_WIDTH } from './text-scribbled.const';
4+
5+
describe('calculatePath', () => {
6+
it('should return a non-empty path starting with M', () => {
7+
const path = calculatePath(200, 50, 'test-id');
8+
expect(path).toBeTypeOf('string');
9+
expect(path.startsWith('M')).toBe(true);
10+
expect(path.length).toBeGreaterThan(0);
11+
});
12+
13+
it('should include at least one "C" or second "M" if width allows it', () => {
14+
const path = calculatePath(300, 50, 'example');
15+
expect(/(C| M )/.test(path)).toBe(true);
16+
});
17+
18+
it('should not generate path segments beyond the given width', () => {
19+
const width = 100;
20+
const path = calculatePath(width, 50, 'another-id');
21+
22+
const commands = path.split(' ');
23+
const coords = commands
24+
.filter(c => c.includes(','))
25+
.map(coord => {
26+
const [x] = coord.split(',').map(Number);
27+
return x;
28+
});
29+
30+
coords.forEach(x => {
31+
expect(x).toBeLessThanOrEqual(width);
32+
});
33+
});
34+
35+
it('should eventually stop if the available width is too small', () => {
36+
const width = AVG_CHAR_WIDTH * 2; // not enough for more than 1 char
37+
const path = calculatePath(width, 50, 'tiny');
38+
const count = (path.match(/C/g) || []).length;
39+
expect(count).toBeLessThanOrEqual(1);
40+
});
41+
42+
it('should return empty or minimal path if SEED_PHRASE offset exceeds its length', () => {
43+
const id = 'zzzzzzzzzzzzzzzzzzzzzzzzzzzz'; // large sum of char codes
44+
const path = calculatePath(200, 50, id);
45+
expect(path.startsWith('M')).toBe(true);
46+
// It might not render any curves, just initial M
47+
const segments = path.split(' ');
48+
const hasCurves = segments.some(s => s === 'C');
49+
// It can be empty if SEED_PHRASE was too short after slicing
50+
expect(typeof hasCurves).toBe('boolean');
51+
});
52+
53+
describe('calculatePath respects width and height boundaries', () => {
54+
const testCases = [
55+
{ width: 2000, height: 50, id: 'big-space' },
56+
{ width: 10, height: 50, id: 'tiny-space' },
57+
{ width: 100, height: 50, id: 'medium-space' },
58+
];
59+
60+
testCases.forEach(({ width, height, id }) => {
61+
it(`should keep all coordinates within bounds (width=${width}, height=${height})`, () => {
62+
const path = calculatePath(width, height, id);
63+
const commands = path.split(' ');
64+
65+
const coordinates = commands
66+
.filter(c => c.includes(','))
67+
.map(pair => {
68+
const [xStr, yStr] = pair.split(',');
69+
return {
70+
x: parseFloat(xStr),
71+
y: parseFloat(yStr),
72+
};
73+
});
74+
75+
coordinates.forEach(({ x, y }) => {
76+
expect(x).toBeGreaterThanOrEqual(0);
77+
expect(x).toBeLessThanOrEqual(width);
78+
expect(y).toBeGreaterThanOrEqual(0);
79+
expect(y).toBeLessThanOrEqual(height);
80+
});
81+
});
82+
});
83+
});
84+
});
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,13 @@
11
import {
22
AVG_CHAR_WIDTH,
3+
MAX_START_OFFSET,
34
SEED_PHRASE,
4-
SPACE_WIDTH,
55
} from './text-scribbled.const';
6-
7-
export const seededRandom = (seed: number) => {
8-
// Let's get a random value in between -1 and 1
9-
// And let's multiply it by 10000 to get a bigger number (more precision)
10-
const x = Math.sin(seed) * 10000;
11-
12-
// Le's extract the decimal part of the number
13-
// a number in between 0 and 1
14-
return x - Math.floor(x);
15-
};
16-
17-
// 30 characters is enough to get a good random offset phrase[X]
18-
// in the past it was phrase.length, but that can lead to issues
19-
// if the offset start at the end of the phrase then we can get a frozen text when we make it bigger.
20-
const MAX_START_OFFSET = 30;
21-
22-
// We need to add some random offset to start the text at a different position
23-
// BUT we cannot use here just a random number because it will change every time
24-
// the component is re-rendered, so we need to use a deterministic way to get the offset
25-
// based on the Id of the shape
26-
// 👇 Based on the Id deterministic offset
27-
// a bit weird, maybe just a random useEffect []
28-
export const getOffsetFromId = (id: string, max: number) => {
29-
let sum = 0;
30-
for (let i = 0; i < id.length; i++) {
31-
sum += id.charCodeAt(i);
32-
}
33-
return sum % max;
34-
};
35-
36-
export const rounded = (value: number) => Math.round(value * 2) / 2;
37-
38-
export const addBlankSpaceToPath = (
39-
currentX: number,
40-
maxWidth: number,
41-
height: number
42-
) => {
43-
currentX += SPACE_WIDTH;
44-
45-
// We don't want to go out of the area, if not transformer won't work well
46-
const adjustedEndX = Math.min(currentX, maxWidth - 1);
47-
48-
return {
49-
pathSlice: `M ${adjustedEndX},${Math.trunc(height / 2)}`,
50-
newCurrentX: currentX,
51-
};
52-
};
53-
54-
const drawCharScribble = (
55-
char: string,
56-
i: number,
57-
currentX: number,
58-
maxWidth: number,
59-
height: number
60-
) => {
61-
// Max Y variation on the scribble
62-
const amplitude = height / 3;
63-
const charWidth = AVG_CHAR_WIDTH;
64-
// Let's generate a psuedo-random number based on the char and the index
65-
const seed = char.charCodeAt(0) + i * 31;
66-
67-
const controlX1 = currentX + charWidth / 2;
68-
const controlY1 = Math.trunc(
69-
rounded(
70-
// Generate a pseudo random number between -amplitude and amplitude
71-
height / 2 + (seededRandom(seed) * amplitude - amplitude / 2)
72-
)
73-
);
74-
75-
const controlX2 = currentX + charWidth;
76-
const controlY2 = Math.trunc(
77-
rounded(height / 2 + (seededRandom(seed + 1) * amplitude - amplitude / 2))
78-
);
79-
80-
// Let's truc it to avoid edge cases with the max
81-
const endX = Math.trunc(currentX + charWidth);
82-
const endY = Math.trunc(height / 2);
83-
84-
// We don't want to go out of the area, if not transformer won't work well
85-
const adjustedEndX = Math.min(endX, maxWidth - 1);
86-
87-
return {
88-
pathSegment: `C ${controlX1},${controlY1} ${controlX2},${controlY2} ${adjustedEndX},${endY}`,
89-
endX,
90-
};
91-
};
6+
import {
7+
addBlankSpaceToPath,
8+
drawCharScribble,
9+
getOffsetFromId,
10+
} from './text-scribbled.utils';
9211

9312
export const calculatePath = (width: number, height: number, id: string) => {
9413
//console.log('** calculatePath', width, height, id);
@@ -137,8 +56,5 @@ export const calculatePath = (width: number, height: number, id: string) => {
13756
if (currentX + AVG_CHAR_WIDTH >= width) break;
13857
}
13958

140-
const result = path.join(' ');
141-
console.log('** calculatePath result', result);
142-
14359
return path.join(' ');
14460
};

src/common/components/mock-components/front-low-wireframes-components/text-scribbled-shape/text-scribbled.const.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export const AVG_CHAR_WIDTH = 10;
44
// Blank space width is 1.5 times the average character width
55
export const SPACE_WIDTH = AVG_CHAR_WIDTH * 1.5;
66

7+
// 30 characters is enough to get a good random offset phrase[X]
8+
// in the past it was phrase.length, but that can lead to issues
9+
// if the offset start at the end of the phrase then we can get a frozen text when we make it bigger.
10+
export const MAX_START_OFFSET = 30;
11+
712
// We use this as a seed to generate the random values for the path
813
// We use this as a seed to generate the random values for the path
914
export const SEED_PHRASE =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
seededRandom,
3+
getOffsetFromId,
4+
rounded,
5+
addBlankSpaceToPath,
6+
drawCharScribble,
7+
} from './text-scribbled.utils';
8+
9+
describe('seededRandom', () => {
10+
it('should return a number between 0 and 1', () => {
11+
const result = seededRandom(42);
12+
expect(result).toBeGreaterThanOrEqual(0);
13+
expect(result).toBeLessThan(1);
14+
});
15+
16+
it('should return the same result for the same seed', () => {
17+
const a = seededRandom(123);
18+
const b = seededRandom(123);
19+
expect(a).toBe(b);
20+
});
21+
});
22+
23+
describe('getOffsetFromId', () => {
24+
it('should return a number less than max', () => {
25+
const result = getOffsetFromId('test', 10);
26+
expect(result).toBeLessThan(10);
27+
});
28+
29+
it('should be deterministic', () => {
30+
const a = getOffsetFromId('hello', 50);
31+
const b = getOffsetFromId('hello', 50);
32+
expect(a).toBe(b);
33+
});
34+
});
35+
36+
describe('rounded', () => {
37+
it('should round to nearest 0.5', () => {
38+
expect(rounded(1.2)).toBe(1);
39+
expect(rounded(1.3)).toBe(1.5);
40+
expect(rounded(1.75)).toBe(2);
41+
});
42+
});
43+
44+
describe('addBlankSpaceToPath', () => {
45+
it('should return a pathSlice and newCurrentX', () => {
46+
const result = addBlankSpaceToPath(10, 100, 50);
47+
expect(result).toHaveProperty('pathSlice');
48+
expect(result).toHaveProperty('newCurrentX');
49+
});
50+
51+
it('should not exceed maxWidth - 1', () => {
52+
const result = addBlankSpaceToPath(200, 210, 50);
53+
const x = parseFloat(result.pathSlice.split(' ')[1]);
54+
expect(x).toBeLessThanOrEqual(209);
55+
});
56+
});
57+
58+
describe('drawCharScribble', () => {
59+
it('should return a valid path segment', () => {
60+
const result = drawCharScribble('A', 0, 0, 100, 50);
61+
expect(result.pathSegment).toMatch(/^C \d+,\d+ \d+,\d+ \d+,\d+$/);
62+
expect(result).toHaveProperty('endX');
63+
});
64+
65+
it('should respect maxWidth constraint', () => {
66+
const result = drawCharScribble('Z', 3, 95, 100, 50);
67+
const parts = result.pathSegment.split(' ');
68+
const endX = parseInt(parts[3].split(',')[0], 10);
69+
expect(endX).toBeLessThanOrEqual(99);
70+
});
71+
72+
it('should respect maxWidth constraint for multiple random chars and dimensions', () => {
73+
const randomChar = () => {
74+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
75+
return chars[Math.floor(Math.random() * chars.length)];
76+
};
77+
78+
const testCases = Array.from({ length: 10 }, () => ({
79+
char: randomChar(),
80+
index: Math.floor(Math.random() * 10),
81+
currentX: Math.floor(Math.random() * 50),
82+
maxWidth: 80 + Math.floor(Math.random() * 50), // values between 80 and 129
83+
height: 30 + Math.floor(Math.random() * 30), // values between 30 and 59
84+
}));
85+
86+
testCases.forEach(({ char, index, currentX, maxWidth, height }) => {
87+
const result = drawCharScribble(char, index, currentX, maxWidth, height);
88+
const parts = result.pathSegment.split(' ');
89+
const [, , , end] = parts;
90+
const endX = parseInt(end.split(',')[0], 10);
91+
92+
expect(endX).toBeLessThanOrEqual(maxWidth - 1);
93+
});
94+
});
95+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { AVG_CHAR_WIDTH, SPACE_WIDTH } from './text-scribbled.const';
2+
3+
export const seededRandom = (seed: number) => {
4+
// Let's get a random value in between -1 and 1
5+
// And let's multiply it by 10000 to get a bigger number (more precision)
6+
const x = Math.sin(seed) * 10000;
7+
8+
// Le's extract the decimal part of the number
9+
// a number in between 0 and 1
10+
return x - Math.floor(x);
11+
};
12+
13+
// We need to add some random offset to start the text at a different position
14+
// BUT we cannot use here just a random number because it will change every time
15+
// the component is re-rendered, so we need to use a deterministic way to get the offset
16+
// based on the Id of the shape
17+
// 👇 Based on the Id deterministic offset
18+
// a bit weird, maybe just a random useEffect []
19+
export const getOffsetFromId = (id: string, max: number) => {
20+
let sum = 0;
21+
for (let i = 0; i < id.length; i++) {
22+
sum += id.charCodeAt(i);
23+
}
24+
return sum % max;
25+
};
26+
27+
export const rounded = (value: number) => Math.round(value * 2) / 2;
28+
29+
export const addBlankSpaceToPath = (
30+
currentX: number,
31+
maxWidth: number,
32+
height: number
33+
) => {
34+
currentX += SPACE_WIDTH;
35+
36+
// We don't want to go out of the area, if not transformer won't work well
37+
const adjustedEndX = Math.min(currentX, maxWidth - 1);
38+
39+
return {
40+
pathSlice: `M ${adjustedEndX},${Math.trunc(height / 2)}`,
41+
newCurrentX: currentX,
42+
};
43+
};
44+
45+
export const drawCharScribble = (
46+
char: string,
47+
i: number,
48+
currentX: number,
49+
maxWidth: number,
50+
height: number
51+
) => {
52+
// Max Y variation on the scribble
53+
const amplitude = height / 3;
54+
const charWidth = AVG_CHAR_WIDTH;
55+
// Let's generate a psuedo-random number based on the char and the index
56+
const seed = char.charCodeAt(0) + i * 31;
57+
58+
const controlX1 = currentX + charWidth / 2;
59+
const controlY1 = Math.trunc(
60+
rounded(
61+
// Generate a pseudo random number between -amplitude and amplitude
62+
height / 2 + (seededRandom(seed) * amplitude - amplitude / 2)
63+
)
64+
);
65+
66+
const controlX2 = currentX + charWidth;
67+
const controlY2 = Math.trunc(
68+
rounded(height / 2 + (seededRandom(seed + 1) * amplitude - amplitude / 2))
69+
);
70+
71+
// Let's truc it to avoid edge cases with the max
72+
const endX = Math.trunc(currentX + charWidth);
73+
const endY = Math.trunc(height / 2);
74+
75+
// We don't want to go out of the area, if not transformer won't work well
76+
const adjustedEndX = Math.min(endX, maxWidth - 1);
77+
78+
return {
79+
pathSegment: `C ${controlX1},${controlY1} ${controlX2},${controlY2} ${adjustedEndX},${endY}`,
80+
endX,
81+
};
82+
};

0 commit comments

Comments
 (0)