@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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. 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
+ })