Skip to content

Commit df3f961

Browse files
committed
test: reach full coverage and remove dead query branches
1 parent e0e26a1 commit df3f961

8 files changed

Lines changed: 309 additions & 28 deletions

File tree

src/infrastructure/serialization/scheme-query.js

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,32 +62,11 @@ function sameValue(first, second) {
6262
);
6363
}
6464

65-
if (
66-
first &&
67-
second &&
68-
typeof first === 'object' &&
69-
typeof second === 'object'
70-
) {
71-
const firstKeys = Object.keys(first);
72-
const secondKeys = Object.keys(second);
73-
74-
return (
75-
firstKeys.length === secondKeys.length &&
76-
firstKeys.every((key) => first[key] === second[key])
77-
);
78-
}
79-
8065
return first === second;
8166
}
8267

8368
function serializeRoundedNumber(value, maxDecimals) {
84-
const roundedValue = Number(value.toFixed(maxDecimals));
85-
86-
if (Object.is(roundedValue, -0)) {
87-
return '0';
88-
}
89-
90-
return String(roundedValue);
69+
return String(Number(value.toFixed(maxDecimals)));
9170
}
9271

9372
function serializeQuantizedNumber(value, { step, maxDecimals }) {
@@ -148,7 +127,7 @@ function serializeSchemeQueryValue(key, value) {
148127
serializePickerNumber(value[2]),
149128
].join(',');
150129
default:
151-
return Array.isArray(value) ? value.join(',') : String(value);
130+
return String(value);
152131
}
153132
}
154133

@@ -176,11 +155,7 @@ function applyPresetDegreesIfMissing(scheme, hasDegreesParam) {
176155
return;
177156
}
178157

179-
const presetDegrees = degreesForColorMode(scheme.colorMode, scheme.hueDistance);
180-
181-
if (presetDegrees) {
182-
scheme.degrees = presetDegrees;
183-
}
158+
scheme.degrees = degreesForColorMode(scheme.colorMode, scheme.hueDistance);
184159
}
185160

186161
function normalizeColorMode(scheme) {

tests/domain/scheme/color-mode.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('ColorMode', () => {
2828
expect(normalizeHueForColorMode(210, 'duotone')).toBe(30);
2929
expect(normalizeHueForColorMode(100, 'tricolor')).toBe(-20);
3030
expect(normalizeHueForColorMode(75, 'hexachrome')).toBe(15);
31+
expect(normalizeHueForColorMode('oops', 'duotone')).toBe(0);
3132
});
3233

3334
it('accepts aliases and validates known color modes', () => {

tests/domain/scheme/color-scheme-calculator.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,25 @@ describe('calculateSchemeColors', () => {
201201
expect(colorHex(result.foreground)).toBe(colorHex(overlayColor));
202202
});
203203

204+
it('tints only achromatic colors when the dye scope is achromatic', () => {
205+
const result = calculateSchemeColors(createScheme({
206+
dyeScope: 'achromatic',
207+
dyeColor: {
208+
hue: 180,
209+
saturation: 100,
210+
lightness: 50,
211+
alpha: 1,
212+
},
213+
}));
214+
215+
const overlayColor = Color({ h: 180, s: 100, l: 50 });
216+
217+
expect(colorHex(result.black)).toBe(colorHex(overlayColor));
218+
expect(colorHex(result.white)).toBe(colorHex(overlayColor));
219+
expect(colorHex(result.red)).toBe(colorHex(Color({ h: 345, s: 50, l: 50 })));
220+
expect(colorHex(result.cyan)).toBe(colorHex(Color({ h: 165, s: 50, l: 50 })));
221+
});
222+
204223
it('resolves legacy special color names and custom special colors', () => {
205224
const customForegroundColor = {
206225
hue: 210,
@@ -220,4 +239,31 @@ describe('calculateSchemeColors', () => {
220239
l: customForegroundColor.lightness,
221240
})));
222241
});
242+
243+
it('falls back to black and white when custom or unknown special colors cannot be resolved', () => {
244+
const result = calculateSchemeColors(createScheme({
245+
dyeColor: null,
246+
background: 'custom',
247+
customBackgroundColor: null,
248+
foreground: 'unknown',
249+
customForegroundColor: null,
250+
}));
251+
252+
expect(colorHex(result.background)).toBe(colorHex(result.black));
253+
expect(colorHex(result.foreground)).toBe(colorHex(result.white));
254+
});
255+
256+
it('treats invalid saturation and lightness ranges as zero adjustments', () => {
257+
const baseline = calculateSchemeColors(createScheme({
258+
saturationRange: 0,
259+
lightnessRange: 0,
260+
}));
261+
const invalidRanges = calculateSchemeColors(createScheme({
262+
saturationRange: 'oops',
263+
lightnessRange: Number.POSITIVE_INFINITY,
264+
}));
265+
266+
expect(colorHex(invalidRanges.yellow)).toBe(colorHex(baseline.yellow));
267+
expect(colorHex(invalidRanges.brightBlue)).toBe(colorHex(baseline.brightBlue));
268+
});
223269
});

tests/domain/scheme/scheme-state.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ describe('scheme-state', () => {
4343
expect(normalizeSchemeRanges(undefined)).toBeUndefined();
4444
});
4545

46+
it('defaults hue distance while normalizing ranges when it is missing', () => {
47+
const scheme = createDefaultScheme();
48+
49+
scheme.hueDistance = undefined;
50+
51+
normalizeSchemeRanges(scheme);
52+
53+
expect(scheme.hueDistance).toBe(DEFAULT_HUE_DISTANCE);
54+
});
55+
4656
it('applies preset color mode invariants to the scheme', () => {
4757
const scheme = createDefaultScheme();
4858

@@ -69,6 +79,11 @@ describe('scheme-state', () => {
6979
expect(scheme.degrees).toEqual(original.degrees);
7080
});
7181

82+
it('returns false when applying a color mode to a nullish scheme', () => {
83+
expect(applyColorModeToScheme(null, 'duotone')).toBe(false);
84+
expect(applyColorModeToScheme(undefined, 'duotone')).toBe(false);
85+
});
86+
7287
it('updates hue distance and recalculates preset degrees', () => {
7388
const scheme = createDefaultScheme();
7489

tests/infrastructure/browser/scheme-url-sync.test.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ describe('SchemeUrlSync', () => {
8282
expect(resolveInitialSchemeSearch('?utm_source=readme', null)).toBe('?utm_source=readme');
8383
});
8484

85+
it('normalizes current search strings that omit the leading question mark', () => {
86+
expect(resolveInitialSchemeSearch('utm_source=readme', null)).toBe('?utm_source=readme');
87+
});
88+
89+
it('ignores persisted search read errors and keeps the current URL search', () => {
90+
const storage = {
91+
getItem: vi.fn(() => {
92+
throw new Error('storage unavailable');
93+
}),
94+
};
95+
96+
expect(resolveInitialSchemeSearch('?utm_source=readme', storage)).toBe('?utm_source=readme');
97+
});
98+
8599
it('falls back to persisted scheme params and preserves unrelated URL params', () => {
86100
const storage = {
87101
getItem: vi.fn(() => '?hue=45&dyeScope=all'),
@@ -91,6 +105,14 @@ describe('SchemeUrlSync', () => {
91105
.toBe('?utm_source=readme&hue=45&dyeScope=all');
92106
});
93107

108+
it('ignores persisted search values that do not contain any actual params', () => {
109+
const storage = {
110+
getItem: vi.fn(() => '?'),
111+
};
112+
113+
expect(resolveInitialSchemeSearch('', storage)).toBe('');
114+
});
115+
94116
it('hydrates the store from persisted search when the URL has no scheme params', () => {
95117
const pinia = createPinia();
96118
const storage = {
@@ -122,6 +144,76 @@ describe('SchemeUrlSync', () => {
122144
);
123145
});
124146

147+
it('hydrates from the string overload using browser fallbacks in non-browser tests', () => {
148+
const pinia = createPinia();
149+
150+
const schemeStore = hydrateSchemeStoreFromLocation(pinia, '?hue=12&dyeScope=all');
151+
152+
expect(schemeStore.scheme.hue).toBe(12);
153+
expect(schemeStore.scheme.dyeScope).toBe('all');
154+
});
155+
156+
it('hydrates from global browser objects when options are omitted', () => {
157+
const pinia = createPinia();
158+
const originalWindow = globalThis.window;
159+
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
160+
const history = {
161+
state: { from: 'browser' },
162+
replaceState: vi.fn(),
163+
};
164+
165+
globalThis.window = {
166+
location: {
167+
pathname: '/4bit/',
168+
search: '',
169+
hash: '#preview',
170+
},
171+
history,
172+
localStorage: {
173+
getItem: vi.fn(() => '?hue=33'),
174+
},
175+
};
176+
177+
try {
178+
const schemeStore = hydrateSchemeStoreFromLocation(pinia);
179+
180+
expect(schemeStore.scheme.hue).toBe(33);
181+
expect(history.replaceState).toHaveBeenCalledWith(
182+
history.state,
183+
'',
184+
'/4bit/?hue=33#preview'
185+
);
186+
} finally {
187+
if (hadWindow) {
188+
globalThis.window = originalWindow;
189+
} else {
190+
delete globalThis.window;
191+
}
192+
}
193+
});
194+
195+
it('does not replace history during hydration when the URL already matches', () => {
196+
const pinia = createPinia();
197+
const history = {
198+
state: { from: 'test' },
199+
replaceState: vi.fn(),
200+
};
201+
const location = {
202+
pathname: '/4bit/',
203+
search: '?hue=12',
204+
hash: '#preview',
205+
};
206+
207+
hydrateSchemeStoreFromLocation(pinia, {
208+
search: '?hue=12',
209+
storage: null,
210+
location,
211+
history,
212+
});
213+
214+
expect(history.replaceState).not.toHaveBeenCalled();
215+
});
216+
125217
it('persists the current scheme search for future visits', () => {
126218
const scheme = createDefaultScheme();
127219
scheme.hue = 10;
@@ -180,4 +272,47 @@ describe('SchemeUrlSync', () => {
180272
'/4bit/?hue=10'
181273
);
182274
});
275+
276+
it('does not replace history when the current URL already matches the scheme', () => {
277+
const scheme = createDefaultScheme();
278+
scheme.hue = 10;
279+
const history = {
280+
state: null,
281+
replaceState: vi.fn(),
282+
};
283+
284+
new SchemeUrlSync({
285+
schemeStore: { scheme },
286+
location: {
287+
pathname: '/4bit/',
288+
search: '?hue=10',
289+
hash: '',
290+
},
291+
history,
292+
storage: null,
293+
}).updateLocation(scheme);
294+
295+
expect(history.replaceState).not.toHaveBeenCalled();
296+
});
297+
298+
it('keeps the URL empty for the default scheme when no extra params are present', () => {
299+
const scheme = createDefaultScheme();
300+
const history = {
301+
state: null,
302+
replaceState: vi.fn(),
303+
};
304+
305+
new SchemeUrlSync({
306+
schemeStore: { scheme },
307+
location: {
308+
pathname: '/4bit/',
309+
search: '',
310+
hash: '',
311+
},
312+
history,
313+
storage: null,
314+
}).updateLocation(scheme);
315+
316+
expect(history.replaceState).not.toHaveBeenCalled();
317+
});
183318
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
normalColorName,
4+
paletteColorNames,
5+
} from '../../../src/infrastructure/serialization/scheme-exports/shared';
6+
7+
describe('scheme-exports/shared', () => {
8+
it('keeps standard color names unchanged', () => {
9+
expect(normalColorName('red')).toBe('red');
10+
expect(normalColorName('brightBlue')).toBe('blue');
11+
});
12+
13+
it('builds a palette with standard colors before bright colors', () => {
14+
expect(paletteColorNames().slice(0, 4)).toEqual(['black', 'red', 'green', 'yellow']);
15+
expect(paletteColorNames().slice(-4)).toEqual([
16+
'brightBlue',
17+
'brightMagenta',
18+
'brightCyan',
19+
'brightWhite',
20+
]);
21+
});
22+
});

tests/infrastructure/serialization/scheme-query.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,31 @@ describe('scheme-query', () => {
121121
expect(readSchemeFromSearch(`?${params.toString()}`)).toEqual(scheme);
122122
});
123123

124+
it('serializes negative near-zero picker values as 0 instead of -0', () => {
125+
const scheme = createDefaultScheme();
126+
127+
scheme.dyeColor = {
128+
hue: 180,
129+
saturation: -0.0000000001,
130+
lightness: 50,
131+
alpha: 0.25,
132+
};
133+
134+
const params = new URLSearchParams(buildSchemeSearch(scheme).slice(1));
135+
136+
expect(params.get('dyeColor')).toBe('180,0,50,0.25');
137+
});
138+
139+
it('serializes non-finite values verbatim instead of crashing quantization', () => {
140+
const scheme = createDefaultScheme();
141+
142+
scheme.hue = Number.POSITIVE_INFINITY;
143+
144+
const params = new URLSearchParams(buildSchemeSearch(scheme).slice(1));
145+
146+
expect(params.get('hue')).toBe('Infinity');
147+
});
148+
124149
it('keeps comma-separated numeric lists readable in the generated URL', () => {
125150
const scheme = createDefaultScheme();
126151

@@ -136,6 +161,19 @@ describe('scheme-query', () => {
136161
expect(search).not.toContain('%2C');
137162
});
138163

164+
it('keeps explicit degrees when the declared color mode no longer matches a preset', () => {
165+
const scheme = createDefaultScheme();
166+
167+
scheme.colorMode = 'duotone';
168+
scheme.hueDistance = 18;
169+
scheme.degrees = [0, 1, 2, 3, 4, 5];
170+
171+
const params = new URLSearchParams(buildSchemeSearch(scheme).slice(1));
172+
173+
expect(params.get('colorMode')).toBeNull();
174+
expect(params.get('degrees')).toBe('0,1,2,3,4,5');
175+
});
176+
139177
it('ignores malformed values and falls back to defaults for those fields', () => {
140178
const scheme = readSchemeFromSearch(
141179
'?hue=oops&degrees=1,2,3&dyeColor=1,2,3&foreground=invalid&customForegroundColor=4,5'

0 commit comments

Comments
 (0)