@gannochenko/staticstripes 0.0.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/.prettierrc +8 -0
- package/Makefile +69 -0
- package/dist/asset-manager.d.ts +16 -0
- package/dist/asset-manager.d.ts.map +1 -0
- package/dist/asset-manager.js +50 -0
- package/dist/asset-manager.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/container-renderer.d.ts +21 -0
- package/dist/container-renderer.d.ts.map +1 -0
- package/dist/container-renderer.js +149 -0
- package/dist/container-renderer.js.map +1 -0
- package/dist/expression-parser.d.ts +63 -0
- package/dist/expression-parser.d.ts.map +1 -0
- package/dist/expression-parser.js +145 -0
- package/dist/expression-parser.js.map +1 -0
- package/dist/ffmpeg.d.ts +375 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +997 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/ffprobe.d.ts +2 -0
- package/dist/ffprobe.d.ts.map +1 -0
- package/dist/ffprobe.js +31 -0
- package/dist/ffprobe.js.map +1 -0
- package/dist/html-parser.d.ts +56 -0
- package/dist/html-parser.d.ts.map +1 -0
- package/dist/html-parser.js +208 -0
- package/dist/html-parser.js.map +1 -0
- package/dist/html-project-parser.d.ts +169 -0
- package/dist/html-project-parser.d.ts.map +1 -0
- package/dist/html-project-parser.js +954 -0
- package/dist/html-project-parser.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/label-generator.d.ts +35 -0
- package/dist/label-generator.d.ts.map +1 -0
- package/dist/label-generator.js +69 -0
- package/dist/label-generator.js.map +1 -0
- package/dist/project.d.ts +29 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +137 -0
- package/dist/project.js.map +1 -0
- package/dist/sample-sequences.d.ts +5 -0
- package/dist/sample-sequences.d.ts.map +1 -0
- package/dist/sample-sequences.js +199 -0
- package/dist/sample-sequences.js.map +1 -0
- package/dist/sample-streams.d.ts +2 -0
- package/dist/sample-streams.d.ts.map +1 -0
- package/dist/sample-streams.js +109 -0
- package/dist/sample-streams.js.map +1 -0
- package/dist/sequence.d.ts +21 -0
- package/dist/sequence.d.ts.map +1 -0
- package/dist/sequence.js +269 -0
- package/dist/sequence.js.map +1 -0
- package/dist/stream.d.ts +135 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +779 -0
- package/dist/stream.js.map +1 -0
- package/dist/type.d.ts +73 -0
- package/dist/type.d.ts.map +1 -0
- package/dist/type.js +3 -0
- package/dist/type.js.map +1 -0
- package/eslint.config.js +44 -0
- package/package.json +50 -0
- package/src/asset-manager.ts +55 -0
- package/src/cli.ts +306 -0
- package/src/container-renderer.ts +190 -0
- package/src/expression-parser.test.ts +459 -0
- package/src/expression-parser.ts +199 -0
- package/src/ffmpeg.ts +1403 -0
- package/src/ffprobe.ts +29 -0
- package/src/html-parser.ts +221 -0
- package/src/html-project-parser.ts +1195 -0
- package/src/index.ts +9 -0
- package/src/label-generator.ts +74 -0
- package/src/project.ts +180 -0
- package/src/sample-sequences.ts +225 -0
- package/src/sample-streams.ts +142 -0
- package/src/sequence.ts +330 -0
- package/src/stream.ts +1012 -0
- package/src/type.ts +81 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
import { writeFile, mkdir, readdir, unlink } from 'fs/promises';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { Container } from './type';
|
|
7
|
+
|
|
8
|
+
export interface RenderContainerOptions {
|
|
9
|
+
container: Container;
|
|
10
|
+
cssText: string;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
projectDir: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ContainerRenderResult {
|
|
17
|
+
container: Container;
|
|
18
|
+
screenshotPath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generates a hash from container content and CSS
|
|
23
|
+
*/
|
|
24
|
+
function generateCacheKey(containerHtml: string, cssText: string): string {
|
|
25
|
+
const hash = createHash('sha256');
|
|
26
|
+
hash.update(containerHtml);
|
|
27
|
+
hash.update(cssText);
|
|
28
|
+
return hash.digest('hex').substring(0, 16);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders a container to a PNG screenshot using Puppeteer
|
|
33
|
+
*/
|
|
34
|
+
export async function renderContainer(
|
|
35
|
+
options: RenderContainerOptions,
|
|
36
|
+
): Promise<ContainerRenderResult> {
|
|
37
|
+
const { container, cssText, width, height, projectDir } = options;
|
|
38
|
+
|
|
39
|
+
// Create cache directory
|
|
40
|
+
const cacheDir = resolve(projectDir, '.cache', 'containers');
|
|
41
|
+
if (!existsSync(cacheDir)) {
|
|
42
|
+
await mkdir(cacheDir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Generate cache key from content hash
|
|
46
|
+
const cacheKey = generateCacheKey(container.htmlContent, cssText);
|
|
47
|
+
const screenshotPath = resolve(cacheDir, `${cacheKey}.png`);
|
|
48
|
+
|
|
49
|
+
// Check if cached version exists
|
|
50
|
+
if (existsSync(screenshotPath)) {
|
|
51
|
+
console.log(
|
|
52
|
+
`Using cached container "${container.id}" (hash: ${cacheKey}) from ${screenshotPath}`,
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
container,
|
|
56
|
+
screenshotPath,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build complete HTML document
|
|
61
|
+
const html = `
|
|
62
|
+
<!DOCTYPE html>
|
|
63
|
+
<html>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="UTF-8">
|
|
66
|
+
<style>
|
|
67
|
+
* {
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: 0;
|
|
70
|
+
box-sizing: border-box;
|
|
71
|
+
}
|
|
72
|
+
body {
|
|
73
|
+
width: ${width}px;
|
|
74
|
+
height: ${height}px;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
background: transparent;
|
|
77
|
+
font-size: 16px;
|
|
78
|
+
}
|
|
79
|
+
${cssText}
|
|
80
|
+
</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
${container.htmlContent}
|
|
84
|
+
</body>
|
|
85
|
+
</html>
|
|
86
|
+
`.trim();
|
|
87
|
+
|
|
88
|
+
// Launch browser and render
|
|
89
|
+
const browser = await puppeteer.launch({
|
|
90
|
+
headless: true,
|
|
91
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const page = await browser.newPage();
|
|
96
|
+
await page.setViewport({ width, height });
|
|
97
|
+
await page.setContent(html, { waitUntil: 'networkidle0' });
|
|
98
|
+
|
|
99
|
+
// Take screenshot with transparent background
|
|
100
|
+
const screenshot = await page.screenshot({
|
|
101
|
+
type: 'png',
|
|
102
|
+
omitBackground: true,
|
|
103
|
+
clip: {
|
|
104
|
+
x: 0,
|
|
105
|
+
y: 0,
|
|
106
|
+
width,
|
|
107
|
+
height,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Save to file
|
|
112
|
+
await writeFile(screenshotPath, screenshot);
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
`Rendered container "${container.id}" (hash: ${cacheKey}) to ${screenshotPath}`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
container,
|
|
120
|
+
screenshotPath,
|
|
121
|
+
};
|
|
122
|
+
} finally {
|
|
123
|
+
await browser.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Cleans up stale cache entries that are not in the active set
|
|
129
|
+
*/
|
|
130
|
+
async function cleanupStaleCache(
|
|
131
|
+
cacheDir: string,
|
|
132
|
+
activeCacheKeys: Set<string>,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
if (!existsSync(cacheDir)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const files = await readdir(cacheDir);
|
|
139
|
+
const pngFiles = files.filter((file) => file.endsWith('.png'));
|
|
140
|
+
|
|
141
|
+
let removedCount = 0;
|
|
142
|
+
for (const file of pngFiles) {
|
|
143
|
+
const cacheKey = file.replace('.png', '');
|
|
144
|
+
if (!activeCacheKeys.has(cacheKey)) {
|
|
145
|
+
const filePath = resolve(cacheDir, file);
|
|
146
|
+
await unlink(filePath);
|
|
147
|
+
console.log(`Removed stale cache entry: ${file}`);
|
|
148
|
+
removedCount++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (removedCount > 0) {
|
|
153
|
+
console.log(`Cleaned up ${removedCount} stale cache entries`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Renders multiple containers in sequence
|
|
159
|
+
*/
|
|
160
|
+
export async function renderContainers(
|
|
161
|
+
containers: Container[],
|
|
162
|
+
cssText: string,
|
|
163
|
+
width: number,
|
|
164
|
+
height: number,
|
|
165
|
+
projectDir: string,
|
|
166
|
+
): Promise<ContainerRenderResult[]> {
|
|
167
|
+
const results: ContainerRenderResult[] = [];
|
|
168
|
+
const activeCacheKeys = new Set<string>();
|
|
169
|
+
|
|
170
|
+
// Render all containers and collect active cache keys
|
|
171
|
+
for (const container of containers) {
|
|
172
|
+
const cacheKey = generateCacheKey(container.htmlContent, cssText);
|
|
173
|
+
activeCacheKeys.add(cacheKey);
|
|
174
|
+
|
|
175
|
+
const result = await renderContainer({
|
|
176
|
+
container,
|
|
177
|
+
cssText,
|
|
178
|
+
width,
|
|
179
|
+
height,
|
|
180
|
+
projectDir,
|
|
181
|
+
});
|
|
182
|
+
results.push(result);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Clean up stale cache entries
|
|
186
|
+
const cacheDir = resolve(projectDir, '.cache', 'containers');
|
|
187
|
+
await cleanupStaleCache(cacheDir, activeCacheKeys);
|
|
188
|
+
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parseExpression,
|
|
4
|
+
evaluateCompiledExpression,
|
|
5
|
+
parseValueLazy,
|
|
6
|
+
calculateFinalValue,
|
|
7
|
+
isCalcExpression,
|
|
8
|
+
ExpressionContext,
|
|
9
|
+
CompiledExpression,
|
|
10
|
+
} from './expression-parser';
|
|
11
|
+
|
|
12
|
+
describe('expression-parser', () => {
|
|
13
|
+
describe('isCalcExpression', () => {
|
|
14
|
+
it('should identify calc expressions', () => {
|
|
15
|
+
expect(isCalcExpression('calc(5 + 3)')).toBe(true);
|
|
16
|
+
expect(isCalcExpression(' calc(5 + 3)')).toBe(true);
|
|
17
|
+
expect(isCalcExpression('5')).toBe(false);
|
|
18
|
+
expect(isCalcExpression('something else')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('parseExpression', () => {
|
|
23
|
+
it('should parse and evaluate simple numeric expression', () => {
|
|
24
|
+
const compiled = parseExpression('calc(5 + 3)');
|
|
25
|
+
expect(compiled.original).toBe('calc(5 + 3)');
|
|
26
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
27
|
+
fragments: new Map(),
|
|
28
|
+
});
|
|
29
|
+
expect(result).toBe(8);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should parse and evaluate expression with url() fragment reference', () => {
|
|
33
|
+
const compiled = parseExpression('calc(url(#ending_screen.time.start))');
|
|
34
|
+
expect(compiled.original).toBe('calc(url(#ending_screen.time.start))');
|
|
35
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
36
|
+
fragments: new Map([
|
|
37
|
+
[
|
|
38
|
+
'ending_screen',
|
|
39
|
+
{
|
|
40
|
+
time: { start: 12000, end: 17000, duration: 5000 },
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
]),
|
|
44
|
+
});
|
|
45
|
+
expect(result).toBe(12000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should parse and evaluate expression with seconds unit', () => {
|
|
49
|
+
const compiled = parseExpression('calc(5s)');
|
|
50
|
+
expect(compiled.original).toBe('calc(5s)');
|
|
51
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
52
|
+
fragments: new Map(),
|
|
53
|
+
});
|
|
54
|
+
expect(result).toBe(5000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should parse and evaluate expression with milliseconds unit', () => {
|
|
58
|
+
const compiled = parseExpression('calc(5000ms)');
|
|
59
|
+
expect(compiled.original).toBe('calc(5000ms)');
|
|
60
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
61
|
+
fragments: new Map(),
|
|
62
|
+
});
|
|
63
|
+
expect(result).toBe(5000);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should parse and evaluate expression with decimal seconds', () => {
|
|
67
|
+
const compiled = parseExpression('calc(1.5s)');
|
|
68
|
+
expect(compiled.original).toBe('calc(1.5s)');
|
|
69
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
70
|
+
fragments: new Map(),
|
|
71
|
+
});
|
|
72
|
+
expect(result).toBe(1500);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should parse and evaluate complex expression with url() and time units', () => {
|
|
76
|
+
const compiled = parseExpression(
|
|
77
|
+
'calc(url(#ending_screen.time.start) * -1 + 5s)',
|
|
78
|
+
);
|
|
79
|
+
expect(compiled.original).toBe(
|
|
80
|
+
'calc(url(#ending_screen.time.start) * -1 + 5s)',
|
|
81
|
+
);
|
|
82
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
83
|
+
fragments: new Map([
|
|
84
|
+
[
|
|
85
|
+
'ending_screen',
|
|
86
|
+
{
|
|
87
|
+
time: { start: 10000, end: 15000, duration: 5000 },
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
]),
|
|
91
|
+
});
|
|
92
|
+
expect(result).toBe(-5000); // -10000 + 5000
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should parse and evaluate expression with multiple fragment references', () => {
|
|
96
|
+
const compiled = parseExpression(
|
|
97
|
+
'calc(url(#scene1.time.end) - url(#scene2.time.start))',
|
|
98
|
+
);
|
|
99
|
+
expect(compiled.original).toBe(
|
|
100
|
+
'calc(url(#scene1.time.end) - url(#scene2.time.start))',
|
|
101
|
+
);
|
|
102
|
+
const result = evaluateCompiledExpression(compiled, {
|
|
103
|
+
fragments: new Map([
|
|
104
|
+
['scene1', { time: { start: 0, end: 5000, duration: 5000 } }],
|
|
105
|
+
['scene2', { time: { start: 3000, end: 8000, duration: 5000 } }],
|
|
106
|
+
]),
|
|
107
|
+
});
|
|
108
|
+
expect(result).toBe(2000); // 5000 - 3000
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should throw error for invalid expression', () => {
|
|
112
|
+
expect(() => parseExpression('calc(5 + )')).toThrow(/Failed to parse/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('evaluateCompiledExpression', () => {
|
|
117
|
+
it('should evaluate simple numeric expression', () => {
|
|
118
|
+
const compiled = parseExpression('calc(5 + 3)');
|
|
119
|
+
const context: ExpressionContext = {
|
|
120
|
+
fragments: new Map(),
|
|
121
|
+
};
|
|
122
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
123
|
+
expect(result).toBe(8);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should evaluate expression with seconds converted to milliseconds', () => {
|
|
127
|
+
const compiled = parseExpression('calc(5s)');
|
|
128
|
+
const context: ExpressionContext = {
|
|
129
|
+
fragments: new Map(),
|
|
130
|
+
};
|
|
131
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
132
|
+
expect(result).toBe(5000);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should evaluate expression with milliseconds', () => {
|
|
136
|
+
const compiled = parseExpression('calc(5000ms)');
|
|
137
|
+
const context: ExpressionContext = {
|
|
138
|
+
fragments: new Map(),
|
|
139
|
+
};
|
|
140
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
141
|
+
expect(result).toBe(5000);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should evaluate expression with decimal seconds', () => {
|
|
145
|
+
const compiled = parseExpression('calc(1.5s)');
|
|
146
|
+
const context: ExpressionContext = {
|
|
147
|
+
fragments: new Map(),
|
|
148
|
+
};
|
|
149
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
150
|
+
expect(result).toBe(1500);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should evaluate expression with fragment reference', () => {
|
|
154
|
+
const compiled = parseExpression('calc(url(#ending_screen.time.start))');
|
|
155
|
+
const context: ExpressionContext = {
|
|
156
|
+
fragments: new Map([
|
|
157
|
+
[
|
|
158
|
+
'ending_screen',
|
|
159
|
+
{
|
|
160
|
+
time: {
|
|
161
|
+
start: 10000,
|
|
162
|
+
end: 15000,
|
|
163
|
+
duration: 5000,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
]),
|
|
168
|
+
};
|
|
169
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
170
|
+
expect(result).toBe(10000);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should evaluate expression with fragment reference and math', () => {
|
|
174
|
+
const compiled = parseExpression(
|
|
175
|
+
'calc(url(#ending_screen.time.start) * -1)',
|
|
176
|
+
);
|
|
177
|
+
const context: ExpressionContext = {
|
|
178
|
+
fragments: new Map([
|
|
179
|
+
[
|
|
180
|
+
'ending_screen',
|
|
181
|
+
{
|
|
182
|
+
time: {
|
|
183
|
+
start: 10000,
|
|
184
|
+
end: 15000,
|
|
185
|
+
duration: 5000,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
]),
|
|
190
|
+
};
|
|
191
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
192
|
+
expect(result).toBe(-10000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should evaluate complex expression with fragment and time unit', () => {
|
|
196
|
+
const compiled = parseExpression(
|
|
197
|
+
'calc(url(#ending_screen.time.start) * -1 + 5s)',
|
|
198
|
+
);
|
|
199
|
+
const context: ExpressionContext = {
|
|
200
|
+
fragments: new Map([
|
|
201
|
+
[
|
|
202
|
+
'ending_screen',
|
|
203
|
+
{
|
|
204
|
+
time: {
|
|
205
|
+
start: 10000,
|
|
206
|
+
end: 15000,
|
|
207
|
+
duration: 5000,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
]),
|
|
212
|
+
};
|
|
213
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
214
|
+
expect(result).toBe(-5000); // -10000 + 5000
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should evaluate expression with multiple fragment references', () => {
|
|
218
|
+
const compiled = parseExpression(
|
|
219
|
+
'calc(url(#scene1.time.end) - url(#scene2.time.start))',
|
|
220
|
+
);
|
|
221
|
+
const context: ExpressionContext = {
|
|
222
|
+
fragments: new Map([
|
|
223
|
+
[
|
|
224
|
+
'scene1',
|
|
225
|
+
{
|
|
226
|
+
time: {
|
|
227
|
+
start: 0,
|
|
228
|
+
end: 5000,
|
|
229
|
+
duration: 5000,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
[
|
|
234
|
+
'scene2',
|
|
235
|
+
{
|
|
236
|
+
time: {
|
|
237
|
+
start: 3000,
|
|
238
|
+
end: 8000,
|
|
239
|
+
duration: 5000,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
]),
|
|
244
|
+
};
|
|
245
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
246
|
+
expect(result).toBe(2000); // 5000 - 3000
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should evaluate expression with duration property', () => {
|
|
250
|
+
const compiled = parseExpression('calc(url(#intro.time.duration) + 1000)');
|
|
251
|
+
const context: ExpressionContext = {
|
|
252
|
+
fragments: new Map([
|
|
253
|
+
[
|
|
254
|
+
'intro',
|
|
255
|
+
{
|
|
256
|
+
time: {
|
|
257
|
+
start: 0,
|
|
258
|
+
end: 3000,
|
|
259
|
+
duration: 3000,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
]),
|
|
264
|
+
};
|
|
265
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
266
|
+
expect(result).toBe(4000);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should throw error for missing fragment', () => {
|
|
270
|
+
const compiled = parseExpression('calc(url(#nonexistent.time.start))');
|
|
271
|
+
const context: ExpressionContext = {
|
|
272
|
+
fragments: new Map(),
|
|
273
|
+
};
|
|
274
|
+
expect(() => evaluateCompiledExpression(compiled, context)).toThrow(
|
|
275
|
+
/Fragment with id "nonexistent" not found/,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should throw error for missing property', () => {
|
|
280
|
+
const compiled = parseExpression('calc(url(#intro.time.invalid))');
|
|
281
|
+
const context: ExpressionContext = {
|
|
282
|
+
fragments: new Map([
|
|
283
|
+
[
|
|
284
|
+
'intro',
|
|
285
|
+
{
|
|
286
|
+
time: {
|
|
287
|
+
start: 0,
|
|
288
|
+
end: 3000,
|
|
289
|
+
duration: 3000,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
]),
|
|
294
|
+
};
|
|
295
|
+
expect(() => evaluateCompiledExpression(compiled, context)).toThrow(
|
|
296
|
+
/Property "time.invalid" not found/,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('parseValueLazy', () => {
|
|
302
|
+
it('should return number as-is', () => {
|
|
303
|
+
const result = parseValueLazy(5000);
|
|
304
|
+
expect(result).toBe(5000);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should parse string number', () => {
|
|
308
|
+
const result = parseValueLazy('5000');
|
|
309
|
+
expect(result).toBe(5000);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should compile calc expression', () => {
|
|
313
|
+
const result = parseValueLazy('calc(5 + 3)');
|
|
314
|
+
expect(typeof result).toBe('object');
|
|
315
|
+
expect((result as CompiledExpression).original).toBe('calc(5 + 3)');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should compile calc expression with url()', () => {
|
|
319
|
+
const result = parseValueLazy('calc(url(#intro.time.start) + 1000)');
|
|
320
|
+
expect(typeof result).toBe('object');
|
|
321
|
+
expect((result as CompiledExpression).original).toBe(
|
|
322
|
+
'calc(url(#intro.time.start) + 1000)',
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should throw error for invalid value', () => {
|
|
327
|
+
expect(() => parseValueLazy('invalid')).toThrow(/Invalid value/);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('calculateFinalValue', () => {
|
|
332
|
+
const context: ExpressionContext = {
|
|
333
|
+
fragments: new Map([
|
|
334
|
+
[
|
|
335
|
+
'intro',
|
|
336
|
+
{
|
|
337
|
+
time: {
|
|
338
|
+
start: 0,
|
|
339
|
+
end: 3000,
|
|
340
|
+
duration: 3000,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
]),
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
it('should return number as-is', () => {
|
|
348
|
+
const result = calculateFinalValue(5000, context);
|
|
349
|
+
expect(result).toBe(5000);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should evaluate compiled expression', () => {
|
|
353
|
+
const compiled = parseExpression('calc(url(#intro.time.duration) + 1000)');
|
|
354
|
+
const result = calculateFinalValue(compiled, context);
|
|
355
|
+
expect(result).toBe(4000);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('Integration: compile once, evaluate many', () => {
|
|
360
|
+
it('should compile once and evaluate with different contexts', () => {
|
|
361
|
+
const compiled = parseExpression('calc(url(#fragment.time.start) + 2s)');
|
|
362
|
+
|
|
363
|
+
const context1: ExpressionContext = {
|
|
364
|
+
fragments: new Map([
|
|
365
|
+
[
|
|
366
|
+
'fragment',
|
|
367
|
+
{
|
|
368
|
+
time: {
|
|
369
|
+
start: 1000,
|
|
370
|
+
end: 6000,
|
|
371
|
+
duration: 5000,
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
]),
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const context2: ExpressionContext = {
|
|
379
|
+
fragments: new Map([
|
|
380
|
+
[
|
|
381
|
+
'fragment',
|
|
382
|
+
{
|
|
383
|
+
time: {
|
|
384
|
+
start: 5000,
|
|
385
|
+
end: 10000,
|
|
386
|
+
duration: 5000,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
]),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const result1 = evaluateCompiledExpression(compiled, context1);
|
|
394
|
+
const result2 = evaluateCompiledExpression(compiled, context2);
|
|
395
|
+
|
|
396
|
+
expect(result1).toBe(3000); // 1000 + 2000
|
|
397
|
+
expect(result2).toBe(7000); // 5000 + 2000
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe('Edge cases', () => {
|
|
402
|
+
it('should handle negative numbers', () => {
|
|
403
|
+
const compiled = parseExpression('calc(-5s)');
|
|
404
|
+
const context: ExpressionContext = {
|
|
405
|
+
fragments: new Map(),
|
|
406
|
+
};
|
|
407
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
408
|
+
expect(result).toBe(-5000);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should handle division', () => {
|
|
412
|
+
const compiled = parseExpression('calc(10s / 2)');
|
|
413
|
+
const context: ExpressionContext = {
|
|
414
|
+
fragments: new Map(),
|
|
415
|
+
};
|
|
416
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
417
|
+
expect(result).toBe(5000);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should handle parentheses', () => {
|
|
421
|
+
const compiled = parseExpression('calc((2s + 3s) * 2)');
|
|
422
|
+
const context: ExpressionContext = {
|
|
423
|
+
fragments: new Map(),
|
|
424
|
+
};
|
|
425
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
426
|
+
expect(result).toBe(10000); // (2000 + 3000) * 2
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should handle complex nested property paths', () => {
|
|
430
|
+
const compiled = parseExpression('calc(url(#fragment.time.start))');
|
|
431
|
+
const context: ExpressionContext = {
|
|
432
|
+
fragments: new Map([
|
|
433
|
+
[
|
|
434
|
+
'fragment',
|
|
435
|
+
{
|
|
436
|
+
time: {
|
|
437
|
+
start: 1500,
|
|
438
|
+
end: 4500,
|
|
439
|
+
duration: 3000,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
]),
|
|
444
|
+
};
|
|
445
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
446
|
+
expect(result).toBe(1500);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should not convert "ms" or "s" in middle of words', () => {
|
|
450
|
+
// This would be an odd case, but the regex should use word boundaries
|
|
451
|
+
const compiled = parseExpression('calc(100)'); // Normal case
|
|
452
|
+
const context: ExpressionContext = {
|
|
453
|
+
fragments: new Map(),
|
|
454
|
+
};
|
|
455
|
+
const result = evaluateCompiledExpression(compiled, context);
|
|
456
|
+
expect(result).toBe(100);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|