@miliastry/quasar 1.0.0
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/LICENSE +119 -0
- package/README.md +45 -0
- package/dist/Visuals/lyne.css +1027 -0
- package/dist/Visuals/osu.css +257 -0
- package/dist/index.d.mts +4563 -0
- package/dist/index.d.ts +4563 -0
- package/dist/index.js +11291 -0
- package/dist/index.mjs +11197 -0
- package/package.json +51 -0
- package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
- package/src/Analysis/Contracts/Contribution.ts +76 -0
- package/src/Analysis/Contracts/Pass.ts +76 -0
- package/src/Analysis/Contracts/PipelineContext.ts +48 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
- package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
- package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
- package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
- package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
- package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
- package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
- package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
- package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
- package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
- package/src/Analysis/Pipeline/Pipeline.ts +133 -0
- package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
- package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
- package/src/Analysis/Utils/color-utils.ts +132 -0
- package/src/Analysis/__tests__/Integration.test.ts +162 -0
- package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
- package/src/Analysis/index.ts +52 -0
- package/src/BBCode/BBCodeDocumentModel.ts +175 -0
- package/src/BBCode/BBCodeToGreenNode.ts +755 -0
- package/src/BBCode/Parser.ts +384 -0
- package/src/BBCode/index.ts +12 -0
- package/src/Collab/positions.ts +91 -0
- package/src/Commands/Command.ts +44 -0
- package/src/Commands/CommandRegistry.ts +78 -0
- package/src/Commands/DeleteNode.ts +20 -0
- package/src/Commands/InsertText.ts +21 -0
- package/src/Commands/SplitMerge.ts +28 -0
- package/src/Commands/WrapInTag.ts +21 -0
- package/src/Commands/index.ts +6 -0
- package/src/Diff/TreeDiffer.ts +264 -0
- package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
- package/src/Diff/index.ts +2 -0
- package/src/Events/EventBus.ts +160 -0
- package/src/Events/index.ts +2 -0
- package/src/Formatter/Formatter.ts +54 -0
- package/src/Formatter/index.ts +2 -0
- package/src/HTML/HTMLDocumentModel.ts +35 -0
- package/src/HTML/HTMLToGreenNode.ts +290 -0
- package/src/Incremental/ChangeTracker.ts +105 -0
- package/src/Incremental/IncrementalParser.ts +591 -0
- package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
- package/src/Incremental/index.ts +4 -0
- package/src/Lexer/BBCodeLexer.ts +382 -0
- package/src/Lexer/Lexer.ts +181 -0
- package/src/Lexer/index.ts +10 -0
- package/src/Linter/Linter.ts +193 -0
- package/src/Linter/index.ts +2 -0
- package/src/Markdown/MarkdownAST.ts +112 -0
- package/src/Markdown/MarkdownDocumentModel.ts +55 -0
- package/src/Markdown/MarkdownLexer.ts +203 -0
- package/src/Markdown/MarkdownParser.ts +455 -0
- package/src/Markdown/MarkdownToGreenNode.ts +153 -0
- package/src/Model/DocumentModel.ts +694 -0
- package/src/Model/NodeFactory.ts +117 -0
- package/src/Model/TagRegistry.ts +495 -0
- package/src/Model/index.ts +5 -0
- package/src/Plugins/PluginAPI.ts +119 -0
- package/src/Plugins/PluginRegistry.ts +132 -0
- package/src/Plugins/index.ts +3 -0
- package/src/Queries/QueryEngine.ts +152 -0
- package/src/Queries/index.ts +1 -0
- package/src/RenderPipeline/RenderPipeline.ts +125 -0
- package/src/RenderPipeline/RenderTree.ts +134 -0
- package/src/RenderPipeline/index.ts +4 -0
- package/src/Semantic/SemanticAnalyzer.ts +506 -0
- package/src/Semantic/index.ts +2 -0
- package/src/Symbols/SymbolTable.ts +124 -0
- package/src/Symbols/index.ts +1 -0
- package/src/Syntax/GreenNode.ts +324 -0
- package/src/Syntax/GreenNodePool.ts +269 -0
- package/src/Syntax/NodeMatcher.ts +370 -0
- package/src/Syntax/RedNode.ts +569 -0
- package/src/Syntax/RedNodeStore.ts +184 -0
- package/src/Syntax/TreeBuilder.ts +214 -0
- package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
- package/src/Syntax/__tests__/RedNode.test.ts +81 -0
- package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
- package/src/Syntax/greenEdit.ts +110 -0
- package/src/Syntax/hash.ts +30 -0
- package/src/Syntax/index.ts +12 -0
- package/src/Syntax/partition.ts +161 -0
- package/src/Syntax/preserveNodeIds.ts +201 -0
- package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
- package/src/Tests/BlockPatcher.test.ts +437 -0
- package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
- package/src/Tests/BoxDrawer.test.ts +217 -0
- package/src/Tests/BoxRichTitle.test.ts +105 -0
- package/src/Tests/Chars500kBenchmark.test.ts +151 -0
- package/src/Tests/Chars500kEdits.test.ts +321 -0
- package/src/Tests/CollabPositions.test.ts +146 -0
- package/src/Tests/CompilerPathProfiling.test.ts +186 -0
- package/src/Tests/DOMMorpher.test.ts +142 -0
- package/src/Tests/DomPatchPerf.test.ts +60 -0
- package/src/Tests/EffectSegments.snapshot.json +616 -0
- package/src/Tests/EffectSegments.test.ts +68 -0
- package/src/Tests/FindNodeAtOffset.test.ts +65 -0
- package/src/Tests/Fuzzer.test.ts +166 -0
- package/src/Tests/GreenNodePool.test.ts +153 -0
- package/src/Tests/Lexer.test.ts +238 -0
- package/src/Tests/LyneMode.test.ts +187 -0
- package/src/Tests/ModelCoherence.test.ts +180 -0
- package/src/Tests/Partition.test.ts +238 -0
- package/src/Tests/PluginTags.test.ts +150 -0
- package/src/Tests/ProblematicSection.test.ts +46 -0
- package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
- package/src/Tests/RedReuse.test.ts +134 -0
- package/src/Tests/ReproDelete20k.test.ts +62 -0
- package/src/Tests/SemanticValidators.test.ts +136 -0
- package/src/Tests/StableNodeIds.test.ts +210 -0
- package/src/Tests/StudioColorBloat.test.ts +25 -0
- package/src/Tests/StudioDebugText.test.ts +27 -0
- package/src/Tests/StudioTrailingChar.test.ts +25 -0
- package/src/Tests/StudioValidText.test.ts +25 -0
- package/src/Tests/UrlImgBug.test.ts +23 -0
- package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
- package/src/Tests/referenceDocument.ts +119 -0
- package/src/Transactions/Transaction.ts +176 -0
- package/src/Transactions/UndoManager.ts +111 -0
- package/src/Transactions/index.ts +3 -0
- package/src/Transformers/ASTOptimizer.ts +315 -0
- package/src/Transformers/GradientTransformer.ts +143 -0
- package/src/Transformers/GrowTransformer.ts +115 -0
- package/src/Transformers/RainbowTransformer.ts +121 -0
- package/src/Transformers/SineWaveTransformer.ts +130 -0
- package/src/Transformers/Transformer.ts +22 -0
- package/src/Types/core.ts +270 -0
- package/src/Types/diagnostics.ts +156 -0
- package/src/Types/index.ts +23 -0
- package/src/Types/operations.ts +180 -0
- package/src/Types/queries.ts +121 -0
- package/src/Types/symbols.ts +69 -0
- package/src/Types/tokens.ts +186 -0
- package/src/Utils/BBCodeGenerator.ts +126 -0
- package/src/Utils/ColorMath.ts +276 -0
- package/src/Utils/color.ts +112 -0
- package/src/Utils/dom-to-svg.test.ts +86 -0
- package/src/Utils/dom-to-svg.ts +615 -0
- package/src/Utils/treeTransformers.ts +717 -0
- package/src/Visitors/BBBlocksExporter.ts +69 -0
- package/src/Visitors/BBCodeExporter.ts +318 -0
- package/src/Visitors/BlockPatcher.ts +963 -0
- package/src/Visitors/DOMMorpher.ts +134 -0
- package/src/Visitors/HTMLRenderer.ts +1077 -0
- package/src/Visitors/JSONExporter.ts +66 -0
- package/src/Visitors/MarkdownExporter.ts +99 -0
- package/src/Visitors/SVGRenderer.ts +35 -0
- package/src/Visitors/TiptapExporter.ts +145 -0
- package/src/Visitors/Visitor.ts +48 -0
- package/src/Visitors/index.ts +9 -0
- package/src/Visuals/BoxDrawer.ts +175 -0
- package/src/Visuals/index.ts +42 -0
- package/src/Visuals/lyne.css +1027 -0
- package/src/Visuals/osu.css +257 -0
- package/src/index.ts +197 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for RainbowAnalyzer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { greenNode, greenLeaf } from '../../../../Syntax/GreenNode'
|
|
7
|
+
import type { GreenNode } from '../../../../Syntax/GreenNode'
|
|
8
|
+
import { RainbowAnalyzer } from '../RainbowAnalyzer'
|
|
9
|
+
import type { PipelineContext } from '../../../Contracts/PipelineContext'
|
|
10
|
+
import { PipelineMode, ExportTarget } from '../../../Contracts/PipelineContext'
|
|
11
|
+
import { ContributionKind } from '../../../Contracts/Contribution'
|
|
12
|
+
|
|
13
|
+
const mockContext: PipelineContext = {
|
|
14
|
+
mode: PipelineMode.Batch,
|
|
15
|
+
target: ExportTarget.Miliastry,
|
|
16
|
+
featureFlags: {},
|
|
17
|
+
metadata: {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function colorNode(hex: string, text: string): GreenNode {
|
|
21
|
+
// Widths of the real BBCode this stands for: `[color=#RRGGBB]` and `[/color]`.
|
|
22
|
+
const textLeaf = greenLeaf('text', text)
|
|
23
|
+
return greenNode('color', `=${hex}`, [textLeaf], 8 + hex.length, 8)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('RainbowAnalyzer', () => {
|
|
27
|
+
it('detects a classic rainbow (hue rotation, stable S/L)', () => {
|
|
28
|
+
// Red → Yellow → Green → Cyan → Blue — classic rainbow with high S, mid L
|
|
29
|
+
const tree = greenNode('document', '', [
|
|
30
|
+
colorNode('#FF0000', 'R'),
|
|
31
|
+
colorNode('#FFAA00', 'A'),
|
|
32
|
+
colorNode('#FFFF00', 'Y'),
|
|
33
|
+
colorNode('#00FF00', 'G'),
|
|
34
|
+
colorNode('#00FFFF', 'C'),
|
|
35
|
+
colorNode('#0000FF', 'B'),
|
|
36
|
+
colorNode('#AA00FF', 'V'),
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const analyzer = new RainbowAnalyzer()
|
|
40
|
+
const results = analyzer.run(tree, mockContext)
|
|
41
|
+
|
|
42
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
43
|
+
if (results[0].kind === 'semantic') {
|
|
44
|
+
expect(results[0].confidence).toBeGreaterThan(0.5)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('gives lower confidence to random colors (no hue rotation)', () => {
|
|
49
|
+
// Colors with varying hues, saturations, and lightness — no clear rainbow
|
|
50
|
+
const tree = greenNode('document', '', [
|
|
51
|
+
colorNode('#804868', 'A'),
|
|
52
|
+
colorNode('#7EB8E0', 'B'),
|
|
53
|
+
colorNode('#D194B3', 'C'),
|
|
54
|
+
colorNode('#43383A', 'D'),
|
|
55
|
+
colorNode('#595255', 'E'),
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
const analyzer = new RainbowAnalyzer()
|
|
59
|
+
const results = analyzer.run(tree, mockContext)
|
|
60
|
+
|
|
61
|
+
// These have no clear hue rotation — confidence should be low
|
|
62
|
+
if (results.length > 0 && results[0].kind === 'semantic') {
|
|
63
|
+
expect(results[0].confidence).toBeLessThan(0.7)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('ignores sequences shorter than minimum length (4)', () => {
|
|
68
|
+
const tree = greenNode('document', '', [
|
|
69
|
+
colorNode('#FF0000', 'A'),
|
|
70
|
+
colorNode('#00FF00', 'B'),
|
|
71
|
+
colorNode('#0000FF', 'C'),
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
const analyzer = new RainbowAnalyzer()
|
|
75
|
+
const results = analyzer.run(tree, mockContext)
|
|
76
|
+
|
|
77
|
+
expect(results).toHaveLength(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('reports diagnostics with hue delta and saturation deviation', () => {
|
|
81
|
+
const tree = greenNode('document', '', [
|
|
82
|
+
colorNode('#FF0000', 'A'),
|
|
83
|
+
colorNode('#FF8800', 'B'),
|
|
84
|
+
colorNode('#FFFF00', 'C'),
|
|
85
|
+
colorNode('#88FF00', 'D'),
|
|
86
|
+
colorNode('#00FF00', 'E'),
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const analyzer = new RainbowAnalyzer()
|
|
90
|
+
const results = analyzer.run(tree, mockContext)
|
|
91
|
+
|
|
92
|
+
if (results.length > 0 && results[0].kind === 'semantic') {
|
|
93
|
+
const diag = results[0].metadata.diagnostics as Record<string, unknown>
|
|
94
|
+
expect(typeof diag.hueDelta).toBe('number')
|
|
95
|
+
expect(typeof diag.saturationDeviation).toBe('number')
|
|
96
|
+
expect(typeof diag.lightnessDeviation).toBe('number')
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for WaveAnalyzer (size oscillation detection)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { greenNode, greenLeaf } from '../../../../Syntax/GreenNode'
|
|
7
|
+
import type { GreenNode } from '../../../../Syntax/GreenNode'
|
|
8
|
+
import { WaveAnalyzer } from '../WaveAnalyzer'
|
|
9
|
+
import type { PipelineContext } from '../../../Contracts/PipelineContext'
|
|
10
|
+
import { PipelineMode, ExportTarget } from '../../../Contracts/PipelineContext'
|
|
11
|
+
import { ContributionKind } from '../../../Contracts/Contribution'
|
|
12
|
+
|
|
13
|
+
const mockContext: PipelineContext = {
|
|
14
|
+
mode: PipelineMode.Batch,
|
|
15
|
+
target: ExportTarget.Miliastry,
|
|
16
|
+
featureFlags: {},
|
|
17
|
+
metadata: {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sizeNode(size: number, text: string): GreenNode {
|
|
21
|
+
// Widths of the real BBCode this stands for: `[size=NN]` and `[/size]`.
|
|
22
|
+
const textLeaf = greenLeaf('text', text)
|
|
23
|
+
return greenNode('font_size', `=${size}`, [textLeaf], 7 + String(size).length, 7)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('WaveAnalyzer', () => {
|
|
27
|
+
it('detects a sine wave size pattern', () => {
|
|
28
|
+
// Sinusoidal sizes: 90 → 120 → 150 → 120 → 90
|
|
29
|
+
const tree = greenNode('document', '', [
|
|
30
|
+
sizeNode(90, 'A'),
|
|
31
|
+
sizeNode(120, 'B'),
|
|
32
|
+
sizeNode(150, 'C'),
|
|
33
|
+
sizeNode(120, 'D'),
|
|
34
|
+
sizeNode(90, 'E'),
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const analyzer = new WaveAnalyzer()
|
|
38
|
+
const results = analyzer.run(tree, mockContext)
|
|
39
|
+
|
|
40
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('gives higher confidence to clear wave patterns', () => {
|
|
44
|
+
// Perfect sine-like: 90→125→160→125→90
|
|
45
|
+
const tree = greenNode('document', '', [
|
|
46
|
+
sizeNode(90, 'A'),
|
|
47
|
+
sizeNode(110, 'B'),
|
|
48
|
+
sizeNode(140, 'C'),
|
|
49
|
+
sizeNode(160, 'D'),
|
|
50
|
+
sizeNode(140, 'E'),
|
|
51
|
+
sizeNode(110, 'F'),
|
|
52
|
+
sizeNode(90, 'G'),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const analyzer = new WaveAnalyzer()
|
|
56
|
+
const results = analyzer.run(tree, mockContext)
|
|
57
|
+
|
|
58
|
+
if (results.length > 0 && results[0].kind === 'semantic') {
|
|
59
|
+
expect(results[0].confidence).toBeGreaterThan(0.4)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('detects a wave pattern and produces a contribution', () => {
|
|
64
|
+
// Validates the WaveAnalyzer produces a semantic contribution for size patterns
|
|
65
|
+
const tree = greenNode('document', '', [
|
|
66
|
+
sizeNode(90, 'A'),
|
|
67
|
+
sizeNode(120, 'B'),
|
|
68
|
+
sizeNode(150, 'C'),
|
|
69
|
+
sizeNode(120, 'D'),
|
|
70
|
+
sizeNode(90, 'E'),
|
|
71
|
+
sizeNode(120, 'F'),
|
|
72
|
+
sizeNode(150, 'G'),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const analyzer = new WaveAnalyzer()
|
|
76
|
+
const results = analyzer.run(tree, mockContext)
|
|
77
|
+
|
|
78
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
79
|
+
if (results[0].kind === 'semantic') {
|
|
80
|
+
expect(results[0].label).toBe('Wave')
|
|
81
|
+
expect(results[0].metadata.model).toBeDefined()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('ignores sequences shorter than minimum length (5)', () => {
|
|
86
|
+
const tree = greenNode('document', '', [
|
|
87
|
+
sizeNode(90, 'A'),
|
|
88
|
+
sizeNode(120, 'B'),
|
|
89
|
+
sizeNode(150, 'C'),
|
|
90
|
+
sizeNode(120, 'D'),
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
const analyzer = new WaveAnalyzer()
|
|
94
|
+
const results = analyzer.run(tree, mockContext)
|
|
95
|
+
|
|
96
|
+
expect(results).toHaveLength(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('reports diagnostics with periodicity score', () => {
|
|
100
|
+
const tree = greenNode('document', '', [
|
|
101
|
+
sizeNode(90, 'A'),
|
|
102
|
+
sizeNode(120, 'B'),
|
|
103
|
+
sizeNode(150, 'C'),
|
|
104
|
+
sizeNode(120, 'D'),
|
|
105
|
+
sizeNode(90, 'E'),
|
|
106
|
+
sizeNode(120, 'F'),
|
|
107
|
+
sizeNode(150, 'G'),
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
const analyzer = new WaveAnalyzer()
|
|
111
|
+
const results = analyzer.run(tree, mockContext)
|
|
112
|
+
|
|
113
|
+
if (results.length > 0 && results[0].kind === 'semantic') {
|
|
114
|
+
const diag = results[0].metadata.diagnostics as Record<string, unknown>
|
|
115
|
+
expect(typeof diag.periodicityScore).toBe('number')
|
|
116
|
+
expect(typeof diag.transitionSmoothness).toBe('number')
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Default Decision
|
|
3
|
+
*
|
|
4
|
+
* Consumes an AnalysisReport and produces a TransformationPlan.
|
|
5
|
+
* The decision logic depends on the PipelineContext:
|
|
6
|
+
*
|
|
7
|
+
* - target === 'miliastry':
|
|
8
|
+
* Aggressively collapses detected gradients (confidence ≥ 0.6).
|
|
9
|
+
* - target === 'osu':
|
|
10
|
+
* Only collapses very high-confidence gradients (confidence ≥ 0.95).
|
|
11
|
+
* - mode === 'batch' || mode === 'import':
|
|
12
|
+
* Uses the OSU threshold by default.
|
|
13
|
+
*
|
|
14
|
+
* Optimisation opportunities (mergeable colours) are always included
|
|
15
|
+
* regardless of target, but only when the count exceeds 1.
|
|
16
|
+
*
|
|
17
|
+
* @see DecisionPass
|
|
18
|
+
* @see TransformationPlan
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { DecisionPass, TransformationPlan, TransformAction } from '../../Contracts/Pass'
|
|
22
|
+
import type { AnalysisReport } from '../../Contracts/AnalysisReport'
|
|
23
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
24
|
+
import { ContributionKind } from '../../Contracts/Contribution'
|
|
25
|
+
import type { SemanticContribution } from '../../Contracts/Contribution'
|
|
26
|
+
import { ExportTarget } from '../../Contracts/PipelineContext'
|
|
27
|
+
|
|
28
|
+
// ── Thresholds ────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const THRESHOLDS = {
|
|
31
|
+
[ExportTarget.Miliastry]: {
|
|
32
|
+
autoCollapse: 0.6, // Suggest for anything ≥ 60%
|
|
33
|
+
forceCollapse: 0.85, // Auto-collapse without asking ≥ 85%
|
|
34
|
+
},
|
|
35
|
+
[ExportTarget.Osu]: {
|
|
36
|
+
autoCollapse: 0.95, // Only collapse very confident gradients
|
|
37
|
+
forceCollapse: 0.99, // Near-certain
|
|
38
|
+
},
|
|
39
|
+
[ExportTarget.HTML]: {
|
|
40
|
+
autoCollapse: 0.8, // Moderate — HTML can handle gradients natively
|
|
41
|
+
forceCollapse: 0.95,
|
|
42
|
+
},
|
|
43
|
+
[ExportTarget.Markdown]: {
|
|
44
|
+
autoCollapse: 0.95, // Conservative — Markdown has no colour support
|
|
45
|
+
forceCollapse: 0.99,
|
|
46
|
+
},
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
const DEFAULT_THRESHOLD = {
|
|
50
|
+
autoCollapse: 0.8,
|
|
51
|
+
forceCollapse: 0.95,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class DefaultDecision implements DecisionPass {
|
|
55
|
+
readonly id = 'default-decision'
|
|
56
|
+
|
|
57
|
+
run(report: AnalysisReport, context: PipelineContext): TransformationPlan {
|
|
58
|
+
const actions: TransformAction[] = []
|
|
59
|
+
const thresholds = THRESHOLDS[context.target] ?? DEFAULT_THRESHOLD
|
|
60
|
+
|
|
61
|
+
for (const contribution of report.contributions) {
|
|
62
|
+
switch (contribution.kind) {
|
|
63
|
+
case ContributionKind.Semantic:
|
|
64
|
+
this.handleSemantic(contribution, thresholds, actions)
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
case ContributionKind.Optimization:
|
|
68
|
+
this.handleOptimization(contribution, actions)
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
// Diagnostics and metrics don't produce actions by default
|
|
72
|
+
default:
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { actions: Object.freeze(actions) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Semantic handlers ───────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** Map contribution label → transform action kind */
|
|
83
|
+
private labelToActionKind(label: string): string | null {
|
|
84
|
+
switch (label) {
|
|
85
|
+
case 'Gradient': return 'collapse-gradient'
|
|
86
|
+
case 'Rainbow': return 'collapse-rainbow'
|
|
87
|
+
case 'Wave': return 'collapse-wave'
|
|
88
|
+
default: return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private handleSemantic(
|
|
93
|
+
contribution: SemanticContribution,
|
|
94
|
+
thresholds: { autoCollapse: number; forceCollapse: number },
|
|
95
|
+
sink: TransformAction[],
|
|
96
|
+
): void {
|
|
97
|
+
const { confidence } = contribution
|
|
98
|
+
const actionKind = this.labelToActionKind(contribution.label)
|
|
99
|
+
if (!actionKind) return
|
|
100
|
+
|
|
101
|
+
if (confidence >= thresholds.forceCollapse) {
|
|
102
|
+
sink.push({
|
|
103
|
+
kind: actionKind,
|
|
104
|
+
payload: {
|
|
105
|
+
range: contribution.range,
|
|
106
|
+
model: contribution.metadata.model as Record<string, unknown>,
|
|
107
|
+
autoCollapse: true,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
} else if (confidence >= thresholds.autoCollapse) {
|
|
111
|
+
sink.push({
|
|
112
|
+
kind: actionKind,
|
|
113
|
+
payload: {
|
|
114
|
+
range: contribution.range,
|
|
115
|
+
model: contribution.metadata.model as Record<string, unknown>,
|
|
116
|
+
autoCollapse: false,
|
|
117
|
+
confidence,
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Optimization handlers ───────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
private handleOptimization(
|
|
126
|
+
contribution: { label: string; range: { start: number; end: number } },
|
|
127
|
+
sink: TransformAction[],
|
|
128
|
+
): void {
|
|
129
|
+
if (contribution.label === 'Merge Colors') {
|
|
130
|
+
// Always suggest merging identical consecutive colours
|
|
131
|
+
sink.push({
|
|
132
|
+
kind: 'merge-colors',
|
|
133
|
+
payload: {
|
|
134
|
+
range: contribution.range,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DefaultDecision
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { DefaultDecision } from '../DefaultDecision'
|
|
7
|
+
import { ContributionKind } from '../../../Contracts/Contribution'
|
|
8
|
+
import type { AnalysisReport } from '../../../Contracts/AnalysisReport'
|
|
9
|
+
import type { PipelineContext } from '../../../Contracts/PipelineContext'
|
|
10
|
+
import { PipelineMode, ExportTarget } from '../../../Contracts/PipelineContext'
|
|
11
|
+
|
|
12
|
+
const baseReport: AnalysisReport = {
|
|
13
|
+
contributions: [],
|
|
14
|
+
passCount: 1,
|
|
15
|
+
elapsedMs: 10,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function osuContext(overrides: Partial<PipelineContext> = {}): PipelineContext {
|
|
19
|
+
return {
|
|
20
|
+
mode: PipelineMode.Batch,
|
|
21
|
+
target: ExportTarget.Osu,
|
|
22
|
+
featureFlags: {},
|
|
23
|
+
metadata: {},
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function miliastryContext(overrides: Partial<PipelineContext> = {}): PipelineContext {
|
|
29
|
+
return {
|
|
30
|
+
mode: PipelineMode.Interactive,
|
|
31
|
+
target: ExportTarget.Miliastry,
|
|
32
|
+
featureFlags: {},
|
|
33
|
+
metadata: {},
|
|
34
|
+
...overrides,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('DefaultDecision', () => {
|
|
39
|
+
describe('gradient detection', () => {
|
|
40
|
+
it('excludes gradients below the threshold for osu target', () => {
|
|
41
|
+
const decision = new DefaultDecision()
|
|
42
|
+
const report: AnalysisReport = {
|
|
43
|
+
...baseReport,
|
|
44
|
+
contributions: [
|
|
45
|
+
{
|
|
46
|
+
kind: ContributionKind.Semantic,
|
|
47
|
+
label: 'Gradient',
|
|
48
|
+
confidence: 0.5, // Below threshold
|
|
49
|
+
range: { start: 0, end: 50 },
|
|
50
|
+
metadata: {
|
|
51
|
+
model: { colors: ['#FF0000', '#00FF00'], easing: 'linear', charCount: 5 },
|
|
52
|
+
diagnostics: {},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const plan = decision.run(report, osuContext())
|
|
59
|
+
expect(plan.actions).toHaveLength(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('includes gradients above auto-threshold for osu target', () => {
|
|
63
|
+
const decision = new DefaultDecision()
|
|
64
|
+
const report: AnalysisReport = {
|
|
65
|
+
...baseReport,
|
|
66
|
+
contributions: [
|
|
67
|
+
{
|
|
68
|
+
kind: ContributionKind.Semantic,
|
|
69
|
+
label: 'Gradient',
|
|
70
|
+
confidence: 0.96, // Above osu threshold (0.95)
|
|
71
|
+
range: { start: 0, end: 50 },
|
|
72
|
+
metadata: {
|
|
73
|
+
model: { colors: ['#FF0000', '#EE1100', '#DD2200'], easing: 'linear', charCount: 3 },
|
|
74
|
+
diagnostics: {},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const plan = decision.run(report, osuContext())
|
|
81
|
+
expect(plan.actions).toHaveLength(1)
|
|
82
|
+
expect(plan.actions[0].kind).toBe('collapse-gradient')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('uses lower threshold for miliastry target', () => {
|
|
86
|
+
const decision = new DefaultDecision()
|
|
87
|
+
const report: AnalysisReport = {
|
|
88
|
+
...baseReport,
|
|
89
|
+
contributions: [
|
|
90
|
+
{
|
|
91
|
+
kind: ContributionKind.Semantic,
|
|
92
|
+
label: 'Gradient',
|
|
93
|
+
confidence: 0.7, // Above miliastry threshold (0.6)
|
|
94
|
+
range: { start: 0, end: 50 },
|
|
95
|
+
metadata: {
|
|
96
|
+
model: { colors: ['#FF0000', '#EE1100', '#DD2200'], easing: 'linear', charCount: 3 },
|
|
97
|
+
diagnostics: {},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const plan = decision.run(report, miliastryContext())
|
|
104
|
+
expect(plan.actions).toHaveLength(1)
|
|
105
|
+
expect(plan.actions[0].kind).toBe('collapse-gradient')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('uses forceCollapse for very high confidence', () => {
|
|
109
|
+
const decision = new DefaultDecision()
|
|
110
|
+
const report: AnalysisReport = {
|
|
111
|
+
...baseReport,
|
|
112
|
+
contributions: [
|
|
113
|
+
{
|
|
114
|
+
kind: ContributionKind.Semantic,
|
|
115
|
+
label: 'Gradient',
|
|
116
|
+
confidence: 0.9,
|
|
117
|
+
range: { start: 0, end: 50 },
|
|
118
|
+
metadata: {
|
|
119
|
+
model: { colors: ['#FF0000', '#EE1100', '#DD2200'], easing: 'linear', charCount: 3 },
|
|
120
|
+
diagnostics: {},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const plan = decision.run(report, miliastryContext())
|
|
127
|
+
// 0.9 ≥ 0.85 = force collapse
|
|
128
|
+
expect(plan.actions[0].payload.autoCollapse).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('optimization detection', () => {
|
|
133
|
+
it('includes merge-colors optimizations', () => {
|
|
134
|
+
const decision = new DefaultDecision()
|
|
135
|
+
const report: AnalysisReport = {
|
|
136
|
+
...baseReport,
|
|
137
|
+
contributions: [
|
|
138
|
+
{
|
|
139
|
+
kind: ContributionKind.Optimization,
|
|
140
|
+
label: 'Merge Colors',
|
|
141
|
+
description: '3 consecutive [color=#FF0000] tags',
|
|
142
|
+
estimatedImprovement: '-66%',
|
|
143
|
+
range: { start: 0, end: 30 },
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const plan = decision.run(report, osuContext())
|
|
149
|
+
expect(plan.actions).toHaveLength(1)
|
|
150
|
+
expect(plan.actions[0].kind).toBe('merge-colors')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('edge cases', () => {
|
|
155
|
+
it('returns empty plan for empty report', () => {
|
|
156
|
+
const decision = new DefaultDecision()
|
|
157
|
+
const plan = decision.run(baseReport, osuContext())
|
|
158
|
+
expect(plan.actions).toHaveLength(0)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('ignores non-semantic and non-optimization contributions', () => {
|
|
162
|
+
const decision = new DefaultDecision()
|
|
163
|
+
const report: AnalysisReport = {
|
|
164
|
+
...baseReport,
|
|
165
|
+
contributions: [
|
|
166
|
+
{ kind: ContributionKind.Metrics, metrics: { characters: 100 } },
|
|
167
|
+
{
|
|
168
|
+
kind: ContributionKind.Diagnostic,
|
|
169
|
+
severity: 'info',
|
|
170
|
+
message: 'Test diagnostic',
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const plan = decision.run(report, osuContext())
|
|
176
|
+
expect(plan.actions).toHaveLength(0)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|