@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,187 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
4
|
+
import { BBCodeExporter } from '../Visitors/BBCodeExporter'
|
|
5
|
+
|
|
6
|
+
describe('Lyne Mode & Dialect Isolation', () => {
|
|
7
|
+
describe('Mode: osu (strict isolation)', () => {
|
|
8
|
+
it('treats Lyne-exclusive tags as literal text with zero parsing errors', () => {
|
|
9
|
+
const doc = new BBCodeDocumentModel({
|
|
10
|
+
source: '[effect=glow]Neon text[/effect] and [wnotice]Warning[/wnotice] and [tables][row][col]1[/col][/row][/tables]',
|
|
11
|
+
mode: 'osu',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const html = doc.toHTML()
|
|
15
|
+
// In osu mode, effect, wnotice, and tables are unknown tags so they should not render as Lyne elements
|
|
16
|
+
expect(html).not.toContain('bb-wnotice')
|
|
17
|
+
expect(html).not.toContain('bb-table')
|
|
18
|
+
expect(html).toContain('[effect=glow]')
|
|
19
|
+
expect(html).toContain('[wnotice]')
|
|
20
|
+
expect(html).toContain('[tables]')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('degrades Lyne-only tags to plain content when exporting to osu target', () => {
|
|
24
|
+
const doc = new BBCodeDocumentModel({
|
|
25
|
+
source: '[wnotice]Important notice[/wnotice]',
|
|
26
|
+
mode: 'lyne',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const exporter = new BBCodeExporter()
|
|
30
|
+
const osuBBCode = exporter.export(doc.root!, 'osu')
|
|
31
|
+
expect(osuBBCode).toBe('Important notice')
|
|
32
|
+
expect(osuBBCode).not.toContain('[wnotice]')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('Mode: lyne (custom tags & rich features)', () => {
|
|
37
|
+
it('parses and renders consolidated [effect] and alias tags', () => {
|
|
38
|
+
const doc = new BBCodeDocumentModel({
|
|
39
|
+
source: '[effect=neon]Cyberpunk[/effect] [glow=#2EE6E2]Cyan Glow[/glow] [shimmer]Shiny[/shimmer]',
|
|
40
|
+
mode: 'lyne',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const html = doc.toHTML()
|
|
44
|
+
expect(html).toContain('class="bb-neon"')
|
|
45
|
+
expect(html).toContain('class="bb-glow"')
|
|
46
|
+
expect(html).toContain('text-shadow:0 0 4px #2EE6E2')
|
|
47
|
+
expect(html).toContain('class="bb-shimmer"')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('parses and renders consolidated [anim] and alias tags', () => {
|
|
51
|
+
const doc = new BBCodeDocumentModel({
|
|
52
|
+
source: '[anim=pulse]Pulsing[/anim] [wave]Waving[/wave] [glitch]Glitched[/glitch] [typewriter]Typed[/typewriter]',
|
|
53
|
+
mode: 'lyne',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const html = doc.toHTML()
|
|
57
|
+
expect(html).toContain('class="bb-pulse"')
|
|
58
|
+
expect(html).toContain('class="bb-wave"')
|
|
59
|
+
expect(html).toContain('class="bb-glitch"')
|
|
60
|
+
expect(html).toContain('class="bb-typewriter"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('parses and renders consolidated [container] and hyphenated [neon-box] tags', () => {
|
|
64
|
+
const doc = new BBCodeDocumentModel({
|
|
65
|
+
source: '[container=card]Card content[/container] [container=neon-box:#ff0055]Neon Box[/container] [neon-box=#2ee6e2]Direct Neon Box[/neon-box]',
|
|
66
|
+
mode: 'lyne',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const html = doc.toHTML()
|
|
70
|
+
expect(html).toContain('class="bb-cut-panel bb-card"')
|
|
71
|
+
expect(html).toContain('class="bb-cut-panel bb-neon-box"')
|
|
72
|
+
expect(html).toContain('--neon-color:#ff0055')
|
|
73
|
+
expect(html).toContain('Direct Neon Box')
|
|
74
|
+
expect(html).not.toContain('[/neon-box]')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('parses and renders safe [style] tags with CSS whitelist', () => {
|
|
78
|
+
const doc = new BBCodeDocumentModel({
|
|
79
|
+
source: '[style="display:flex;opacity:0.8;color:#2ee6e2;evil-prop:expression()"]Styled text[/style]',
|
|
80
|
+
mode: 'lyne',
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const html = doc.toHTML()
|
|
84
|
+
expect(html).toContain('display:flex;opacity:0.8;color:#2ee6e2')
|
|
85
|
+
expect(html).not.toContain('evil-prop')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('parses and renders [wnotice] with warning symbol and custom color', () => {
|
|
89
|
+
const doc = new BBCodeDocumentModel({
|
|
90
|
+
source: '[wnotice=#ffaa00]Dangerous action ahead[/wnotice]',
|
|
91
|
+
mode: 'lyne',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const html = doc.toHTML()
|
|
95
|
+
expect(html).toContain('bb-wnotice')
|
|
96
|
+
expect(html).toContain('âš ')
|
|
97
|
+
expect(html).toContain('Dangerous action ahead')
|
|
98
|
+
expect(html).toContain('border-left-color:#ffaa00')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('parses and renders [tables], [columns], [gallery], [separator], [scroll]', () => {
|
|
102
|
+
const doc = new BBCodeDocumentModel({
|
|
103
|
+
source: `[tables=striped,borders][row][th]Header[/th][/row][row][col]Cell[/col][/row][/tables]
|
|
104
|
+
[columns=3]Col1 Col2 Col3[/columns]
|
|
105
|
+
[gallery][img]https://example.com/1.png[/img][img]https://example.com/2.png[/img][/gallery]
|
|
106
|
+
[separator=stars]
|
|
107
|
+
[scroll=150]Scrollable text[/scroll]`,
|
|
108
|
+
mode: 'lyne',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const html = doc.toHTML()
|
|
112
|
+
expect(html).toContain('class="bb-table bb-table-striped bb-table-borders"')
|
|
113
|
+
expect(html).toContain('class="bb-th"')
|
|
114
|
+
expect(html).toContain('class="bb-columns" style="column-count:3;"')
|
|
115
|
+
expect(html).toContain('class="bb-gallery"')
|
|
116
|
+
expect(html).toContain('✦ ✦ ✦')
|
|
117
|
+
expect(html).toContain('class="bb-scroll" style="max-height:150px;"')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('parses and renders inline typography tags (sup, sub, mark, kbd, tooltip, flip, raw)', () => {
|
|
121
|
+
const doc = new BBCodeDocumentModel({
|
|
122
|
+
source: '[sup]super[/sup] [sub]sub[/sub] [mark]highlight[/mark] [kbd]CTRL[/kbd] [tooltip=Help text]Hover me[/tooltip] [flip]Flipped[/flip] [raw]<strong>Literal</strong>[/raw]',
|
|
123
|
+
mode: 'lyne',
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const html = doc.toHTML()
|
|
127
|
+
expect(html).toContain('>super</sup>')
|
|
128
|
+
expect(html).toContain('>sub</sub>')
|
|
129
|
+
expect(html).toContain('class="bb-mark">highlight</mark>')
|
|
130
|
+
expect(html).toContain('class="bb-kbd">CTRL</kbd>')
|
|
131
|
+
expect(html).toContain('class="bb-tooltip" title="Help text"')
|
|
132
|
+
expect(html).toContain('transform:scaleX(-1)')
|
|
133
|
+
expect(html).toContain('<strong>Literal</strong>')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('supports mediaProxy and entityLinkResolver options', () => {
|
|
137
|
+
const renderer = new HTMLRenderer({
|
|
138
|
+
theme: 'lyne',
|
|
139
|
+
dialect: 'lyne',
|
|
140
|
+
mediaProxy: (url) => `https://proxy.lyne.game/?url=${encodeURIComponent(url)}`,
|
|
141
|
+
entityLinkResolver: (kind, val) => {
|
|
142
|
+
if (kind === 'guild') return { href: `/g/${val}`, external: false }
|
|
143
|
+
if (kind === 'map') return { href: `/m/${val}`, external: true }
|
|
144
|
+
return null
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const doc = new BBCodeDocumentModel({
|
|
149
|
+
source: '[img]https://externalsite.com/pic.jpg[/img] [guild]CYBER[/guild] [map]12345[/map]',
|
|
150
|
+
mode: 'lyne',
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const html = renderer.render(doc.root!)
|
|
154
|
+
expect(html).toContain('src="https://proxy.lyne.game/?url=https%3A%2F%2Fexternalsite.com%2Fpic.jpg"')
|
|
155
|
+
expect(html).toContain('href="/g/CYBER"')
|
|
156
|
+
expect(html).toContain('href="/m/12345" target="_blank"')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('supports rich centered and aligned box titles in lyne mode', () => {
|
|
160
|
+
const doc = new BBCodeDocumentModel({
|
|
161
|
+
source: '[centre][box="Centered Mission"]Content[/box][/centre]\n[box=[centre]Inner Centered Title[/centre]]Content[/box]\n[centre][box=joined contests][notice][box=[b]nested box[/b]]inner[/box][/notice][/box][/centre]',
|
|
162
|
+
mode: 'lyne',
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const renderer = new HTMLRenderer({ theme: 'lyne', dialect: 'lyne' })
|
|
166
|
+
const html = renderer.render(doc.root!)
|
|
167
|
+
expect(html).toContain('<details')
|
|
168
|
+
expect(html).toContain('class="box"')
|
|
169
|
+
expect(html).toContain('Centered Mission')
|
|
170
|
+
expect(html).toContain('style="text-align:center;"')
|
|
171
|
+
expect(html).toContain('nested box')
|
|
172
|
+
expect(html).toContain('class="bb-notice-body"')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('exports correctly with target lyne', () => {
|
|
176
|
+
const input = '[wnotice=#ff0000]Be careful[/wnotice]'
|
|
177
|
+
const doc = new BBCodeDocumentModel({
|
|
178
|
+
source: input,
|
|
179
|
+
mode: 'lyne',
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const exporter = new BBCodeExporter(undefined, 'lyne')
|
|
183
|
+
const exported = exporter.export(doc.root!)
|
|
184
|
+
expect(exported).toBe('[wnotice=#ff0000]Be careful[/wnotice]')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
import type { RedNode } from '../Syntax/RedNode'
|
|
4
|
+
import type { Operation } from '../Types/operations'
|
|
5
|
+
import type { NodeId } from '../Types/core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Three properties this file pins down:
|
|
9
|
+
*
|
|
10
|
+
* 1. `ensureAnalyzed` — diagnostics must describe the CURRENT text. The
|
|
11
|
+
* incremental edit path defers `analyze()` behind a debounce, so a caller
|
|
12
|
+
* reading `diagnostics` right after `applyTextUpdate` used to see the
|
|
13
|
+
* previous document's errors (the error panel ran one keystroke behind).
|
|
14
|
+
*
|
|
15
|
+
* 2. EventBus laziness — with no subscribers, an edit must not retain event
|
|
16
|
+
* history (each retained event pinned the source string and, via the lazy
|
|
17
|
+
* `nodeMatch` closure, the whole previous red/green tree). The capability
|
|
18
|
+
* stays: subscribers get events, `recordHistory` opts back in.
|
|
19
|
+
*
|
|
20
|
+
* 3. transact/undo/redo coherence — after any of them, `source`, `greenRoot`
|
|
21
|
+
* and `redRoot` describe the same document. The old implementation swapped
|
|
22
|
+
* the red root and left source/green stale, and its undo re-applied
|
|
23
|
+
* non-inverted operations.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function findKind(root: RedNode, kind: string): RedNode | null {
|
|
27
|
+
let found: RedNode | null = null
|
|
28
|
+
root.walk(n => {
|
|
29
|
+
if (!found && n.kind === kind) found = n
|
|
30
|
+
})
|
|
31
|
+
return found
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function deleteOp(node: RedNode): Operation {
|
|
35
|
+
return {
|
|
36
|
+
kind: 'delete_node',
|
|
37
|
+
nodeId: node.id,
|
|
38
|
+
node,
|
|
39
|
+
parentId: node.parent!.id,
|
|
40
|
+
index: node.index,
|
|
41
|
+
id: '',
|
|
42
|
+
undoable: true,
|
|
43
|
+
timestamp: 0,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('ensureAnalyzed — diagnostics freshness', () => {
|
|
48
|
+
it('diagnostics describe the current text right after an incremental edit', () => {
|
|
49
|
+
const model = new BBCodeDocumentModel({ source: 'hola mundo' })
|
|
50
|
+
expect(model.diagnostics?.items ?? []).toHaveLength(0)
|
|
51
|
+
|
|
52
|
+
model.applyTextUpdate('hola [b]mundo')
|
|
53
|
+
model.ensureAnalyzed()
|
|
54
|
+
|
|
55
|
+
expect(model.diagnostics!.items.map(d => d.code)).toContain('unclosed-tag')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('clears again when the error is fixed', () => {
|
|
59
|
+
const model = new BBCodeDocumentModel({ source: 'hola [b]mundo' })
|
|
60
|
+
model.applyTextUpdate('hola [b]mundo[/b]')
|
|
61
|
+
model.ensureAnalyzed()
|
|
62
|
+
|
|
63
|
+
expect(model.diagnostics!.items).toHaveLength(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('flushes the pending work exactly once', async () => {
|
|
67
|
+
const model = new BBCodeDocumentModel({ source: 'hola mundo' })
|
|
68
|
+
let emitted = 0
|
|
69
|
+
model.events.on('diagnostics_updated', () => { emitted++ })
|
|
70
|
+
|
|
71
|
+
model.applyTextUpdate('hola mundo!')
|
|
72
|
+
model.ensureAnalyzed()
|
|
73
|
+
expect(emitted).toBe(1)
|
|
74
|
+
|
|
75
|
+
// The debounce timer must not fire a second run afterwards.
|
|
76
|
+
await new Promise(r => setTimeout(r, 30))
|
|
77
|
+
expect(emitted).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('EventBus — no cost without subscribers', () => {
|
|
82
|
+
it('keeps no event history by default', () => {
|
|
83
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
84
|
+
model.applyTextUpdate('hola mundo')
|
|
85
|
+
model.ensureAnalyzed()
|
|
86
|
+
model.rebuild('adios')
|
|
87
|
+
|
|
88
|
+
expect(model.events.getHistory()).toHaveLength(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('records history when opted in', () => {
|
|
92
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
93
|
+
model.events.recordHistory = true
|
|
94
|
+
model.rebuild('adios')
|
|
95
|
+
|
|
96
|
+
expect(model.events.getHistory().length).toBeGreaterThan(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('subscribers still receive events, with a working lazy nodeMatch', () => {
|
|
100
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
101
|
+
const seen: Array<Record<string, unknown>> = []
|
|
102
|
+
model.events.on('document_changed', e => seen.push(e))
|
|
103
|
+
|
|
104
|
+
model.rebuild('adios')
|
|
105
|
+
|
|
106
|
+
expect(seen).toHaveLength(1)
|
|
107
|
+
// Reading the accessor computes the match on demand.
|
|
108
|
+
expect(seen[0].nodeMatch).toBeTruthy()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('transact / undo / redo — model coherence', () => {
|
|
113
|
+
it('transact updates source, green and red together', () => {
|
|
114
|
+
const model = new BBCodeDocumentModel({ source: '[b]hola[/b] mundo' })
|
|
115
|
+
const before = model.source
|
|
116
|
+
const bold = findKind(model.redRoot!, 'bold')!
|
|
117
|
+
|
|
118
|
+
const changed = model.transact([deleteOp(bold)], 'delete bold')
|
|
119
|
+
|
|
120
|
+
expect(changed).toBe(true)
|
|
121
|
+
expect(model.source).not.toContain('[b]')
|
|
122
|
+
expect(model.source).toContain('mundo')
|
|
123
|
+
// The red tree is a view over the green tree the model holds — not a
|
|
124
|
+
// mutated orphan, which is what the old implementation left behind.
|
|
125
|
+
expect(model.redRoot!.green).toBe(model.greenRoot)
|
|
126
|
+
expect(model.source).not.toBe(before)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('undo and redo replay snapshots and stay coherent', () => {
|
|
130
|
+
const model = new BBCodeDocumentModel({ source: '[b]hola[/b] mundo' })
|
|
131
|
+
const before = model.source
|
|
132
|
+
const bold = findKind(model.redRoot!, 'bold')!
|
|
133
|
+
model.transact([deleteOp(bold)], 'delete bold')
|
|
134
|
+
const after = model.source
|
|
135
|
+
|
|
136
|
+
expect(model.undo()).toBe(true)
|
|
137
|
+
expect(model.source).toBe(before)
|
|
138
|
+
expect(model.redRoot!.green).toBe(model.greenRoot)
|
|
139
|
+
expect(findKind(model.redRoot!, 'bold')).toBeTruthy()
|
|
140
|
+
|
|
141
|
+
expect(model.redo()).toBe(true)
|
|
142
|
+
expect(model.source).toBe(after)
|
|
143
|
+
expect(findKind(model.redRoot!, 'bold')).toBeNull()
|
|
144
|
+
|
|
145
|
+
expect(model.undo()).toBe(true)
|
|
146
|
+
expect(model.source).toBe(before)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('text editing keeps working after a transact', () => {
|
|
150
|
+
const model = new BBCodeDocumentModel({ source: '[b]hola[/b] mundo' })
|
|
151
|
+
const bold = findKind(model.redRoot!, 'bold')!
|
|
152
|
+
model.transact([deleteOp(bold)])
|
|
153
|
+
|
|
154
|
+
// This used to diff against a stale `_source` and splice a stale green
|
|
155
|
+
// tree; with the coherence contract it is just another edit.
|
|
156
|
+
const edited = model.source + '!'
|
|
157
|
+
model.applyTextUpdate(edited)
|
|
158
|
+
expect(model.source).toBe(edited)
|
|
159
|
+
expect(model.redRoot!.green).toBe(model.greenRoot)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('a no-op transaction changes nothing and is not undoable', () => {
|
|
163
|
+
const model = new BBCodeDocumentModel({ source: 'hola mundo' })
|
|
164
|
+
const missing: Operation = {
|
|
165
|
+
kind: 'delete_node',
|
|
166
|
+
nodeId: 'no-such-node' as unknown as NodeId,
|
|
167
|
+
node: model.redRoot!,
|
|
168
|
+
parentId: 'no-such-parent' as unknown as NodeId,
|
|
169
|
+
index: 0,
|
|
170
|
+
id: '',
|
|
171
|
+
undoable: true,
|
|
172
|
+
timestamp: 0,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(model.transact([missing])).toBe(false)
|
|
176
|
+
expect(model.source).toBe('hola mundo')
|
|
177
|
+
expect(model.undoManager.undoCount).toBe(0)
|
|
178
|
+
expect(model.undo()).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The partition invariant (roadmap point 14).
|
|
3
|
+
*
|
|
4
|
+
* The tree must account for every character of the source exactly once. This
|
|
5
|
+
* was false in three independent ways before the width model, and each one is
|
|
6
|
+
* pinned here by the minimal case that exposed it — because the failure mode
|
|
7
|
+
* is silent: nothing crashes, offsets just quietly stop meaning anything, and
|
|
8
|
+
* everything built on them (`findNodeAtOffset`, the Monaco↔AST mapping,
|
|
9
|
+
* diagnostics, incremental reparse) inherits the ambiguity.
|
|
10
|
+
*
|
|
11
|
+
* Since point 5, green nodes carry widths and no positions, so most of the
|
|
12
|
+
* invariant holds by construction. What these tests check is therefore split
|
|
13
|
+
* in two: the WIDTHS on the green tree, and the POSITIONS the red tree derives
|
|
14
|
+
* from them — which is where a mistake would now actually show up.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { REFERENCE_DOCUMENT, REFERENCE_DOCUMENT_WITH_GRADIENT } from './referenceDocument'
|
|
18
|
+
import { parseBBCode } from '../BBCode/Parser'
|
|
19
|
+
import { greenToRedNode } from '../BBCode/BBCodeToGreenNode'
|
|
20
|
+
import { checkPartition } from '../Syntax/partition'
|
|
21
|
+
import { GreenNode } from '../Syntax/GreenNode'
|
|
22
|
+
import type { RedNode } from '../Syntax/RedNode'
|
|
23
|
+
|
|
24
|
+
const violations = (source: string, strictMode = false) =>
|
|
25
|
+
checkPartition(parseBBCode(source, { strictMode }), source.length, {
|
|
26
|
+
limit: 50,
|
|
27
|
+
checkLeafWidths: true,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const expectPartitions = (source: string, strictMode = false) => {
|
|
31
|
+
const vs = violations(source, strictMode)
|
|
32
|
+
expect(
|
|
33
|
+
vs.map(v => `${v.kind} at ${v.path}: ${v.detail}`),
|
|
34
|
+
`source: ${JSON.stringify(source)}`,
|
|
35
|
+
).toEqual([])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse and build the red tree, where absolute positions live. */
|
|
39
|
+
const redOf = (source: string): RedNode => greenToRedNode(parseBBCode(source))
|
|
40
|
+
|
|
41
|
+
/** Every character offset must resolve to exactly one owning node. */
|
|
42
|
+
function ownersPerOffset(root: RedNode, length: number): number[] {
|
|
43
|
+
const owners = new Array<number>(length).fill(0)
|
|
44
|
+
const stack: RedNode[] = [root]
|
|
45
|
+
while (stack.length > 0) {
|
|
46
|
+
const node = stack.pop()!
|
|
47
|
+
// A node OWNS only what its children do not: its delimiters.
|
|
48
|
+
if (node.children.length === 0) {
|
|
49
|
+
for (let i = node.range.start; i < node.range.end; i++) owners[i]++
|
|
50
|
+
} else {
|
|
51
|
+
const first = node.children[0]
|
|
52
|
+
const last = node.children[node.children.length - 1]
|
|
53
|
+
for (let i = node.range.start; i < first.range.start; i++) owners[i]++
|
|
54
|
+
for (let i = last.range.end; i < node.range.end; i++) owners[i]++
|
|
55
|
+
for (const c of node.children) stack.push(c)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return owners
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('partition invariant', () => {
|
|
62
|
+
describe('the three original defects', () => {
|
|
63
|
+
it('delimiters are accounted for by leading/trailing width', () => {
|
|
64
|
+
const bold = redOf('[b]hola[/b]').children[0].children[0]
|
|
65
|
+
|
|
66
|
+
expect(bold.range).toEqual({ start: 0, end: 11 })
|
|
67
|
+
expect(bold.green.leadingWidth).toBe(3) // '[b]'
|
|
68
|
+
expect(bold.green.trailingWidth).toBe(4) // '[/b]'
|
|
69
|
+
expect(bold.children[0].range).toEqual({ start: 3, end: 7 })
|
|
70
|
+
expectPartitions('[b]hola[/b]')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('siblings never share a range — the spacing/empty_line case', () => {
|
|
74
|
+
// This exact input produced `spacing [4..6]` AND `empty_line [4..6]`.
|
|
75
|
+
const kinds: { kind: string; start: number; end: number }[] = []
|
|
76
|
+
const collect = (n: RedNode): void => {
|
|
77
|
+
if (n.kind === 'spacing' || n.kind === 'empty_line') {
|
|
78
|
+
kinds.push({ kind: n.kind, start: n.range.start, end: n.range.end })
|
|
79
|
+
}
|
|
80
|
+
for (const c of n.children) collect(c)
|
|
81
|
+
}
|
|
82
|
+
collect(redOf('hola\n\nmundo'))
|
|
83
|
+
|
|
84
|
+
expect(kinds).toEqual([
|
|
85
|
+
{ kind: 'spacing', start: 4, end: 5 },
|
|
86
|
+
{ kind: 'empty_line', start: 5, end: 6 },
|
|
87
|
+
])
|
|
88
|
+
expectPartitions('hola\n\nmundo')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('an orphaned closing tag survives as text instead of vanishing', () => {
|
|
92
|
+
const source = 'hola [/b] mundo'
|
|
93
|
+
const texts: string[] = []
|
|
94
|
+
parseBBCode(source).walk(n => { if (n.kind === 'text') texts.push(n.text) })
|
|
95
|
+
|
|
96
|
+
expect(texts.join('')).toBe(source)
|
|
97
|
+
expectPartitions(source)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('an auto-closed inner tag does not swallow the closing delimiter', () => {
|
|
102
|
+
// `[b][i]x[/b]` used to give italic and bold BOTH ending at 11.
|
|
103
|
+
const bold = redOf('[b][i]x[/b]').children[0].children[0]
|
|
104
|
+
const italic = bold.children[0]
|
|
105
|
+
|
|
106
|
+
expect(bold.range).toEqual({ start: 0, end: 11 })
|
|
107
|
+
expect(italic.range).toEqual({ start: 3, end: 7 })
|
|
108
|
+
expect(italic.green.trailingWidth).toBe(0)
|
|
109
|
+
expect(bold.green.trailingWidth).toBe(4)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('the root always spans the whole source', () => {
|
|
113
|
+
for (const source of ['', 'hola', '[/b]', '[/b][/i]', '[b]', 'a\n']) {
|
|
114
|
+
const tree = parseBBCode(source)
|
|
115
|
+
expect([source, tree.width]).toEqual([source, source.length])
|
|
116
|
+
expect([source, redOf(source).range]).toEqual([
|
|
117
|
+
source,
|
|
118
|
+
{ start: 0, end: source.length },
|
|
119
|
+
])
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('holds on real and hostile input', () => {
|
|
124
|
+
// Was two untracked `.milia` files read from disk; see `referenceDocument.ts`.
|
|
125
|
+
const fixtures = {
|
|
126
|
+
'reference document': REFERENCE_DOCUMENT,
|
|
127
|
+
'reference document with gradient': REFERENCE_DOCUMENT_WITH_GRADIENT,
|
|
128
|
+
}
|
|
129
|
+
for (const [name, source] of Object.entries(fixtures)) {
|
|
130
|
+
it(`fixture ${name}`, () => {
|
|
131
|
+
expectPartitions(source)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const cases = [
|
|
136
|
+
'', 'hola', 'hola\n\nmundo', '\n\n\n\n', 'a\r\n\r\nb',
|
|
137
|
+
'[b]hola[/b]', '[b][i]x[/b]', '[/b]', '[b]sin cerrar', '[b][/b]',
|
|
138
|
+
'[code]raw [b]x[/b][/code]', '[code]sin cerrar', '[c]x[/c]',
|
|
139
|
+
'[list][*]a[*]b[/list]', '[list][*]a', '[*]suelto',
|
|
140
|
+
'[Gateron]', '[90 misses]', '[[[[', ']]]]', '[',
|
|
141
|
+
'[box=[b]t[/b]]c[/box]', '[b]hola[/b]texto[i]y[/i]',
|
|
142
|
+
'[url=https://osu.ppy.sh]x[/url][/url]', '[quote][/b][/quote]',
|
|
143
|
+
'🎵[b]á€ñ[/b]🎵',
|
|
144
|
+
]
|
|
145
|
+
for (const source of cases) {
|
|
146
|
+
it(`case ${JSON.stringify(source)}`, () => {
|
|
147
|
+
expectPartitions(source)
|
|
148
|
+
expectPartitions(source, true) // strict mode too
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('every offset has exactly one owner', () => {
|
|
154
|
+
const source = '[centre][b]hola[/b]\n\n[/i]mundo[list][*]a[/list][b]abierto'
|
|
155
|
+
const owners = ownersPerOffset(redOf(source), source.length)
|
|
156
|
+
|
|
157
|
+
const unowned = owners.flatMap((c, i) => (c === 0 ? [i] : []))
|
|
158
|
+
const shared = owners.flatMap((c, i) => (c > 1 ? [i] : []))
|
|
159
|
+
expect({ unowned, shared }).toEqual({ unowned: [], shared: [] })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('red offsets agree with the source text they claim', () => {
|
|
163
|
+
// The check that only exists once positions are DERIVED: every leaf must
|
|
164
|
+
// still be able to find itself in the source at the offset it reports.
|
|
165
|
+
const source = REFERENCE_DOCUMENT_WITH_GRADIENT
|
|
166
|
+
const mismatches: string[] = []
|
|
167
|
+
const visit = (n: RedNode): void => {
|
|
168
|
+
if (n.kind === 'text' && mismatches.length < 5) {
|
|
169
|
+
const slice = source.slice(n.range.start, n.range.end)
|
|
170
|
+
if (slice !== n.text) {
|
|
171
|
+
mismatches.push(`[${n.range.start}..${n.range.end}] ${JSON.stringify(slice)} != ${JSON.stringify(n.text)}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const c of n.children) visit(c)
|
|
175
|
+
}
|
|
176
|
+
visit(greenToRedNode(parseBBCode(source)))
|
|
177
|
+
|
|
178
|
+
expect(mismatches).toEqual([])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('seeded fuzz', () => {
|
|
182
|
+
// mulberry32 — same generator the other fuzz suites use, so a failure is
|
|
183
|
+
// reproducible from the seed alone.
|
|
184
|
+
function mulberry32(seed: number): () => number {
|
|
185
|
+
let a = seed >>> 0
|
|
186
|
+
return () => {
|
|
187
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
188
|
+
let t = a
|
|
189
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
190
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
191
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const TAGS = ['b', 'i', 'u', 'color=red', 'size=50', 'url=https://osu.ppy.sh',
|
|
196
|
+
'notice', 'centre', 'box=t', 'spoiler', 'list', 'code', 'quote']
|
|
197
|
+
const WORDS = ['osu!', 'peppy', 'combo', 'hola', 'mundo', '[', ']', '[/', 'á€ñ', '🎵']
|
|
198
|
+
const SPACES = [' ', ' ', '\n', '\n\n', '\n\n\n', '\r\n', '']
|
|
199
|
+
|
|
200
|
+
function genDoc(rnd: () => number, depth = 0): string {
|
|
201
|
+
const pick = <T>(xs: T[]): T => xs[Math.floor(rnd() * xs.length)]
|
|
202
|
+
let out = ''
|
|
203
|
+
const n = 1 + Math.floor(rnd() * 6)
|
|
204
|
+
for (let i = 0; i < n; i++) {
|
|
205
|
+
const c = Math.floor(rnd() * 12)
|
|
206
|
+
if (c < 3) out += pick(WORDS) + pick(SPACES)
|
|
207
|
+
else if (c < 6 && depth < 4) {
|
|
208
|
+
const t = pick(TAGS)
|
|
209
|
+
out += `[${t}]${genDoc(rnd, depth + 1)}[/${t.split('=')[0]}]`
|
|
210
|
+
} else if (c < 7) out += `[/${pick(TAGS).split('=')[0]}]` // orphan close
|
|
211
|
+
else if (c < 8 && depth < 4) out += `[${pick(TAGS)}]${genDoc(rnd, depth + 1)}` // unclosed
|
|
212
|
+
else if (c < 9) out += `[${pick(['Gateron', '90 misses', '???'])}]` // unknown tag
|
|
213
|
+
else if (c < 10) out += pick(['[*]', '[*] item', '[[[[', ']]]]'])
|
|
214
|
+
else out += pick(SPACES) + pick(WORDS)
|
|
215
|
+
}
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const seed of [1, 7, 42, 1337, 99999, 0x5eed1a57]) {
|
|
220
|
+
it(`seed ${seed}: 1000 documents partition their source`, () => {
|
|
221
|
+
const rnd = mulberry32(seed)
|
|
222
|
+
for (let i = 0; i < 1000; i++) {
|
|
223
|
+
const source = genDoc(rnd)
|
|
224
|
+
const vs = checkPartition(parseBBCode(source), source.length, {
|
|
225
|
+
limit: 3,
|
|
226
|
+
checkLeafWidths: true,
|
|
227
|
+
})
|
|
228
|
+
if (vs.length > 0) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`seed ${seed} doc #${i}\n src: ${JSON.stringify(source)}\n` +
|
|
231
|
+
vs.map(v => ` ${v.kind} at ${v.path}: ${v.detail}`).join('\n'),
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
})
|