refactor(AdjustTypography): add typography-control module (factory, types, constants)
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './model';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
import type {
|
||||
ControlId,
|
||||
ControlModel,
|
||||
} from '../types/typography';
|
||||
|
||||
/**
|
||||
* Responsive font-size scaling factors applied by typographySettingsStore.
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
/**
|
||||
* Default control definitions seeding the typography settings store.
|
||||
* Composed from the font-render ranges/defaults owned by the Font entity.
|
||||
*/
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,8 @@
|
||||
export {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
type TypographySettingsStore,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
ControlLabels,
|
||||
NumericControl,
|
||||
} from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Identifiers for the adjustable typography axes
|
||||
*/
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
/**
|
||||
* Static configuration for one typography control.
|
||||
*
|
||||
* Derived from the SSOT contract types — declares no fields of its own beyond
|
||||
* the domain `id`. Bounds come from NumericControl, labels from ControlLabels.
|
||||
*
|
||||
* @template T - Control identifier type
|
||||
*/
|
||||
export type ControlModel<T extends string = string> =
|
||||
& Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>
|
||||
& ControlLabels
|
||||
& {
|
||||
/**
|
||||
* Unique identifier for the control
|
||||
*/
|
||||
id: T;
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Bounded numeric control for typography settings.
|
||||
*
|
||||
* Produces a reactive control that clamps to [min, max] and rounds to step.
|
||||
* Implements the NumericControl contract that ComboControl renders.
|
||||
*/
|
||||
import {
|
||||
clampNumber,
|
||||
roundToStepPrecision,
|
||||
} from '$shared/lib/utils';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Bounds + initial value seed for a control
|
||||
*/
|
||||
type ControlSeed = Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>;
|
||||
|
||||
/**
|
||||
* Create a reactive bounded numeric control.
|
||||
*
|
||||
* @param initialState - Initial value and bounds
|
||||
* @returns A NumericControl whose value is always clamped and step-rounded
|
||||
*/
|
||||
export function createTypographyControl(initialState: ControlSeed): NumericControl {
|
||||
let value = $state(initialState.value);
|
||||
let max = $state(initialState.max);
|
||||
let min = $state(initialState.min);
|
||||
let step = $state(initialState.step);
|
||||
|
||||
const { isAtMax, isAtMin } = $derived({
|
||||
isAtMax: value >= max,
|
||||
isAtMin: value <= min,
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(newValue) {
|
||||
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
|
||||
if (value !== rounded) {
|
||||
value = rounded;
|
||||
}
|
||||
},
|
||||
get max() {
|
||||
return max;
|
||||
},
|
||||
get min() {
|
||||
return min;
|
||||
},
|
||||
get step() {
|
||||
return step;
|
||||
},
|
||||
get isAtMax() {
|
||||
return isAtMax;
|
||||
},
|
||||
get isAtMin() {
|
||||
return isAtMin;
|
||||
},
|
||||
increase() {
|
||||
value = roundToStepPrecision(clampNumber(value + step, min, max), step);
|
||||
},
|
||||
decrease() {
|
||||
value = roundToStepPrecision(clampNumber(value - step, min, max), step);
|
||||
},
|
||||
};
|
||||
}
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createTypographyControl } from './createTypographyControl.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for createTypographyControl Helper
|
||||
*
|
||||
* This test suite validates the TypographyControl state management logic.
|
||||
* These are unit tests for the pure control logic, separate from component rendering.
|
||||
*
|
||||
* Test Coverage:
|
||||
* 1. Control Initialization: Creating controls with various configurations
|
||||
* 2. Value Setting: Direct assignment with clamping and precision
|
||||
* 3. Increase Method: Incrementing value with bounds checking
|
||||
* 4. Decrease Method: Decrementing value with bounds checking
|
||||
* 5. Derived State: isAtMax and isAtMin reactive properties
|
||||
* 6. Combined Operations: Multiple method calls and value changes
|
||||
* 7. Edge Cases: Boundary conditions and special values
|
||||
* 8. Type Safety: Interface compliance and immutability
|
||||
* 9. Use Case Scenarios: Real-world typography control examples
|
||||
*/
|
||||
|
||||
describe('createTypographyControl - Unit Tests', () => {
|
||||
/**
|
||||
* Helper function to create a TypographyControl for testing
|
||||
*/
|
||||
function createMockControl(initialValue: number, options?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}): NumericControl {
|
||||
return createTypographyControl({
|
||||
value: initialValue,
|
||||
min: options?.min ?? 0,
|
||||
max: options?.max ?? 100,
|
||||
step: options?.step ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Control Initialization', () => {
|
||||
it('creates control with default values', () => {
|
||||
const control = createTypographyControl({
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
});
|
||||
|
||||
expect(control.value).toBe(50);
|
||||
expect(control.min).toBe(0);
|
||||
expect(control.max).toBe(100);
|
||||
expect(control.step).toBe(1);
|
||||
});
|
||||
|
||||
it('creates control with custom min/max/step', () => {
|
||||
const control = createTypographyControl({
|
||||
value: 5,
|
||||
min: -10,
|
||||
max: 20,
|
||||
step: 0.5,
|
||||
});
|
||||
|
||||
expect(control.value).toBe(5);
|
||||
expect(control.min).toBe(-10);
|
||||
expect(control.max).toBe(20);
|
||||
expect(control.step).toBe(0.5);
|
||||
});
|
||||
|
||||
// NOTE: Derived state initialization tests removed because
|
||||
// Svelte 5's $derived runes require a reactivity context which
|
||||
// is not available in Node.js unit tests. These behaviors
|
||||
// should be tested in E2E tests with Playwright.
|
||||
});
|
||||
|
||||
describe('Value Setting', () => {
|
||||
it('updates value when set to valid number', () => {
|
||||
const control = createMockControl(50);
|
||||
control.value = 75;
|
||||
expect(control.value).toBe(75);
|
||||
});
|
||||
|
||||
it('clamps value below min when set', () => {
|
||||
const control = createMockControl(50, { min: 0, max: 100 });
|
||||
control.value = -10;
|
||||
expect(control.value).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps value above max when set', () => {
|
||||
const control = createMockControl(50, { min: 0, max: 100 });
|
||||
control.value = 150;
|
||||
expect(control.value).toBe(100);
|
||||
});
|
||||
|
||||
it('rounds to step precision when set', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 0.25 });
|
||||
control.value = 5.13;
|
||||
// roundToStepPrecision fixes floating point issues by rounding to step's decimal places
|
||||
// 5.13 with step 0.25 (2 decimals) → 5.13
|
||||
expect(control.value).toBeCloseTo(5.13);
|
||||
});
|
||||
|
||||
it('handles step of 0.01 precision', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 0.01 });
|
||||
control.value = 5.1234;
|
||||
expect(control.value).toBeCloseTo(5.12);
|
||||
});
|
||||
|
||||
it('handles step of 0.5 precision', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 0.5 });
|
||||
control.value = 5.3;
|
||||
// 5.3 with step 0.5 (1 decimal) → 5.3 (already correct precision)
|
||||
expect(control.value).toBeCloseTo(5.3);
|
||||
});
|
||||
|
||||
it('handles integer step', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
|
||||
control.value = 5.7;
|
||||
expect(control.value).toBe(6);
|
||||
});
|
||||
|
||||
it('handles negative range', () => {
|
||||
const control = createMockControl(-5, { min: -10, max: 10 });
|
||||
control.value = -15;
|
||||
expect(control.value).toBe(-10); // Clamped to min
|
||||
|
||||
control.value = 15;
|
||||
expect(control.value).toBe(10); // Clamped to max
|
||||
});
|
||||
});
|
||||
|
||||
describe('Increase Method', () => {
|
||||
it('increases value by step', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
|
||||
control.increase();
|
||||
expect(control.value).toBe(6);
|
||||
});
|
||||
|
||||
it('respects max bound when increasing', () => {
|
||||
const control = createMockControl(9.5, { min: 0, max: 10, step: 1 });
|
||||
control.increase();
|
||||
expect(control.value).toBe(10);
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(10); // Still at max
|
||||
});
|
||||
|
||||
it('respects step precision when increasing', () => {
|
||||
const control = createMockControl(5.25, { min: 0, max: 10, step: 0.25 });
|
||||
control.increase();
|
||||
expect(control.value).toBe(5.5);
|
||||
});
|
||||
|
||||
// NOTE: Derived state (isAtMax, isAtMin) tests removed because
|
||||
// Svelte 5's $derived runes require a reactivity context which
|
||||
// is not available in Node.js unit tests. These behaviors
|
||||
// should be tested in E2E tests with Playwright.
|
||||
});
|
||||
|
||||
describe('Decrease Method', () => {
|
||||
it('decreases value by step', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
|
||||
control.decrease();
|
||||
expect(control.value).toBe(4);
|
||||
});
|
||||
|
||||
it('respects min bound when decreasing', () => {
|
||||
const control = createMockControl(0.5, { min: 0, max: 10, step: 1 });
|
||||
control.decrease();
|
||||
expect(control.value).toBe(0);
|
||||
|
||||
control.decrease();
|
||||
expect(control.value).toBe(0); // Still at min
|
||||
});
|
||||
|
||||
it('respects step precision when decreasing', () => {
|
||||
const control = createMockControl(5.5, { min: 0, max: 10, step: 0.25 });
|
||||
control.decrease();
|
||||
expect(control.value).toBe(5.25);
|
||||
});
|
||||
|
||||
// NOTE: Derived state (isAtMax, isAtMin) tests removed because
|
||||
// Svelte 5's $derived runes require a reactivity context which
|
||||
// is not available in Node.js unit tests. These behaviors
|
||||
// should be tested in E2E tests with Playwright.
|
||||
});
|
||||
|
||||
// NOTE: Derived State Reactivity tests removed because
|
||||
// Svelte 5's $derived runes require a reactivity context which
|
||||
// is not available in Node.js unit tests. These behaviors
|
||||
// should be tested in E2E tests with Playwright.
|
||||
|
||||
describe('Combined Operations', () => {
|
||||
it('handles multiple increase/decrease operations', () => {
|
||||
const control = createMockControl(50, { min: 0, max: 100, step: 5 });
|
||||
|
||||
control.increase();
|
||||
control.increase();
|
||||
control.increase();
|
||||
expect(control.value).toBe(65);
|
||||
|
||||
control.decrease();
|
||||
control.decrease();
|
||||
expect(control.value).toBe(55);
|
||||
});
|
||||
|
||||
it('handles value setting followed by method calls', () => {
|
||||
const control = createMockControl(50, { min: 0, max: 100, step: 1 });
|
||||
|
||||
control.value = 90;
|
||||
expect(control.value).toBe(90);
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(91);
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(92);
|
||||
|
||||
control.decrease();
|
||||
expect(control.value).toBe(91);
|
||||
});
|
||||
|
||||
it('handles rapid value changes', () => {
|
||||
const control = createMockControl(50, { min: 0, max: 100, step: 0.1 });
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
control.increase();
|
||||
}
|
||||
expect(control.value).toBe(60);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
control.decrease();
|
||||
}
|
||||
expect(control.value).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles step larger than range', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 20 });
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(10); // Clamped to max
|
||||
|
||||
control.decrease();
|
||||
expect(control.value).toBe(0); // Clamped to min
|
||||
});
|
||||
|
||||
it('handles very small step values', () => {
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 0.001 });
|
||||
|
||||
control.value = 5.0005;
|
||||
expect(control.value).toBeCloseTo(5.001);
|
||||
});
|
||||
|
||||
it('handles floating point precision issues', () => {
|
||||
const control = createMockControl(0.1, { min: 0, max: 1, step: 0.1 });
|
||||
|
||||
control.value = 0.3;
|
||||
expect(control.value).toBeCloseTo(0.3);
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
it('handles zero as valid value', () => {
|
||||
const control = createMockControl(0, { min: 0, max: 100 });
|
||||
|
||||
expect(control.value).toBe(0);
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(1);
|
||||
});
|
||||
|
||||
it('handles negative step values effectively', () => {
|
||||
// Step is always positive in the interface, but we test the logic
|
||||
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
|
||||
|
||||
// Even with negative value initially, it should work
|
||||
expect(control.min).toBe(0);
|
||||
expect(control.max).toBe(10);
|
||||
});
|
||||
|
||||
it('handles equal min and max', () => {
|
||||
const control = createMockControl(5, { min: 5, max: 5, step: 1 });
|
||||
|
||||
expect(control.value).toBe(5);
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(5);
|
||||
|
||||
control.decrease();
|
||||
expect(control.value).toBe(5);
|
||||
});
|
||||
|
||||
it('handles very large values', () => {
|
||||
const control = createMockControl(1000, { min: 0, max: 10000, step: 100 });
|
||||
|
||||
control.value = 5500;
|
||||
expect(control.value).toBe(5500); // 5500 is already on step of 100
|
||||
|
||||
control.increase();
|
||||
expect(control.value).toBe(5600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety and Interface', () => {
|
||||
it('exposes correct TypographyControl interface', () => {
|
||||
const control = createMockControl(50);
|
||||
|
||||
expect(control).toHaveProperty('value');
|
||||
expect(typeof control.value).toBe('number');
|
||||
expect(control).toHaveProperty('min');
|
||||
expect(typeof control.min).toBe('number');
|
||||
expect(control).toHaveProperty('max');
|
||||
expect(typeof control.max).toBe('number');
|
||||
expect(control).toHaveProperty('step');
|
||||
expect(typeof control.step).toBe('number');
|
||||
expect(control).toHaveProperty('isAtMax');
|
||||
expect(typeof control.isAtMax).toBe('boolean');
|
||||
expect(control).toHaveProperty('isAtMin');
|
||||
expect(typeof control.isAtMin).toBe('boolean');
|
||||
expect(typeof control.increase).toBe('function');
|
||||
expect(typeof control.decrease).toBe('function');
|
||||
});
|
||||
|
||||
it('maintains immutability of min/max/step', () => {
|
||||
const control = createMockControl(50, { min: 0, max: 100, step: 1 });
|
||||
|
||||
// These should be read-only
|
||||
const originalMin = control.min;
|
||||
const originalMax = control.max;
|
||||
const originalStep = control.step;
|
||||
|
||||
// TypeScript should prevent assignment, but test runtime behavior
|
||||
expect(control.min).toBe(originalMin);
|
||||
expect(control.max).toBe(originalMax);
|
||||
expect(control.step).toBe(originalStep);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Case Scenarios', () => {
|
||||
it('typical font size control (12px to 72px, step 1px)', () => {
|
||||
const control = createMockControl(16, { min: 12, max: 72, step: 1 });
|
||||
|
||||
expect(control.value).toBe(16);
|
||||
|
||||
// Increase to 18
|
||||
control.increase();
|
||||
control.increase();
|
||||
expect(control.value).toBe(18);
|
||||
|
||||
// Set to 24
|
||||
control.value = 24;
|
||||
expect(control.value).toBe(24);
|
||||
|
||||
// Try to go below min
|
||||
control.value = 10;
|
||||
expect(control.value).toBe(12); // Clamped to 12
|
||||
|
||||
// Try to go above max
|
||||
control.value = 80;
|
||||
expect(control.value).toBe(72); // Clamped to 72
|
||||
});
|
||||
|
||||
it('typical letter spacing control (-0.1em to 0.5em, step 0.01em)', () => {
|
||||
const control = createMockControl(0, { min: -0.1, max: 0.5, step: 0.01 });
|
||||
|
||||
expect(control.value).toBe(0);
|
||||
|
||||
// Increase to 0.02
|
||||
control.increase();
|
||||
control.increase();
|
||||
expect(control.value).toBeCloseTo(0.02);
|
||||
|
||||
// Set to negative value
|
||||
control.value = -0.05;
|
||||
expect(control.value).toBeCloseTo(-0.05);
|
||||
|
||||
// Precision rounding
|
||||
control.value = 0.1234;
|
||||
expect(control.value).toBeCloseTo(0.12);
|
||||
});
|
||||
|
||||
it('typical line height control (0.8 to 2.0, step 0.1)', () => {
|
||||
const control = createMockControl(1.5, { min: 0.8, max: 2.0, step: 0.1 });
|
||||
|
||||
expect(control.value).toBe(1.5);
|
||||
|
||||
// Decrease to 1.3
|
||||
control.decrease();
|
||||
control.decrease();
|
||||
expect(control.value).toBeCloseTo(1.3);
|
||||
|
||||
// Set to specific value
|
||||
control.value = 1.65;
|
||||
// 1.65 with step 0.1 → rounds to 1 decimal place → 1.6 (banker's rounding)
|
||||
expect(control.value).toBeCloseTo(1.6);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user