@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,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockPatcher tests — equivalence and incremental correctness.
|
|
3
|
+
*
|
|
4
|
+
* The invariant: `patchBlocksInto(container, root, { renderer })` must produce
|
|
5
|
+
* the same DOM as `morphHTML(container, renderer.render(root))`.
|
|
6
|
+
*
|
|
7
|
+
* data-node-id values are stripped for cross-model comparisons because the
|
|
8
|
+
* global counter makes them differ between separately-constructed models.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
13
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
14
|
+
import { morphHTML } from '../Visitors/DOMMorpher'
|
|
15
|
+
import { patchBlocksInto } from '../Visitors/BlockPatcher'
|
|
16
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
17
|
+
|
|
18
|
+
const renderer = new HTMLRenderer()
|
|
19
|
+
|
|
20
|
+
/** Strip data-node-id attributes so cross-model comparisons are structural. */
|
|
21
|
+
function stripIds(html: string): string {
|
|
22
|
+
return html.replace(/ data-node-id="[^"]*"/g, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Same-model reference: morphHTML on the given model's root. */
|
|
26
|
+
function morphDOM(model: BBCodeDocumentModel): HTMLDivElement {
|
|
27
|
+
const el = document.createElement('div')
|
|
28
|
+
if (model.redRoot) morphHTML(el, renderer.render(model.redRoot))
|
|
29
|
+
return el
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Same-model reference: patchBlocksInto on the given model's root. */
|
|
33
|
+
function patchDOM(model: BBCodeDocumentModel): HTMLDivElement {
|
|
34
|
+
const el = document.createElement('div')
|
|
35
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
36
|
+
return el
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('patchBlocksInto — equivalence', () => {
|
|
40
|
+
const sources = [
|
|
41
|
+
'hola mundo',
|
|
42
|
+
'[box]caja[/box]\n\n[center][b]negrita[/b][/center]\n\nparrafo suelto',
|
|
43
|
+
'[list][*]uno[*]dos[/list]\n[quote]cita[/quote]',
|
|
44
|
+
'[heading]Título[/heading]\n\n[notice]aviso[/notice]\n\nempty_line\n\nfinal',
|
|
45
|
+
'[spoilerbox]contenido oculto[/spoilerbox]',
|
|
46
|
+
'[b]solo bold[/b]',
|
|
47
|
+
'[code]print(hello)[/code]',
|
|
48
|
+
'',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
for (const src of sources) {
|
|
52
|
+
it(`produce el mismo DOM que un morph completo para: "${src.slice(0, 40)}"`, () => {
|
|
53
|
+
const model = new BBCodeDocumentModel({ source: src })
|
|
54
|
+
const elMorph = morphDOM(model)
|
|
55
|
+
const elPatch = patchDOM(model)
|
|
56
|
+
expect(stripIds(elPatch.innerHTML)).toBe(stripIds(elMorph.innerHTML))
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('patchBlocksInto — incremental', () => {
|
|
62
|
+
it('editar texto dentro de un bloque solo parchea ese bloque (mode=blocks, patched≥1)', () => {
|
|
63
|
+
const model = new BBCodeDocumentModel({ source: 'primer parrafo\n\nsegundo parrafo' })
|
|
64
|
+
const el = document.createElement('div')
|
|
65
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
66
|
+
|
|
67
|
+
// Editar el segundo párrafo
|
|
68
|
+
model.applyTextUpdate('primer parrafo\n\nsegundo parrafo EDITADO')
|
|
69
|
+
model.ensureAnalyzed()
|
|
70
|
+
|
|
71
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
72
|
+
expect(stats.mode).toBe('blocks')
|
|
73
|
+
expect(stats.patched).toBeGreaterThanOrEqual(1)
|
|
74
|
+
|
|
75
|
+
// El DOM resultante debe ser idéntico al morph completo del mismo modelo
|
|
76
|
+
const elExpected = morphDOM(model)
|
|
77
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('no re-morph cuando el contenido no cambió (patched=0, mode=blocks)', () => {
|
|
81
|
+
const model = new BBCodeDocumentModel({ source: '[box]a[/box]\n\nparrafo b' })
|
|
82
|
+
const el = document.createElement('div')
|
|
83
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
84
|
+
|
|
85
|
+
// Editar un bloque distinto (el parrafo); el box debe quedar intacto
|
|
86
|
+
model.applyTextUpdate('[box]a[/box]\n\nparrafo EDITADO')
|
|
87
|
+
model.ensureAnalyzed()
|
|
88
|
+
|
|
89
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
90
|
+
expect(stats.mode).toBe('blocks')
|
|
91
|
+
// El box no cambió, el parrafo sí → patched 1 (solo el parrafo)
|
|
92
|
+
expect(stats.patched).toBe(1)
|
|
93
|
+
|
|
94
|
+
// Aplicar el mismo cambio otra vez (no-op): el cache ya tiene el HTML
|
|
95
|
+
// del parrafo editado → patched 0
|
|
96
|
+
model.applyTextUpdate('[box]a[/box]\n\nparrafo EDITADO')
|
|
97
|
+
model.ensureAnalyzed()
|
|
98
|
+
const stats2 = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
99
|
+
expect(stats2.mode).toBe('blocks')
|
|
100
|
+
expect(stats2.patched).toBe(0)
|
|
101
|
+
|
|
102
|
+
// Y el DOM final equivale al morph completo
|
|
103
|
+
const elExpected = morphDOM(model)
|
|
104
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('una inserción de bloque inserta solo el bloque nuevo (mode=blocks, patched≥1)', () => {
|
|
108
|
+
const model = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc' })
|
|
109
|
+
const el = document.createElement('div')
|
|
110
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
111
|
+
|
|
112
|
+
model.applyTextUpdate('[heading]Título nuevo[/heading]\n\na\n\nb\n\nc')
|
|
113
|
+
model.ensureAnalyzed()
|
|
114
|
+
|
|
115
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
116
|
+
expect(stats.mode).toBe('blocks')
|
|
117
|
+
expect(stats.patched).toBeGreaterThanOrEqual(1)
|
|
118
|
+
|
|
119
|
+
const elExpected = morphDOM(model)
|
|
120
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('una eliminación de bloque quita solo los bloques afectados (mode=blocks)', () => {
|
|
124
|
+
const model = new BBCodeDocumentModel({ source: '[box]a[/box]\n\n[box]b[/box]' })
|
|
125
|
+
const el = document.createElement('div')
|
|
126
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
127
|
+
const boxA = el.querySelector('details')
|
|
128
|
+
|
|
129
|
+
model.applyTextUpdate('[box]a[/box]')
|
|
130
|
+
model.ensureAnalyzed()
|
|
131
|
+
|
|
132
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
133
|
+
expect(stats.mode).toBe('blocks')
|
|
134
|
+
// El box que sobrevive es el MISMO nodo DOM (identidad intacta).
|
|
135
|
+
expect(el.querySelector('details')).toBe(boxA)
|
|
136
|
+
|
|
137
|
+
const elExpected = morphDOM(model)
|
|
138
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('árbol nulo limpia el contenedor', () => {
|
|
142
|
+
const el = document.createElement('div')
|
|
143
|
+
el.innerHTML = '<p>viejo</p>'
|
|
144
|
+
const stats = patchBlocksInto(el, null, { renderer })
|
|
145
|
+
expect(el.innerHTML).toBe('')
|
|
146
|
+
expect(stats.mode).toBe('full')
|
|
147
|
+
expect(stats.total).toBe(0)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('nodos sin id (spacing/empty_line) se reemplazan sin romper la estructura', () => {
|
|
151
|
+
const model = new BBCodeDocumentModel({ source: 'uno\n\n\n\n\ncinco' })
|
|
152
|
+
const el = patchDOM(model)
|
|
153
|
+
const elExpected = morphDOM(model)
|
|
154
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('patchBlocksInto — keyed reconciliation', () => {
|
|
159
|
+
it('insertar un bloque al inicio conserva los demás: la caja abierta sigue abierta y es el MISMO nodo', () => {
|
|
160
|
+
const model = new BBCodeDocumentModel({ source: '[box]uno[/box]\n\n[box]dos[/box]' })
|
|
161
|
+
const el = document.createElement('div')
|
|
162
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
163
|
+
|
|
164
|
+
const details = el.querySelectorAll('details')
|
|
165
|
+
const box1 = details[0]
|
|
166
|
+
const box2 = details[1]
|
|
167
|
+
;(box1 as HTMLDetailsElement).open = true
|
|
168
|
+
|
|
169
|
+
// Cambio estructural real: prepend de un heading (como Enter al inicio).
|
|
170
|
+
model.applyTextUpdate('[heading]Top[/heading]\n\n[box]uno[/box]\n\n[box]dos[/box]')
|
|
171
|
+
model.ensureAnalyzed()
|
|
172
|
+
|
|
173
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
174
|
+
expect(stats.mode).toBe('blocks')
|
|
175
|
+
|
|
176
|
+
const after = el.querySelectorAll('details')
|
|
177
|
+
expect(after.length).toBe(2)
|
|
178
|
+
// Los boxes se desplazaron pero NO se reconstruyeron: mismo nodo, mismo estado.
|
|
179
|
+
expect(after[0]).toBe(box1)
|
|
180
|
+
expect((after[0] as HTMLDetailsElement).open).toBe(true)
|
|
181
|
+
expect(after[1]).toBe(box2)
|
|
182
|
+
expect(el.querySelector('h2')?.textContent).toBe('Top')
|
|
183
|
+
|
|
184
|
+
// El patch preserva el estado runtime (open) que un render fresco no tiene:
|
|
185
|
+
// la equivalencia estructural se compara ignorando ese atributo.
|
|
186
|
+
const elExpected = morphDOM(model)
|
|
187
|
+
expect(stripIds(el.innerHTML).replace(/ open=""/g, '')).toBe(stripIds(elExpected.innerHTML))
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('reordenar un bloque mueve el MISMO elemento DOM (estado runtime conservado)', () => {
|
|
191
|
+
const model = new BBCodeDocumentModel({ source: '[box]uno[/box]\n\n[box]dos[/box]\n\n[box]tres[/box]' })
|
|
192
|
+
const el = document.createElement('div')
|
|
193
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
194
|
+
|
|
195
|
+
const details = el.querySelectorAll('details')
|
|
196
|
+
const one = details[0]
|
|
197
|
+
const three = details[2]
|
|
198
|
+
;(one as HTMLDetailsElement).open = true
|
|
199
|
+
|
|
200
|
+
// Mover 'uno' al final con la misma API del visual builder (removeChild +
|
|
201
|
+
// insertChildAt), sin re-parsear.
|
|
202
|
+
const root = model.redRoot!
|
|
203
|
+
RedNode.allowMutation(() => {
|
|
204
|
+
const firstBox = root.children.find((c) => c.kind === 'box')!
|
|
205
|
+
root.removeChild(firstBox.id)
|
|
206
|
+
root.insertChildAt(root.children.length, firstBox)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
210
|
+
expect(stats.mode).toBe('blocks')
|
|
211
|
+
|
|
212
|
+
const after = el.querySelectorAll('details')
|
|
213
|
+
expect(after.length).toBe(3)
|
|
214
|
+
// El MISMO nodo DOM llegó al final — abierto sigue abierto.
|
|
215
|
+
expect(after[2]).toBe(one)
|
|
216
|
+
expect((after[2] as HTMLDetailsElement).open).toBe(true)
|
|
217
|
+
expect(after[1]).toBe(three)
|
|
218
|
+
|
|
219
|
+
// El DOM final equivale al morph completo del mismo árbol, ignorando el
|
|
220
|
+
// `open` que el patch conservó (un render fresco no conoce el estado).
|
|
221
|
+
const elExpected = document.createElement('div')
|
|
222
|
+
morphHTML(elExpected, renderer.render(model.redRoot!))
|
|
223
|
+
expect(stripIds(el.innerHTML).replace(/ open=""/g, '')).toBe(stripIds(elExpected.innerHTML))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('un documento nuevo en el mismo contenedor (ids regenerados) cae a full rebuild', () => {
|
|
227
|
+
const modelA = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc' })
|
|
228
|
+
const modelB = new BBCodeDocumentModel({ source: 'x\n\ny\n\nz\n\nw' })
|
|
229
|
+
const el = document.createElement('div')
|
|
230
|
+
patchBlocksInto(el, modelA.redRoot!, { renderer })
|
|
231
|
+
|
|
232
|
+
// Model B es otra instancia: sus ids (contador global) son todos nuevos.
|
|
233
|
+
const stats = patchBlocksInto(el, modelB.redRoot!, { renderer })
|
|
234
|
+
expect(stats.mode).toBe('full')
|
|
235
|
+
|
|
236
|
+
const elExpected = morphDOM(modelB)
|
|
237
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('el cache no acumula entradas de bloques eliminados (prune tras keyed)', () => {
|
|
241
|
+
const model = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc\n\nd' })
|
|
242
|
+
const el = document.createElement('div')
|
|
243
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
244
|
+
|
|
245
|
+
model.applyTextUpdate('a\n\nb\n\nc')
|
|
246
|
+
model.ensureAnalyzed()
|
|
247
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
248
|
+
|
|
249
|
+
model.applyTextUpdate('a\n\nb')
|
|
250
|
+
model.ensureAnalyzed()
|
|
251
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
252
|
+
|
|
253
|
+
const elExpected = morphDOM(model)
|
|
254
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('insertar al inicio de un doc grande NO mueve los bloques sobrevivientes (regresión 6.2s)', () => {
|
|
258
|
+
// El bug medido en Chrome con el doc de 40k líneas: teclear al INICIO
|
|
259
|
+
// re-identaba el primer bloque, su run viejo quedaba huérfano en la cola,
|
|
260
|
+
// y el reconciler movía CADA run sobreviviente un slot (79997 insertBefore
|
|
261
|
+
// ≈ 6.2s). El fix elimina el huérfano ANTES del loop y camina el anchor
|
|
262
|
+
// con nextSibling → un shift puro mueve 0 nodos.
|
|
263
|
+
//
|
|
264
|
+
// Test determinista con un MutationObserver: el DOM solo debe ver la
|
|
265
|
+
// eliminación del huérfano + la inserción del bloque nuevo — NO N mutaciones
|
|
266
|
+
// proporcionales al tamaño del documento.
|
|
267
|
+
const lines: string[] = []
|
|
268
|
+
for (let i = 0; i < 400; i++) lines.push(`linea ${i}`)
|
|
269
|
+
const src = lines.join('\n')
|
|
270
|
+
|
|
271
|
+
const model = new BBCodeDocumentModel({ source: src })
|
|
272
|
+
const el = document.createElement('div')
|
|
273
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
274
|
+
const runCount = model.redRoot!.children.length
|
|
275
|
+
expect(runCount).toBeGreaterThan(100)
|
|
276
|
+
|
|
277
|
+
// Insertar texto al inicio (equivale a teclear en la primera línea).
|
|
278
|
+
model.applyTextUpdate('AAA' + src)
|
|
279
|
+
model.ensureAnalyzed()
|
|
280
|
+
|
|
281
|
+
// `takeRecords()` (no el callback): el callback es un microtask que corre
|
|
282
|
+
// DESPUÉS de este bloque síncrono, y `disconnect()` descarta la cola — así
|
|
283
|
+
// que contar en el callback siempre daría 0 (test vacuo). takeRecords lee
|
|
284
|
+
// la cola síncronamente, justo tras el patch.
|
|
285
|
+
const mo = new MutationObserver(() => {})
|
|
286
|
+
mo.observe(el, { childList: true })
|
|
287
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
288
|
+
const mutations = mo.takeRecords().length
|
|
289
|
+
mo.disconnect()
|
|
290
|
+
|
|
291
|
+
expect(stats.mode).toBe('blocks')
|
|
292
|
+
// O(1) en el número de bloques: huérfano (1) + bloque nuevo (1) — no O(n).
|
|
293
|
+
// Tolerancia a la implementación: NUNCA proporcional a los 400 bloques.
|
|
294
|
+
expect(mutations).toBeLessThanOrEqual(4)
|
|
295
|
+
|
|
296
|
+
const elExpected = morphDOM(model)
|
|
297
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('borrar al inicio de un doc grande también es O(1) en mutaciones DOM', () => {
|
|
301
|
+
const lines: string[] = []
|
|
302
|
+
for (let i = 0; i < 400; i++) lines.push(`linea ${i}`)
|
|
303
|
+
const src = lines.join('\n')
|
|
304
|
+
|
|
305
|
+
const model = new BBCodeDocumentModel({ source: 'AAA' + src })
|
|
306
|
+
const el = document.createElement('div')
|
|
307
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
308
|
+
|
|
309
|
+
// Eliminar el prefijo: el primer bloque (con AAA) desaparece.
|
|
310
|
+
model.applyTextUpdate(src)
|
|
311
|
+
model.ensureAnalyzed()
|
|
312
|
+
|
|
313
|
+
const mo = new MutationObserver(() => {})
|
|
314
|
+
mo.observe(el, { childList: true })
|
|
315
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
316
|
+
const mutations = mo.takeRecords().length
|
|
317
|
+
mo.disconnect()
|
|
318
|
+
|
|
319
|
+
expect(stats.mode).toBe('blocks')
|
|
320
|
+
expect(mutations).toBeLessThanOrEqual(4)
|
|
321
|
+
|
|
322
|
+
const elExpected = morphDOM(model)
|
|
323
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('patchBlocksInto — text runs (HTML text-node merging)', () => {
|
|
328
|
+
it('un doc con text run (spacing+empty_line \'\n\' antes de [code]) mantiene la alineación', () => {
|
|
329
|
+
// El \n\n antes de [code] renderiza spacing('\n') + empty_line('\n'): dos
|
|
330
|
+
// bloques de texto adyacentes que el parser de HTML fusiona en UN textNode.
|
|
331
|
+
// La reconciliación debe alinearse contra ese nodo fusionado sin desviarse.
|
|
332
|
+
const model = new BBCodeDocumentModel({ source: '[quote]cita[/quote]\n\n[code]x[/code]' })
|
|
333
|
+
const el = document.createElement('div')
|
|
334
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
335
|
+
expect(el.childNodes.length).toBeLessThan(model.redRoot!.children.length)
|
|
336
|
+
|
|
337
|
+
// Editar DENTRO del quote (después del run): los nodos no deben desalinearse.
|
|
338
|
+
model.applyTextUpdate('[quote]cita EDITADA[/quote]\n\n[code]x[/code]')
|
|
339
|
+
model.ensureAnalyzed()
|
|
340
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
341
|
+
expect(stats.mode).toBe('blocks')
|
|
342
|
+
expect(stats.patched).toBeGreaterThanOrEqual(1)
|
|
343
|
+
|
|
344
|
+
const elExpected = morphDOM(model)
|
|
345
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('cambiar el contenido del text run mismo lo actualiza sin romper la alineación', () => {
|
|
349
|
+
const model = new BBCodeDocumentModel({ source: '[code]a[/code]\n\n[code]b[/code]' })
|
|
350
|
+
const el = document.createElement('div')
|
|
351
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
352
|
+
|
|
353
|
+
// Más líneas en blanco → el text run crece de '\n\n' a '\n\n\n\n'.
|
|
354
|
+
model.applyTextUpdate('[code]a[/code]\n\n\n\n[code]b[/code]')
|
|
355
|
+
model.ensureAnalyzed()
|
|
356
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
357
|
+
expect(stats.mode).toBe('blocks')
|
|
358
|
+
|
|
359
|
+
const elExpected = morphDOM(model)
|
|
360
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe('patchBlocksInto — casos borde', () => {
|
|
365
|
+
it('varias ediciones rápidas en el mismo bloque acumulan un solo patch', () => {
|
|
366
|
+
const model = new BBCodeDocumentModel({ source: 'x' })
|
|
367
|
+
const el = document.createElement('div')
|
|
368
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
369
|
+
|
|
370
|
+
model.applyTextUpdate('xx'); model.ensureAnalyzed()
|
|
371
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
372
|
+
|
|
373
|
+
model.applyTextUpdate('xxx'); model.ensureAnalyzed()
|
|
374
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
375
|
+
|
|
376
|
+
expect(stats.mode).toBe('blocks')
|
|
377
|
+
const elExpected = morphDOM(model)
|
|
378
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('una imagen que cambia de src se actualiza (elemento hoja se reemplaza)', () => {
|
|
382
|
+
const model = new BBCodeDocumentModel({
|
|
383
|
+
source: '[img]https://a.example/1.png[/img]',
|
|
384
|
+
})
|
|
385
|
+
const el = document.createElement('div')
|
|
386
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
387
|
+
|
|
388
|
+
model.applyTextUpdate('[img]https://b.example/2.png[/img]')
|
|
389
|
+
model.ensureAnalyzed()
|
|
390
|
+
|
|
391
|
+
const stats = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
392
|
+
expect(stats.mode).toBe('blocks')
|
|
393
|
+
// El <img> es un bloque hoja: la URL debe haber cambiado en el DOM
|
|
394
|
+
expect(el.querySelector('img')!.getAttribute('src')).toBe('https://b.example/2.png')
|
|
395
|
+
|
|
396
|
+
const elExpected = morphDOM(model)
|
|
397
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('un renderer que lanza en el render de un bloque cae a full rebuild sin lanzar al llamante', () => {
|
|
401
|
+
const model = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc' })
|
|
402
|
+
const el = document.createElement('div')
|
|
403
|
+
patchBlocksInto(el, model.redRoot!, { renderer })
|
|
404
|
+
|
|
405
|
+
// Renderer que lanza UNA vez en el primer render tras la edición: el
|
|
406
|
+
// camino incremental debe atraparlo (onError) y reconstruir completo,
|
|
407
|
+
// nunca propagar la excepción hacia el Preview.
|
|
408
|
+
const throwing = new HTMLRenderer()
|
|
409
|
+
const orig = throwing.render.bind(throwing)
|
|
410
|
+
let armed = true
|
|
411
|
+
;(throwing as unknown as { render: (n: RedNode) => string }).render = (n: RedNode) => {
|
|
412
|
+
if (armed) { armed = false; throw new Error('boom') }
|
|
413
|
+
return orig(n)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let notified: unknown = null
|
|
417
|
+
model.applyTextUpdate('a\n\nb EDITADO\n\nc')
|
|
418
|
+
model.ensureAnalyzed()
|
|
419
|
+
const stats = patchBlocksInto(el, model.redRoot!, {
|
|
420
|
+
renderer: throwing,
|
|
421
|
+
onError: (err) => { notified = err },
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
expect(notified).toBeInstanceOf(Error)
|
|
425
|
+
expect(stats.mode).toBe('full')
|
|
426
|
+
expect(el.textContent).toContain('b EDITADO')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('el cache se limpia automáticamente cuando el contenedor se descarta (WeakMap)', () => {
|
|
430
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
431
|
+
const el1 = document.createElement('div')
|
|
432
|
+
const el2 = document.createElement('div')
|
|
433
|
+
patchBlocksInto(el1, model.redRoot!, { renderer })
|
|
434
|
+
patchBlocksInto(el2, model.redRoot!, { renderer })
|
|
435
|
+
expect(stripIds(el1.innerHTML)).toBe(stripIds(el2.innerHTML))
|
|
436
|
+
})
|
|
437
|
+
})
|