@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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Rainbow Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Detects sequences of [color=#HEX] tags where the HSL hue rotates
|
|
5
|
+
* continuously while saturation and lightness remain approximately
|
|
6
|
+
* constant — the classic "rainbow" pattern.
|
|
7
|
+
*
|
|
8
|
+
* ## Confidence features
|
|
9
|
+
*
|
|
10
|
+
* - hueRotation (+0.35) — hue changes monotonically (increasing or decreasing)
|
|
11
|
+
* - stableSaturation (+0.20) — saturation stays within a narrow band
|
|
12
|
+
* - stableLightness (+0.20) — lightness stays within a narrow band
|
|
13
|
+
* - contiguousWrappers (+0.15) — no gaps in the colour sequence
|
|
14
|
+
* - noFormattingBreaks (+0.10) — no formatting tags between colours
|
|
15
|
+
*
|
|
16
|
+
* @see SemanticContribution
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { AnalyzerPass } from '../../Contracts/Pass'
|
|
20
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
21
|
+
import type { Contribution } from '../../Contracts/Contribution'
|
|
22
|
+
import { ContributionKind } from '../../Contracts/Contribution'
|
|
23
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
24
|
+
import { childOffsets } from '../../../Syntax/GreenNode'
|
|
25
|
+
import { hexToHsl } from '../../../Utils/ColorMath'
|
|
26
|
+
import { extractHex, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
|
|
27
|
+
|
|
28
|
+
// ── Constants ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const MIN_SEQUENCE_LENGTH = 4
|
|
31
|
+
const SIGMOID_STEEPNESS = 6
|
|
32
|
+
|
|
33
|
+
const WEIGHTS = {
|
|
34
|
+
hueRotation: 0.50,
|
|
35
|
+
stableSaturation: 0.25,
|
|
36
|
+
stableLightness: 0.20,
|
|
37
|
+
noFormattingBreaks: 0.05,
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
// ── Types ─────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface RainbowModel {
|
|
43
|
+
readonly colors: string[]
|
|
44
|
+
readonly hueStart: number
|
|
45
|
+
readonly hueEnd: number
|
|
46
|
+
readonly avgSaturation: number
|
|
47
|
+
readonly avgLightness: number
|
|
48
|
+
readonly rangeStart: number
|
|
49
|
+
readonly rangeEnd: number
|
|
50
|
+
readonly charCount: number
|
|
51
|
+
readonly diagnostics: RainbowDiagnostics
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RainbowDiagnostics {
|
|
55
|
+
readonly hueDelta: number
|
|
56
|
+
readonly saturationDeviation: number
|
|
57
|
+
readonly lightnessDeviation: number
|
|
58
|
+
readonly isContinuous: boolean
|
|
59
|
+
readonly featureScores: Readonly<Record<string, number>>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Main Analyzer ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export class RainbowAnalyzer implements AnalyzerPass {
|
|
65
|
+
readonly id = 'rainbow-analyzer'
|
|
66
|
+
|
|
67
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[] {
|
|
68
|
+
const contributions: Contribution[] = []
|
|
69
|
+
this.findRainbows(tree, contributions)
|
|
70
|
+
return contributions
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private findRainbows(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
|
|
74
|
+
// Green nodes carry widths, not positions, so a walk that reports source
|
|
75
|
+
// ranges accumulates them on the way down.
|
|
76
|
+
const offsets = childOffsets(node, nodeStart)
|
|
77
|
+
if (node.children.length > 0) {
|
|
78
|
+
const children = node.children as GreenNode[]
|
|
79
|
+
const sequences = extractSequences(children, 'color', extractHex)
|
|
80
|
+
|
|
81
|
+
for (const seq of sequences) {
|
|
82
|
+
const colors = seq.values as string[]
|
|
83
|
+
if (colors.length < MIN_SEQUENCE_LENGTH) continue
|
|
84
|
+
|
|
85
|
+
let textLen = 0
|
|
86
|
+
for (let i = seq.startIdx; i < seq.endIdx; i++) {
|
|
87
|
+
for (const child of children[i].children as GreenNode[]) {
|
|
88
|
+
if (child.kind === 'text') textLen += child.text.length
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hasBreaks = checkFormattingBreaks(children, seq, 'color')
|
|
93
|
+
const diag = this.analyzeRainbow(colors)
|
|
94
|
+
const { score, featureScores } = this.calculateRawScore(diag, hasBreaks)
|
|
95
|
+
const confidence = sigmoid(score, SIGMOID_STEEPNESS)
|
|
96
|
+
|
|
97
|
+
sink.push({
|
|
98
|
+
kind: ContributionKind.Semantic,
|
|
99
|
+
label: 'Rainbow',
|
|
100
|
+
confidence,
|
|
101
|
+
range: {
|
|
102
|
+
start: offsets[seq.startIdx],
|
|
103
|
+
end: offsets[seq.endIdx],
|
|
104
|
+
},
|
|
105
|
+
metadata: {
|
|
106
|
+
model: {
|
|
107
|
+
colors,
|
|
108
|
+
hueStart: diag.hueDelta >= 0 ? colors[0] : colors[colors.length - 1],
|
|
109
|
+
hueEnd: diag.hueDelta >= 0 ? colors[colors.length - 1] : colors[0],
|
|
110
|
+
avgSaturation: this.extractHSL(colors).avgS,
|
|
111
|
+
avgLightness: this.extractHSL(colors).avgL,
|
|
112
|
+
charCount: textLen,
|
|
113
|
+
},
|
|
114
|
+
diagnostics: { ...diag, featureScores },
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const kids = node.children as GreenNode[]
|
|
121
|
+
for (let i = 0; i < kids.length; i++) {
|
|
122
|
+
this.findRainbows(kids[i], sink, offsets[i])
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── HSL Analysis ────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
private extractHSL(colors: string[]): { hues: number[]; sats: number[]; lights: number[]; avgS: number; avgL: number } {
|
|
129
|
+
const hues: number[] = []
|
|
130
|
+
const sats: number[] = []
|
|
131
|
+
const lights: number[] = []
|
|
132
|
+
|
|
133
|
+
for (const c of colors) {
|
|
134
|
+
const [h, s, l] = hexToHsl(c)
|
|
135
|
+
hues.push(h)
|
|
136
|
+
sats.push(s)
|
|
137
|
+
lights.push(l)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const avgS = sats.reduce((a, b) => a + b, 0) / sats.length
|
|
141
|
+
const avgL = lights.reduce((a, b) => a + b, 0) / lights.length
|
|
142
|
+
|
|
143
|
+
return { hues, sats, lights, avgS, avgL }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private analyzeRainbow(colors: string[]): Omit<RainbowDiagnostics, 'featureScores'> {
|
|
147
|
+
const { hues, sats, lights, avgS, avgL } = this.extractHSL(colors)
|
|
148
|
+
const n = colors.length
|
|
149
|
+
|
|
150
|
+
// 1. Hue rotation: compute signed delta, unwrapping at 360→0
|
|
151
|
+
let totalHueDelta = 0
|
|
152
|
+
let unwrapped = hues[0]
|
|
153
|
+
for (let i = 1; i < n; i++) {
|
|
154
|
+
let delta = hues[i] - hues[i - 1]
|
|
155
|
+
if (delta > 180) delta -= 360
|
|
156
|
+
else if (delta < -180) delta += 360
|
|
157
|
+
totalHueDelta += delta
|
|
158
|
+
unwrapped += delta
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Reset to compute direction from unwrapped
|
|
162
|
+
const hueDirection = Math.sign(totalHueDelta)
|
|
163
|
+
|
|
164
|
+
// Check hue monotonicity (after unwrapping)
|
|
165
|
+
let hueViolations = 0
|
|
166
|
+
unwrapped = hues[0]
|
|
167
|
+
for (let i = 1; i < n; i++) {
|
|
168
|
+
let delta = hues[i] - hues[i - 1]
|
|
169
|
+
if (delta > 180) delta -= 360
|
|
170
|
+
else if (delta < -180) delta += 360
|
|
171
|
+
unwrapped += delta
|
|
172
|
+
|
|
173
|
+
const prevUnwrapped = unwrapped - delta
|
|
174
|
+
if (Math.sign(delta) !== hueDirection && Math.abs(delta) > 15) {
|
|
175
|
+
hueViolations++
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const isContinuous = hueViolations < n * 0.25
|
|
179
|
+
|
|
180
|
+
// 2. Saturation stability (standard deviation)
|
|
181
|
+
const satDev = Math.sqrt(sats.reduce((sum, s) => sum + (s - avgS) ** 2, 0) / n)
|
|
182
|
+
|
|
183
|
+
// 3. Lightness stability (standard deviation)
|
|
184
|
+
const litDev = Math.sqrt(lights.reduce((sum, l) => sum + (l - avgL) ** 2, 0) / n)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
hueDelta: totalHueDelta,
|
|
188
|
+
saturationDeviation: satDev,
|
|
189
|
+
lightnessDeviation: litDev,
|
|
190
|
+
isContinuous,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Confidence Scoring ──────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
private calculateRawScore(
|
|
197
|
+
diag: Omit<RainbowDiagnostics, 'featureScores'>,
|
|
198
|
+
hasBreaks: boolean,
|
|
199
|
+
): { score: number; featureScores: Record<string, number> } {
|
|
200
|
+
let score = 0
|
|
201
|
+
|
|
202
|
+
// Hue rotation: need significant hue change + continuity
|
|
203
|
+
const absHueDelta = Math.abs(diag.hueDelta)
|
|
204
|
+
if (diag.isContinuous && absHueDelta > 60) score += WEIGHTS.hueRotation
|
|
205
|
+
else if (absHueDelta > 30) score += WEIGHTS.hueRotation * 0.5
|
|
206
|
+
else score -= WEIGHTS.hueRotation * 0.5
|
|
207
|
+
|
|
208
|
+
// Stable saturation (low deviation)
|
|
209
|
+
if (diag.saturationDeviation < 15) score += WEIGHTS.stableSaturation
|
|
210
|
+
else if (diag.saturationDeviation < 30) score += WEIGHTS.stableSaturation * 0.5
|
|
211
|
+
else score -= WEIGHTS.stableSaturation * 0.5
|
|
212
|
+
|
|
213
|
+
// Stable lightness (low deviation)
|
|
214
|
+
if (diag.lightnessDeviation < 15) score += WEIGHTS.stableLightness
|
|
215
|
+
else if (diag.lightnessDeviation < 30) score += WEIGHTS.stableLightness * 0.5
|
|
216
|
+
else score -= WEIGHTS.stableLightness * 0.5
|
|
217
|
+
|
|
218
|
+
// No formatting breaks
|
|
219
|
+
if (!hasBreaks) score += WEIGHTS.noFormattingBreaks
|
|
220
|
+
|
|
221
|
+
const featureScores = {
|
|
222
|
+
hueRotation: (diag.isContinuous && absHueDelta > 60) ? WEIGHTS.hueRotation :
|
|
223
|
+
absHueDelta > 30 ? WEIGHTS.hueRotation * 0.5 : -WEIGHTS.hueRotation * 0.5,
|
|
224
|
+
stableSaturation: diag.saturationDeviation < 15 ? WEIGHTS.stableSaturation :
|
|
225
|
+
diag.saturationDeviation < 30 ? WEIGHTS.stableSaturation * 0.5 : -WEIGHTS.stableSaturation * 0.5,
|
|
226
|
+
stableLightness: diag.lightnessDeviation < 15 ? WEIGHTS.stableLightness :
|
|
227
|
+
diag.lightnessDeviation < 30 ? WEIGHTS.stableLightness * 0.5 : -WEIGHTS.stableLightness * 0.5,
|
|
228
|
+
noFormattingBreaks: hasBreaks ? -WEIGHTS.noFormattingBreaks : WEIGHTS.noFormattingBreaks,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { score, featureScores }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Wave (Size Oscillation) Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Detects sequences of [size=N] tags where the sizes follow a sinusoidal
|
|
5
|
+
* (or other periodic) oscillation pattern — the classic "wave/grow" effect.
|
|
6
|
+
*
|
|
7
|
+
* ## Confidence features
|
|
8
|
+
*
|
|
9
|
+
* - periodicPattern (+0.35) — sizes follow a clear periodic pattern
|
|
10
|
+
* - boundedRange (+0.20) — sizes stay within a plausible min-max range
|
|
11
|
+
* - smoothTransitions (+0.20) — adjacent size differences are small
|
|
12
|
+
* - contiguousWrappers (+0.15) — no gaps in the size sequence
|
|
13
|
+
* - noFormattingBreaks (+0.10) — no formatting tags between size nodes
|
|
14
|
+
*
|
|
15
|
+
* @see SemanticContribution
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AnalyzerPass } from '../../Contracts/Pass'
|
|
19
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
20
|
+
import type { Contribution } from '../../Contracts/Contribution'
|
|
21
|
+
import { ContributionKind } from '../../Contracts/Contribution'
|
|
22
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
23
|
+
import { childOffsets } from '../../../Syntax/GreenNode'
|
|
24
|
+
import { extractSize, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
|
|
25
|
+
|
|
26
|
+
// ── Constants ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const MIN_SEQUENCE_LENGTH = 5
|
|
29
|
+
const SIGMOID_STEEPNESS = 6
|
|
30
|
+
|
|
31
|
+
const WEIGHTS = {
|
|
32
|
+
periodicPattern: 0.45,
|
|
33
|
+
boundedRange: 0.25,
|
|
34
|
+
smoothTransitions: 0.25,
|
|
35
|
+
noFormattingBreaks: 0.05,
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
// ── Types ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface WaveModel {
|
|
41
|
+
readonly sizes: number[]
|
|
42
|
+
readonly minSize: number
|
|
43
|
+
readonly maxSize: number
|
|
44
|
+
readonly estimatedFrequency: number
|
|
45
|
+
readonly rangeStart: number
|
|
46
|
+
readonly rangeEnd: number
|
|
47
|
+
readonly charCount: number
|
|
48
|
+
readonly diagnostics: WaveDiagnostics
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface WaveDiagnostics {
|
|
52
|
+
readonly periodicityScore: number // 0-1, how well it matches a sinusoid
|
|
53
|
+
readonly hasSymmetry: boolean
|
|
54
|
+
readonly isBounded: boolean
|
|
55
|
+
readonly transitionSmoothness: number // 0-1
|
|
56
|
+
readonly featureScores: Readonly<Record<string, number>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Main Analyzer ─────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export class WaveAnalyzer implements AnalyzerPass {
|
|
62
|
+
readonly id = 'wave-analyzer'
|
|
63
|
+
|
|
64
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[] {
|
|
65
|
+
const contributions: Contribution[] = []
|
|
66
|
+
this.findWaves(tree, contributions)
|
|
67
|
+
return contributions
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private findWaves(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
|
|
71
|
+
// Green nodes carry widths, not positions, so a walk that reports source
|
|
72
|
+
// ranges accumulates them on the way down.
|
|
73
|
+
const offsets = childOffsets(node, nodeStart)
|
|
74
|
+
if (node.children.length > 0) {
|
|
75
|
+
const children = node.children as GreenNode[]
|
|
76
|
+
const sequences = extractSequences(children, 'font_size', extractSize)
|
|
77
|
+
|
|
78
|
+
for (const seq of sequences) {
|
|
79
|
+
const sizes = seq.values as number[]
|
|
80
|
+
if (sizes.length < MIN_SEQUENCE_LENGTH) continue
|
|
81
|
+
|
|
82
|
+
let textLen = 0
|
|
83
|
+
for (let i = seq.startIdx; i < seq.endIdx; i++) {
|
|
84
|
+
for (const child of children[i].children as GreenNode[]) {
|
|
85
|
+
if (child.kind === 'text') textLen += child.text.length
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const hasBreaks = checkFormattingBreaks(children, seq, 'font_size')
|
|
90
|
+
const diag = this.analyzeWave(sizes)
|
|
91
|
+
const { score, featureScores } = this.calculateRawScore(diag, hasBreaks)
|
|
92
|
+
const confidence = sigmoid(score, SIGMOID_STEEPNESS)
|
|
93
|
+
|
|
94
|
+
sink.push({
|
|
95
|
+
kind: ContributionKind.Semantic,
|
|
96
|
+
label: 'Wave',
|
|
97
|
+
confidence,
|
|
98
|
+
range: {
|
|
99
|
+
start: offsets[seq.startIdx],
|
|
100
|
+
end: offsets[seq.endIdx],
|
|
101
|
+
},
|
|
102
|
+
metadata: {
|
|
103
|
+
model: {
|
|
104
|
+
sizes,
|
|
105
|
+
minSize: Math.min(...sizes),
|
|
106
|
+
maxSize: Math.max(...sizes),
|
|
107
|
+
estimatedFrequency: diag.periodicityScore,
|
|
108
|
+
charCount: textLen,
|
|
109
|
+
},
|
|
110
|
+
diagnostics: { ...diag, featureScores },
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const kids = node.children as GreenNode[]
|
|
117
|
+
for (let i = 0; i < kids.length; i++) {
|
|
118
|
+
this.findWaves(kids[i], sink, offsets[i])
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Wave Analysis ───────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
private analyzeWave(sizes: number[]): Omit<WaveDiagnostics, 'featureScores'> {
|
|
125
|
+
const n = sizes.length
|
|
126
|
+
|
|
127
|
+
// 1. Detect periodicity: normalise to [0,1] and check correlation with sinusoid
|
|
128
|
+
const min = Math.min(...sizes)
|
|
129
|
+
const max = Math.max(...sizes)
|
|
130
|
+
const range = max - min || 1
|
|
131
|
+
const norm = sizes.map(s => (s - min) / range)
|
|
132
|
+
|
|
133
|
+
// Try multiple frequencies, pick the best match
|
|
134
|
+
let bestCorrelation = 0
|
|
135
|
+
for (let freq = 0.5; freq <= 4; freq += 0.25) {
|
|
136
|
+
let correlation = 0
|
|
137
|
+
for (let i = 0; i < n; i++) {
|
|
138
|
+
const t = i / (n - 1 || 1)
|
|
139
|
+
const sinVal = (Math.sin(t * Math.PI * 2 * freq) + 1) / 2
|
|
140
|
+
correlation += 1 - Math.abs(norm[i] - sinVal)
|
|
141
|
+
}
|
|
142
|
+
correlation /= n
|
|
143
|
+
bestCorrelation = Math.max(bestCorrelation, correlation)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const periodicityScore = Math.max(0, Math.min(1, bestCorrelation))
|
|
147
|
+
|
|
148
|
+
// 2. Check symmetry: is the first half a mirror of the second half?
|
|
149
|
+
let symmetry = 0
|
|
150
|
+
for (let i = 0; i < Math.floor(n / 2); i++) {
|
|
151
|
+
const j = n - 1 - i
|
|
152
|
+
symmetry += 1 - Math.abs(norm[i] - norm[j]) / (range || 1)
|
|
153
|
+
}
|
|
154
|
+
const hasSymmetry = symmetry / Math.max(1, Math.floor(n / 2)) > 0.7
|
|
155
|
+
|
|
156
|
+
// 3. Bounded range: are min and max within a sensible range?
|
|
157
|
+
const isBounded = min >= 50 && max <= 250 && range >= 20
|
|
158
|
+
|
|
159
|
+
// 4. Transition smoothness: are adjacent sizes close?
|
|
160
|
+
let smoothness = 0
|
|
161
|
+
for (let i = 1; i < n; i++) {
|
|
162
|
+
const diff = Math.abs(sizes[i] - sizes[i - 1])
|
|
163
|
+
smoothness += 1 - Math.min(1, diff / (range || 1))
|
|
164
|
+
}
|
|
165
|
+
const transitionSmoothness = smoothness / Math.max(1, n - 1)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
periodicityScore,
|
|
169
|
+
hasSymmetry,
|
|
170
|
+
isBounded,
|
|
171
|
+
transitionSmoothness,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Confidence Scoring ──────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
private calculateRawScore(
|
|
178
|
+
diag: Omit<WaveDiagnostics, 'featureScores'>,
|
|
179
|
+
hasBreaks: boolean,
|
|
180
|
+
): { score: number; featureScores: Record<string, number> } {
|
|
181
|
+
let score = 0
|
|
182
|
+
|
|
183
|
+
// Periodicity
|
|
184
|
+
if (diag.periodicityScore > 0.7) score += WEIGHTS.periodicPattern
|
|
185
|
+
else if (diag.periodicityScore > 0.4) score += WEIGHTS.periodicPattern * 0.5
|
|
186
|
+
else score -= WEIGHTS.periodicPattern * 0.5
|
|
187
|
+
|
|
188
|
+
// Bounded range
|
|
189
|
+
if (diag.isBounded) score += WEIGHTS.boundedRange
|
|
190
|
+
else score -= WEIGHTS.boundedRange * 0.5
|
|
191
|
+
|
|
192
|
+
// Smooth transitions
|
|
193
|
+
if (diag.transitionSmoothness > 0.6) score += WEIGHTS.smoothTransitions
|
|
194
|
+
else if (diag.transitionSmoothness > 0.3) score += WEIGHTS.smoothTransitions * 0.5
|
|
195
|
+
else score -= WEIGHTS.smoothTransitions * 0.5
|
|
196
|
+
|
|
197
|
+
// No formatting breaks
|
|
198
|
+
if (!hasBreaks) score += WEIGHTS.noFormattingBreaks
|
|
199
|
+
|
|
200
|
+
const featureScores = {
|
|
201
|
+
periodicPattern: diag.periodicityScore > 0.7 ? WEIGHTS.periodicPattern :
|
|
202
|
+
diag.periodicityScore > 0.4 ? WEIGHTS.periodicPattern * 0.5 : -WEIGHTS.periodicPattern * 0.5,
|
|
203
|
+
boundedRange: diag.isBounded ? WEIGHTS.boundedRange : -WEIGHTS.boundedRange * 0.5,
|
|
204
|
+
smoothTransitions: diag.transitionSmoothness > 0.6 ? WEIGHTS.smoothTransitions :
|
|
205
|
+
diag.transitionSmoothness > 0.3 ? WEIGHTS.smoothTransitions * 0.5 : -WEIGHTS.smoothTransitions * 0.5,
|
|
206
|
+
noFormattingBreaks: hasBreaks ? -WEIGHTS.noFormattingBreaks : WEIGHTS.noFormattingBreaks,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { score, featureScores }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for refined GradientAnalyzer v2 (OKLab, change-point 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 { GradientAnalyzer } from '../GradientAnalyzer'
|
|
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('GradientAnalyzer v2', () => {
|
|
27
|
+
it('detects a smooth linear gradient with high confidence', () => {
|
|
28
|
+
// Perfectly spaced perceptual gradient: #FF0000 → #990000 → #330000
|
|
29
|
+
const tree = greenNode('document', '', [
|
|
30
|
+
colorNode('#FF0000', 'A'),
|
|
31
|
+
colorNode('#CC0022', 'B'),
|
|
32
|
+
colorNode('#990044', 'C'),
|
|
33
|
+
colorNode('#660066', 'D'),
|
|
34
|
+
colorNode('#330088', 'E'),
|
|
35
|
+
colorNode('#0000AA', 'F'),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
const analyzer = new GradientAnalyzer()
|
|
39
|
+
const results = analyzer.run(tree, mockContext)
|
|
40
|
+
|
|
41
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
42
|
+
if (results[0].kind === 'semantic') {
|
|
43
|
+
expect(results[0].confidence).toBeGreaterThan(0.7)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('detects change points (stops) in plateau-heavy sequences', () => {
|
|
48
|
+
// RRRR→G→BBBB: 4 R, 1 G, 4 B
|
|
49
|
+
const tree = greenNode('document', '', [
|
|
50
|
+
colorNode('#FF0000', 'R'),
|
|
51
|
+
colorNode('#FF0000', 'R'),
|
|
52
|
+
colorNode('#FF0000', 'R'),
|
|
53
|
+
colorNode('#FF0000', 'R'),
|
|
54
|
+
colorNode('#00FF00', 'G'),
|
|
55
|
+
colorNode('#0000FF', 'B'),
|
|
56
|
+
colorNode('#0000FF', 'B'),
|
|
57
|
+
colorNode('#0000FF', 'B'),
|
|
58
|
+
colorNode('#0000FF', 'B'),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const analyzer = new GradientAnalyzer()
|
|
62
|
+
const results = analyzer.run(tree, mockContext)
|
|
63
|
+
|
|
64
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
65
|
+
if (results[0].kind === 'semantic') {
|
|
66
|
+
// Check that stops were detected via change-point detection
|
|
67
|
+
const model = results[0].metadata.model as Record<string, unknown>
|
|
68
|
+
const stops = model.stops as Array<{ color: string; position: number }>
|
|
69
|
+
// Should detect 3 stops: R, G, B
|
|
70
|
+
expect(stops.length).toBeGreaterThanOrEqual(3)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('gives low confidence to random colour jumps', () => {
|
|
75
|
+
// Red→Green→Blue: sharp perceptual jumps
|
|
76
|
+
const tree = greenNode('document', '', [
|
|
77
|
+
colorNode('#FF0000', 'A'),
|
|
78
|
+
colorNode('#00FF00', 'B'),
|
|
79
|
+
colorNode('#0000FF', 'C'),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
const analyzer = new GradientAnalyzer()
|
|
83
|
+
const results = analyzer.run(tree, mockContext)
|
|
84
|
+
|
|
85
|
+
// Should detect, but with low confidence (large perceptual error)
|
|
86
|
+
if (results.length > 0 && results[0].kind === 'semantic') {
|
|
87
|
+
expect(results[0].confidence).toBeLessThan(0.7)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('ignores sequences shorter than minimum length', () => {
|
|
92
|
+
const tree = greenNode('document', '', [
|
|
93
|
+
colorNode('#FF0000', 'A'),
|
|
94
|
+
colorNode('#00FF00', 'B'),
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
const analyzer = new GradientAnalyzer()
|
|
98
|
+
const results = analyzer.run(tree, mockContext)
|
|
99
|
+
|
|
100
|
+
expect(results).toHaveLength(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('handles mixed content (non-color nodes between colors)', () => {
|
|
104
|
+
const tree = greenNode('document', '', [
|
|
105
|
+
colorNode('#FF0000', 'A'),
|
|
106
|
+
greenLeaf('text', ' break '),
|
|
107
|
+
colorNode('#EE1100', 'B'),
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
const analyzer = new GradientAnalyzer()
|
|
111
|
+
const results = analyzer.run(tree, mockContext)
|
|
112
|
+
|
|
113
|
+
// The break text should separate the sequence into single nodes
|
|
114
|
+
expect(results).toHaveLength(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns diagnostics with stop count in metadata', () => {
|
|
118
|
+
const tree = greenNode('document', '', [
|
|
119
|
+
colorNode('#FF0000', 'A'),
|
|
120
|
+
colorNode('#DD2222', 'B'),
|
|
121
|
+
colorNode('#BB4444', 'C'),
|
|
122
|
+
colorNode('#996666', 'D'),
|
|
123
|
+
colorNode('#778888', 'E'),
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
const analyzer = new GradientAnalyzer()
|
|
127
|
+
const results = analyzer.run(tree, mockContext)
|
|
128
|
+
|
|
129
|
+
if (results.length > 0 && results[0].kind === 'semantic') {
|
|
130
|
+
const diag = results[0].metadata.diagnostics as Record<string, unknown>
|
|
131
|
+
expect(typeof diag.stopCount).toBe('number')
|
|
132
|
+
expect(typeof diag.maxPerceptualError).toBe('number')
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for MergeableColorAnalyzer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { greenNode, greenLeaf } from '../../../../Syntax/GreenNode'
|
|
7
|
+
import { MergeableColorAnalyzer } from '../MergeableColorAnalyzer'
|
|
8
|
+
import type { PipelineContext } from '../../../Contracts/PipelineContext'
|
|
9
|
+
import { PipelineMode, ExportTarget } from '../../../Contracts/PipelineContext'
|
|
10
|
+
import { ContributionKind } from '../../../Contracts/Contribution'
|
|
11
|
+
|
|
12
|
+
const mockContext: PipelineContext = {
|
|
13
|
+
mode: PipelineMode.Batch,
|
|
14
|
+
target: ExportTarget.Osu,
|
|
15
|
+
featureFlags: {},
|
|
16
|
+
metadata: {},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function colorNode(hex: string, text: string): import('../../../../Syntax/GreenNode').GreenNode {
|
|
20
|
+
// Widths of the real BBCode this stands for: `[color=#RRGGBB]` and `[/color]`.
|
|
21
|
+
const textLeaf = greenLeaf('text', text)
|
|
22
|
+
return greenNode('color', `=${hex}`, [textLeaf], 8 + hex.length, 8)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('MergeableColorAnalyzer', () => {
|
|
26
|
+
it('detects mergeable consecutive identical colors', () => {
|
|
27
|
+
// [color=#FF0000]H[/color][color=#FF0000]e[/color][color=#FF0000]l[/color]
|
|
28
|
+
const tree = greenNode('document', '', [
|
|
29
|
+
colorNode('#FF0000', 'H'),
|
|
30
|
+
colorNode('#FF0000', 'e'),
|
|
31
|
+
colorNode('#FF0000', 'l'),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const analyzer = new MergeableColorAnalyzer()
|
|
35
|
+
const results = analyzer.run(tree, mockContext)
|
|
36
|
+
|
|
37
|
+
expect(results).toHaveLength(1)
|
|
38
|
+
expect(results[0]).toMatchObject({
|
|
39
|
+
kind: ContributionKind.Optimization,
|
|
40
|
+
label: 'Merge Colors',
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does NOT report non-identical consecutive colors', () => {
|
|
45
|
+
// [color=#FF0000]H[/color][color=#EE1100]e[/color][color=#DD2200]l[/color]
|
|
46
|
+
const tree = greenNode('document', '', [
|
|
47
|
+
colorNode('#FF0000', 'H'),
|
|
48
|
+
colorNode('#EE1100', 'e'),
|
|
49
|
+
colorNode('#DD2200', 'l'),
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const analyzer = new MergeableColorAnalyzer()
|
|
53
|
+
const results = analyzer.run(tree, mockContext)
|
|
54
|
+
|
|
55
|
+
expect(results).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does NOT report single color nodes', () => {
|
|
59
|
+
const tree = greenNode('document', '', [
|
|
60
|
+
colorNode('#FF0000', 'H'),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const analyzer = new MergeableColorAnalyzer()
|
|
64
|
+
const results = analyzer.run(tree, mockContext)
|
|
65
|
+
|
|
66
|
+
expect(results).toHaveLength(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('detects multiple separate mergeable sequences', () => {
|
|
70
|
+
// [color=#FF0000]A[/color][color=#FF0000]B[/color] --- [color=#00FF00]X[/color][color=#00FF00]Y[/color][color=#00FF00]Z[/color]
|
|
71
|
+
const tree = greenNode('document', '', [
|
|
72
|
+
colorNode('#FF0000', 'A'),
|
|
73
|
+
colorNode('#FF0000', 'B'),
|
|
74
|
+
colorNode('#00FF00', 'X'),
|
|
75
|
+
colorNode('#00FF00', 'Y'),
|
|
76
|
+
colorNode('#00FF00', 'Z'),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
const analyzer = new MergeableColorAnalyzer()
|
|
80
|
+
const results = analyzer.run(tree, mockContext)
|
|
81
|
+
|
|
82
|
+
expect(results).toHaveLength(2)
|
|
83
|
+
})
|
|
84
|
+
})
|