@bendyline/squisq 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Types-sh2VRxfo.d.ts +15 -0
- package/dist/chunk-7UDSRZKG.js +103 -0
- package/dist/chunk-7UDSRZKG.js.map +1 -0
- package/dist/chunk-O5HCIUAD.js +577 -0
- package/dist/chunk-O5HCIUAD.js.map +1 -0
- package/dist/chunk-PJ7AODIM.js +3040 -0
- package/dist/chunk-PJ7AODIM.js.map +1 -0
- package/dist/chunk-QWVRN6I4.js +81 -0
- package/dist/chunk-QWVRN6I4.js.map +1 -0
- package/dist/chunk-S4F2RY37.js +92 -0
- package/dist/chunk-S4F2RY37.js.map +1 -0
- package/dist/chunk-URU6QMLY.js +888 -0
- package/dist/chunk-URU6QMLY.js.map +1 -0
- package/dist/chunk-VJN7UB2Z.js +145 -0
- package/dist/chunk-VJN7UB2Z.js.map +1 -0
- package/dist/doc/index.d.ts +3 -0
- package/dist/doc/index.js +126 -0
- package/dist/doc/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown/index.d.ts +237 -0
- package/dist/markdown/index.js +33 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/schemas/index.d.ts +70 -0
- package/dist/schemas/index.js +61 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/spatial/index.d.ts +111 -0
- package/dist/spatial/index.js +25 -0
- package/dist/spatial/index.js.map +1 -0
- package/dist/storage/index.d.ts +91 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/story/index.d.ts +691 -0
- package/dist/story/index.js +126 -0
- package/dist/story/index.js.map +1 -0
- package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
- package/dist/types-DHiv_Pnm.d.ts +496 -0
- package/package.json +91 -0
- package/src/__tests__/animationUtils.test.ts +77 -0
- package/src/__tests__/geohash.test.ts +109 -0
- package/src/__tests__/getLayers.test.ts +200 -0
- package/src/__tests__/haversine.test.ts +63 -0
- package/src/__tests__/localForageAdapter.test.ts +155 -0
- package/src/__tests__/markdown.test.ts +806 -0
- package/src/__tests__/markdownToDoc.test.ts +374 -0
- package/src/__tests__/storage.test.ts +60 -0
- package/src/__tests__/templates.test.ts +247 -0
- package/src/doc/docToMarkdown.ts +98 -0
- package/src/doc/getLayers.ts +152 -0
- package/src/doc/index.ts +8 -0
- package/src/doc/markdownToDoc.ts +277 -0
- package/src/doc/templates/accentImage.ts +338 -0
- package/src/doc/templates/captionUtils.ts +33 -0
- package/src/doc/templates/comparisonBar.ts +140 -0
- package/src/doc/templates/coverBlock.ts +156 -0
- package/src/doc/templates/dateEvent.ts +122 -0
- package/src/doc/templates/definitionCard.ts +129 -0
- package/src/doc/templates/factCard.ts +114 -0
- package/src/doc/templates/fullBleedQuote.ts +59 -0
- package/src/doc/templates/imageWithCaption.ts +167 -0
- package/src/doc/templates/index.ts +571 -0
- package/src/doc/templates/listBlock.ts +101 -0
- package/src/doc/templates/mapBlock.ts +132 -0
- package/src/doc/templates/persistentLayers.ts +522 -0
- package/src/doc/templates/photoGrid.ts +199 -0
- package/src/doc/templates/pullQuote.ts +119 -0
- package/src/doc/templates/quoteBlock.ts +112 -0
- package/src/doc/templates/sectionHeader.ts +105 -0
- package/src/doc/templates/statHighlight.ts +113 -0
- package/src/doc/templates/titleBlock.ts +100 -0
- package/src/doc/templates/twoColumn.ts +239 -0
- package/src/doc/templates/videoPullQuote.ts +128 -0
- package/src/doc/templates/videoWithCaption.ts +126 -0
- package/src/doc/utils/animationUtils.ts +135 -0
- package/src/doc/utils/themeUtils.ts +139 -0
- package/src/index.ts +5 -0
- package/src/markdown/convert.ts +897 -0
- package/src/markdown/htmlParse.ts +212 -0
- package/src/markdown/index.ts +110 -0
- package/src/markdown/parse.ts +103 -0
- package/src/markdown/stringify.ts +122 -0
- package/src/markdown/types.ts +605 -0
- package/src/markdown/utils.ts +165 -0
- package/src/ngeohash.d.ts +11 -0
- package/src/schemas/BlockTemplates.ts +706 -0
- package/src/schemas/Doc.ts +565 -0
- package/src/schemas/LayoutStrategy.ts +178 -0
- package/src/schemas/MediaProvider.ts +70 -0
- package/src/schemas/Theme.ts +235 -0
- package/src/schemas/Types.ts +15 -0
- package/src/schemas/Viewport.ts +91 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/themeLibrary.ts +492 -0
- package/src/spatial/Geohash.ts +168 -0
- package/src/spatial/Haversine.ts +59 -0
- package/src/spatial/index.ts +2 -0
- package/src/storage/LocalForageAdapter.ts +103 -0
- package/src/storage/LocalStorageAdapter.ts +57 -0
- package/src/storage/MemoryStorageAdapter.ts +37 -0
- package/src/storage/Storage.ts +28 -0
- package/src/storage/index.ts +5 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
encodeGeohash,
|
|
4
|
+
decodeGeohash,
|
|
5
|
+
getNeighbors,
|
|
6
|
+
getGeohash4Neighbors,
|
|
7
|
+
getGeohashPrefix,
|
|
8
|
+
geohashToHierarchicalPath,
|
|
9
|
+
getGeohashPath,
|
|
10
|
+
geohashOverlapsBounds,
|
|
11
|
+
} from '../spatial/Geohash';
|
|
12
|
+
|
|
13
|
+
describe('encodeGeohash / decodeGeohash round-trip', () => {
|
|
14
|
+
it('encodes and decodes Seattle', () => {
|
|
15
|
+
const lat = 47.6062;
|
|
16
|
+
const lng = -122.3321;
|
|
17
|
+
const hash = encodeGeohash(lat, lng, 6);
|
|
18
|
+
expect(hash).toHaveLength(6);
|
|
19
|
+
|
|
20
|
+
const decoded = decodeGeohash(hash);
|
|
21
|
+
expect(decoded.lat).toBeCloseTo(lat, 1);
|
|
22
|
+
expect(decoded.lng).toBeCloseTo(lng, 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('encodes and decodes equator/prime meridian', () => {
|
|
26
|
+
const hash = encodeGeohash(0, 0, 4);
|
|
27
|
+
expect(hash).toHaveLength(4);
|
|
28
|
+
const decoded = decodeGeohash(hash);
|
|
29
|
+
expect(decoded.lat).toBeCloseTo(0, 0);
|
|
30
|
+
expect(decoded.lng).toBeCloseTo(0, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('defaults to precision 9', () => {
|
|
34
|
+
const hash = encodeGeohash(47.6, -122.3);
|
|
35
|
+
expect(hash).toHaveLength(9);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('getNeighbors', () => {
|
|
40
|
+
it('returns 8 neighbors', () => {
|
|
41
|
+
const neighbors = getNeighbors('c23n');
|
|
42
|
+
expect(neighbors).toHaveLength(8);
|
|
43
|
+
neighbors.forEach((n) => expect(n).toHaveLength(4));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('getGeohash4Neighbors', () => {
|
|
48
|
+
it('returns center + 8 neighbors (9 total)', () => {
|
|
49
|
+
const result = getGeohash4Neighbors('c23n');
|
|
50
|
+
expect(result).toHaveLength(9);
|
|
51
|
+
expect(result[0]).toBe('c23n');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws for non-4-char input', () => {
|
|
55
|
+
expect(() => getGeohash4Neighbors('c2')).toThrow();
|
|
56
|
+
expect(() => getGeohash4Neighbors('c23nn')).toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('getGeohashPrefix', () => {
|
|
61
|
+
it('extracts prefix', () => {
|
|
62
|
+
expect(getGeohashPrefix('c23nxy', 4)).toBe('c23n');
|
|
63
|
+
expect(getGeohashPrefix('c23nxy', 2)).toBe('c2');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('geohashToHierarchicalPath', () => {
|
|
68
|
+
it('converts 4-char geohash to path', () => {
|
|
69
|
+
expect(geohashToHierarchicalPath('c23n')).toBe('c/2/3/n');
|
|
70
|
+
expect(geohashToHierarchicalPath('9q8y')).toBe('9/q/8/y');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws for non-4-char input', () => {
|
|
74
|
+
expect(() => geohashToHierarchicalPath('c2')).toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('getGeohashPath', () => {
|
|
79
|
+
it('returns empty for same cell', () => {
|
|
80
|
+
expect(getGeohashPath('c23n', 'c23n')).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns intermediate cells between two geohashes', () => {
|
|
84
|
+
const path = getGeohashPath('c23n', 'c24k');
|
|
85
|
+
expect(path.length).toBeGreaterThan(0);
|
|
86
|
+
path.forEach((h) => {
|
|
87
|
+
expect(h).toHaveLength(4);
|
|
88
|
+
expect(h).not.toBe('c23n');
|
|
89
|
+
expect(h).not.toBe('c24k');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('geohashOverlapsBounds', () => {
|
|
95
|
+
it('detects overlap', () => {
|
|
96
|
+
// Seattle area: c23n
|
|
97
|
+
const bounds = { north: 48, south: 47, east: -122, west: -123 };
|
|
98
|
+
const hash = encodeGeohash(47.6, -122.3, 4);
|
|
99
|
+
expect(geohashOverlapsBounds(hash, bounds)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('detects non-overlap', () => {
|
|
103
|
+
// Tokyo area bounds
|
|
104
|
+
const bounds = { north: 36, south: 35, east: 140, west: 139 };
|
|
105
|
+
// Seattle geohash
|
|
106
|
+
const hash = encodeGeohash(47.6, -122.3, 4);
|
|
107
|
+
expect(geohashOverlapsBounds(hash, bounds)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { getLayers, RenderContext } from '../doc/getLayers.js';
|
|
3
|
+
import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
|
|
4
|
+
import { TemplateBlock, PersistentLayerConfig } from '../schemas/BlockTemplates.js';
|
|
5
|
+
import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
|
|
6
|
+
import type { Block, Layer, TextLayer, ShapeLayer } from '../schemas/Doc.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeTemplateBlock(overrides: Partial<TemplateBlock> = {}): TemplateBlock {
|
|
13
|
+
return {
|
|
14
|
+
template: 'titleBlock',
|
|
15
|
+
id: 'test-title-1',
|
|
16
|
+
duration: 5,
|
|
17
|
+
audioSegment: 0,
|
|
18
|
+
title: 'Hello World',
|
|
19
|
+
...overrides,
|
|
20
|
+
} as TemplateBlock;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeRawBlock(layers: Layer[]): Block {
|
|
24
|
+
return {
|
|
25
|
+
id: 'raw-block-1',
|
|
26
|
+
duration: 5,
|
|
27
|
+
startTime: 0,
|
|
28
|
+
audioSegment: 0,
|
|
29
|
+
layers,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defaultContext: RenderContext = {
|
|
34
|
+
theme: DEFAULT_THEME,
|
|
35
|
+
viewport: VIEWPORT_PRESETS.landscape,
|
|
36
|
+
blockIndex: 0,
|
|
37
|
+
totalBlocks: 5,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Tests
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('getLayers', () => {
|
|
45
|
+
it('generates layers for a known template block', () => {
|
|
46
|
+
const block = makeTemplateBlock({ template: 'titleBlock', title: 'Test Title' });
|
|
47
|
+
const layers = getLayers(block, defaultContext);
|
|
48
|
+
|
|
49
|
+
expect(Array.isArray(layers)).toBe(true);
|
|
50
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
51
|
+
|
|
52
|
+
// titleBlock always produces at least a text layer with the title
|
|
53
|
+
const textLayer = layers.find((l) => l.type === 'text');
|
|
54
|
+
expect(textLayer).toBeDefined();
|
|
55
|
+
expect((textLayer as TextLayer).content.text).toContain('Test Title');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('generates layers for statHighlight template', () => {
|
|
59
|
+
const block = makeTemplateBlock({
|
|
60
|
+
template: 'statHighlight',
|
|
61
|
+
stat: '42%',
|
|
62
|
+
description: 'of users',
|
|
63
|
+
});
|
|
64
|
+
const layers = getLayers(block, defaultContext);
|
|
65
|
+
|
|
66
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
67
|
+
const texts = layers.filter((l) => l.type === 'text');
|
|
68
|
+
expect(texts.length).toBeGreaterThanOrEqual(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns raw block layers when block has pre-computed layers', () => {
|
|
72
|
+
const existingLayers: Layer[] = [
|
|
73
|
+
{
|
|
74
|
+
type: 'shape',
|
|
75
|
+
id: 'test-shape',
|
|
76
|
+
content: { shape: 'rect', fill: '#ff0000' },
|
|
77
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
78
|
+
} as ShapeLayer,
|
|
79
|
+
];
|
|
80
|
+
const block = makeRawBlock(existingLayers);
|
|
81
|
+
const layers = getLayers(block, defaultContext);
|
|
82
|
+
|
|
83
|
+
expect(layers).toEqual(existingLayers);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns empty array for unknown template', () => {
|
|
87
|
+
const block = {
|
|
88
|
+
template: 'totally_nonexistent',
|
|
89
|
+
id: 'unknown-1',
|
|
90
|
+
duration: 5,
|
|
91
|
+
audioSegment: 0,
|
|
92
|
+
} as unknown as TemplateBlock;
|
|
93
|
+
const layers = getLayers(block, defaultContext);
|
|
94
|
+
|
|
95
|
+
expect(layers).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns empty array for block with no template and no layers', () => {
|
|
99
|
+
const block: Block = { id: 'empty-1', duration: 5, startTime: 0, audioSegment: 0 };
|
|
100
|
+
const layers = getLayers(block, defaultContext);
|
|
101
|
+
|
|
102
|
+
expect(layers).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('uses default context values when context is omitted', () => {
|
|
106
|
+
const block = makeTemplateBlock({ template: 'titleBlock', title: 'Defaults' });
|
|
107
|
+
const layers = getLayers(block);
|
|
108
|
+
|
|
109
|
+
expect(Array.isArray(layers)).toBe(true);
|
|
110
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('handles template that throws an error gracefully', () => {
|
|
114
|
+
// We spy on console.error to suppress noise and verify it was called
|
|
115
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
116
|
+
|
|
117
|
+
// comparisonBar requires numeric values; passing invalid data may throw
|
|
118
|
+
// but getLayers should catch and return []
|
|
119
|
+
const block = makeTemplateBlock({
|
|
120
|
+
template: 'comparisonBar',
|
|
121
|
+
// Missing required fields
|
|
122
|
+
});
|
|
123
|
+
const layers = getLayers(block, defaultContext);
|
|
124
|
+
|
|
125
|
+
expect(Array.isArray(layers)).toBe(true);
|
|
126
|
+
consoleSpy.mockRestore();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('persistent layers injection', () => {
|
|
130
|
+
const bottomLayer: Layer = {
|
|
131
|
+
type: 'shape',
|
|
132
|
+
id: 'bottom',
|
|
133
|
+
content: { shape: 'rect', fill: '#000' },
|
|
134
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
135
|
+
} as ShapeLayer;
|
|
136
|
+
|
|
137
|
+
const topLayer: Layer = {
|
|
138
|
+
type: 'shape',
|
|
139
|
+
id: 'top',
|
|
140
|
+
content: { shape: 'rect', fill: '#fff' },
|
|
141
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
142
|
+
} as ShapeLayer;
|
|
143
|
+
|
|
144
|
+
const persistentConfig: PersistentLayerConfig = {
|
|
145
|
+
bottomLayers: [bottomLayer],
|
|
146
|
+
topLayers: [topLayer],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
it('injects persistent bottom and top layers around block layers', () => {
|
|
150
|
+
const block = makeTemplateBlock({ template: 'titleBlock', title: 'With Persistent' });
|
|
151
|
+
const layers = getLayers(block, { ...defaultContext, persistentLayers: persistentConfig });
|
|
152
|
+
|
|
153
|
+
// First layer should be the bottom persistent layer
|
|
154
|
+
expect(layers[0]).toEqual(bottomLayer);
|
|
155
|
+
// Last layer should be the top persistent layer
|
|
156
|
+
expect(layers[layers.length - 1]).toEqual(topLayer);
|
|
157
|
+
// Middle layers (from template) should exist
|
|
158
|
+
expect(layers.length).toBeGreaterThan(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('respects useBottomLayer: false opt-out', () => {
|
|
162
|
+
const block = makeTemplateBlock({
|
|
163
|
+
template: 'titleBlock',
|
|
164
|
+
title: 'No Bottom',
|
|
165
|
+
useBottomLayer: false,
|
|
166
|
+
});
|
|
167
|
+
const layers = getLayers(block, { ...defaultContext, persistentLayers: persistentConfig });
|
|
168
|
+
|
|
169
|
+
// Should NOT start with the bottom persistent layer
|
|
170
|
+
expect(layers[0]).not.toEqual(bottomLayer);
|
|
171
|
+
// Should still end with the top persistent layer
|
|
172
|
+
expect(layers[layers.length - 1]).toEqual(topLayer);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('respects useTopLayer: false opt-out', () => {
|
|
176
|
+
const block = makeTemplateBlock({
|
|
177
|
+
template: 'titleBlock',
|
|
178
|
+
title: 'No Top',
|
|
179
|
+
useTopLayer: false,
|
|
180
|
+
});
|
|
181
|
+
const layers = getLayers(block, { ...defaultContext, persistentLayers: persistentConfig });
|
|
182
|
+
|
|
183
|
+
// Should start with the bottom persistent layer
|
|
184
|
+
expect(layers[0]).toEqual(bottomLayer);
|
|
185
|
+
// Should NOT end with the top persistent layer
|
|
186
|
+
expect(layers[layers.length - 1]).not.toEqual(topLayer);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('skips injection when no persistent layers are configured', () => {
|
|
190
|
+
const block = makeTemplateBlock({ template: 'titleBlock', title: 'No Persistent' });
|
|
191
|
+
const withoutPersistent = getLayers(block, defaultContext);
|
|
192
|
+
const withEmptyPersistent = getLayers(block, {
|
|
193
|
+
...defaultContext,
|
|
194
|
+
persistentLayers: { bottomLayers: [], topLayers: [] },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(withoutPersistent).toEqual(withEmptyPersistent);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { haversineDistance, calculateBearing } from '../spatial/Haversine';
|
|
3
|
+
|
|
4
|
+
describe('haversineDistance', () => {
|
|
5
|
+
it('returns 0 for same point', () => {
|
|
6
|
+
const p = { lat: 47.6062, lng: -122.3321 };
|
|
7
|
+
expect(haversineDistance(p, p)).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('calculates Seattle to Portland (~233 km)', () => {
|
|
11
|
+
const seattle = { lat: 47.6062, lng: -122.3321 };
|
|
12
|
+
const portland = { lat: 45.5152, lng: -122.6784 };
|
|
13
|
+
const dist = haversineDistance(seattle, portland);
|
|
14
|
+
expect(dist).toBeGreaterThan(230);
|
|
15
|
+
expect(dist).toBeLessThan(240);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('calculates London to Paris (~344 km)', () => {
|
|
19
|
+
const london = { lat: 51.5074, lng: -0.1278 };
|
|
20
|
+
const paris = { lat: 48.8566, lng: 2.3522 };
|
|
21
|
+
const dist = haversineDistance(london, paris);
|
|
22
|
+
expect(dist).toBeGreaterThan(340);
|
|
23
|
+
expect(dist).toBeLessThan(350);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('calculates antipodal points (~20,000 km)', () => {
|
|
27
|
+
const p1 = { lat: 0, lng: 0 };
|
|
28
|
+
const p2 = { lat: 0, lng: 180 };
|
|
29
|
+
const dist = haversineDistance(p1, p2);
|
|
30
|
+
expect(dist).toBeGreaterThan(20000);
|
|
31
|
+
expect(dist).toBeLessThan(20100);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('calculateBearing', () => {
|
|
36
|
+
it('returns ~0 for due north', () => {
|
|
37
|
+
const from = { lat: 0, lng: 0 };
|
|
38
|
+
const to = { lat: 10, lng: 0 };
|
|
39
|
+
const bearing = calculateBearing(from, to);
|
|
40
|
+
expect(bearing).toBeCloseTo(0, 0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns ~90 for due east', () => {
|
|
44
|
+
const from = { lat: 0, lng: 0 };
|
|
45
|
+
const to = { lat: 0, lng: 10 };
|
|
46
|
+
const bearing = calculateBearing(from, to);
|
|
47
|
+
expect(bearing).toBeCloseTo(90, 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns ~180 for due south', () => {
|
|
51
|
+
const from = { lat: 10, lng: 0 };
|
|
52
|
+
const to = { lat: 0, lng: 0 };
|
|
53
|
+
const bearing = calculateBearing(from, to);
|
|
54
|
+
expect(bearing).toBeCloseTo(180, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns ~270 for due west', () => {
|
|
58
|
+
const from = { lat: 0, lng: 10 };
|
|
59
|
+
const to = { lat: 0, lng: 0 };
|
|
60
|
+
const bearing = calculateBearing(from, to);
|
|
61
|
+
expect(bearing).toBeCloseTo(270, 0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalForageAdapter tests
|
|
3
|
+
*
|
|
4
|
+
* Uses the localforage library (which falls back to an in-memory driver
|
|
5
|
+
* in jsdom/Node environments) to verify the adapter contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
+
import { LocalForageAdapter } from '../storage/LocalForageAdapter';
|
|
10
|
+
|
|
11
|
+
describe('LocalForageAdapter', () => {
|
|
12
|
+
let storage: LocalForageAdapter;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
storage = new LocalForageAdapter({ name: 'test-db', storeName: 'test-store' });
|
|
16
|
+
await storage.clear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('supports enumeration', () => {
|
|
20
|
+
expect(storage.supportsEnumeration).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('get returns null for missing key', async () => {
|
|
24
|
+
expect(await storage.get('nonexistent')).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('set and get round-trip', async () => {
|
|
28
|
+
await storage.set('key', { hello: 'world' });
|
|
29
|
+
expect(await storage.get('key')).toEqual({ hello: 'world' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('stores primitives', async () => {
|
|
33
|
+
await storage.set('str', 'hello');
|
|
34
|
+
await storage.set('num', 42);
|
|
35
|
+
await storage.set('bool', true);
|
|
36
|
+
expect(await storage.get('str')).toBe('hello');
|
|
37
|
+
expect(await storage.get('num')).toBe(42);
|
|
38
|
+
expect(await storage.get('bool')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('overwrites existing values', async () => {
|
|
42
|
+
await storage.set('key', 'first');
|
|
43
|
+
await storage.set('key', 'second');
|
|
44
|
+
expect(await storage.get('key')).toBe('second');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('remove deletes a key', async () => {
|
|
48
|
+
await storage.set('key', 'value');
|
|
49
|
+
await storage.remove('key');
|
|
50
|
+
expect(await storage.get('key')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('clear removes all keys', async () => {
|
|
54
|
+
await storage.set('a', 1);
|
|
55
|
+
await storage.set('b', 2);
|
|
56
|
+
await storage.clear();
|
|
57
|
+
expect(await storage.get('a')).toBeNull();
|
|
58
|
+
expect(await storage.get('b')).toBeNull();
|
|
59
|
+
expect(await storage.keys()).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keys returns all stored keys', async () => {
|
|
63
|
+
await storage.set('alpha', 1);
|
|
64
|
+
await storage.set('beta', 2);
|
|
65
|
+
const keys = await storage.keys();
|
|
66
|
+
expect(keys.sort()).toEqual(['alpha', 'beta']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('stores binary data (ArrayBuffer)', async () => {
|
|
70
|
+
const buffer = new Uint8Array([1, 2, 3, 4]).buffer;
|
|
71
|
+
await storage.set('binary', buffer);
|
|
72
|
+
const result = await storage.get<ArrayBuffer>('binary');
|
|
73
|
+
expect(result).toBeInstanceOf(ArrayBuffer);
|
|
74
|
+
expect(new Uint8Array(result!)).toEqual(new Uint8Array([1, 2, 3, 4]));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('stores Uint8Array', async () => {
|
|
78
|
+
const data = new Uint8Array([10, 20, 30]);
|
|
79
|
+
await storage.set('bytes', data);
|
|
80
|
+
const result = await storage.get<Uint8Array>('bytes');
|
|
81
|
+
expect(new Uint8Array(result!)).toEqual(new Uint8Array([10, 20, 30]));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('LocalForageAdapter with prefix', () => {
|
|
86
|
+
let storage: LocalForageAdapter;
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
storage = new LocalForageAdapter({
|
|
90
|
+
name: 'test-db-prefix',
|
|
91
|
+
storeName: 'test-store',
|
|
92
|
+
prefix: 'slot1:',
|
|
93
|
+
});
|
|
94
|
+
await storage.clear();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('get/set respects prefix', async () => {
|
|
98
|
+
await storage.set('doc', { title: 'My Doc' });
|
|
99
|
+
expect(await storage.get('doc')).toEqual({ title: 'My Doc' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('keys strips prefix', async () => {
|
|
103
|
+
await storage.set('alpha', 1);
|
|
104
|
+
await storage.set('beta', 2);
|
|
105
|
+
const keys = await storage.keys();
|
|
106
|
+
expect(keys.sort()).toEqual(['alpha', 'beta']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('clear only removes prefixed keys', async () => {
|
|
110
|
+
// Write directly to a separate adapter (no prefix) to verify isolation
|
|
111
|
+
const other = new LocalForageAdapter({
|
|
112
|
+
name: 'test-db-prefix',
|
|
113
|
+
storeName: 'test-store',
|
|
114
|
+
prefix: 'slot2:',
|
|
115
|
+
});
|
|
116
|
+
await other.set('other-key', 'should-survive');
|
|
117
|
+
await storage.set('my-key', 'will-be-cleared');
|
|
118
|
+
|
|
119
|
+
await storage.clear();
|
|
120
|
+
|
|
121
|
+
expect(await storage.get('my-key')).toBeNull();
|
|
122
|
+
expect(await other.get('other-key')).toBe('should-survive');
|
|
123
|
+
|
|
124
|
+
// Cleanup
|
|
125
|
+
await other.clear();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('different prefixes are isolated', async () => {
|
|
129
|
+
const slot1 = new LocalForageAdapter({
|
|
130
|
+
name: 'test-isolation',
|
|
131
|
+
storeName: 'test-store',
|
|
132
|
+
prefix: 'slot1:',
|
|
133
|
+
});
|
|
134
|
+
const slot2 = new LocalForageAdapter({
|
|
135
|
+
name: 'test-isolation',
|
|
136
|
+
storeName: 'test-store',
|
|
137
|
+
prefix: 'slot2:',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await slot1.set('doc', 'slot1-doc');
|
|
141
|
+
await slot2.set('doc', 'slot2-doc');
|
|
142
|
+
|
|
143
|
+
expect(await slot1.get('doc')).toBe('slot1-doc');
|
|
144
|
+
expect(await slot2.get('doc')).toBe('slot2-doc');
|
|
145
|
+
|
|
146
|
+
const keys1 = await slot1.keys();
|
|
147
|
+
const keys2 = await slot2.keys();
|
|
148
|
+
expect(keys1).toEqual(['doc']);
|
|
149
|
+
expect(keys2).toEqual(['doc']);
|
|
150
|
+
|
|
151
|
+
// Cleanup
|
|
152
|
+
await slot1.clear();
|
|
153
|
+
await slot2.clear();
|
|
154
|
+
});
|
|
155
|
+
});
|