@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,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockPatcher — windowed (O(edit)) reconciliation tests.
|
|
3
|
+
*
|
|
4
|
+
* The windowed path is the 500k-character fast path: when the model knows the
|
|
5
|
+
* source range of the edit (`TextChangeRange`, attached to the root as
|
|
6
|
+
* `__changeRange` by `DocumentModel.applyTextUpdate`), `patchBlocksInto`
|
|
7
|
+
* reconciles ONLY the runs overlapping that range instead of walking every
|
|
8
|
+
* run of the document.
|
|
9
|
+
*
|
|
10
|
+
* The invariant is the same as the full path: the patched DOM must equal
|
|
11
|
+
* `morphHTML(container, renderer.render(root))`. The differential loops below
|
|
12
|
+
* check that after EVERY edit — the windowed path must never leave the DOM
|
|
13
|
+
* diverging, no matter where the edit lands (start, middle, end, run
|
|
14
|
+
* boundaries, block insertions/deletions).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
19
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
20
|
+
import { morphHTML } from '../Visitors/DOMMorpher'
|
|
21
|
+
import { patchBlocksInto } from '../Visitors/BlockPatcher'
|
|
22
|
+
|
|
23
|
+
const renderer = new HTMLRenderer()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Force the windowed path on small documents. Production defaults to
|
|
27
|
+
* `MIN_WINDOWED_BLOCKS` (200) — below it the full keyed walk beats the
|
|
28
|
+
* windowed bookkeeping — but these tests must exercise the window machinery
|
|
29
|
+
* itself, so they opt in with `0`.
|
|
30
|
+
*/
|
|
31
|
+
const WIN_OPTS = { renderer, minWindowedBlocks: 0 }
|
|
32
|
+
|
|
33
|
+
/** Strip data-node-id attributes so cross-model comparisons are structural. */
|
|
34
|
+
function stripIds(html: string): string {
|
|
35
|
+
return html.replace(/ data-node-id="[^"]*"/g, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Same-model reference: morphHTML on the given model's root. */
|
|
39
|
+
function morphDOM(model: BBCodeDocumentModel): HTMLDivElement {
|
|
40
|
+
const el = document.createElement('div')
|
|
41
|
+
if (model.redRoot) morphHTML(el, renderer.render(model.redRoot))
|
|
42
|
+
return el
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Un documento GRANDE (≥2500 chars) con `n` párrafos de relleno ANTES del
|
|
47
|
+
* contenido real.
|
|
48
|
+
*
|
|
49
|
+
* El camino por ventana exige DOS cosas que solo pasan en documentos grandes:
|
|
50
|
+
* 1. el parser incremental (umbral `MIN_SOURCE_LENGTH` = 2500 chars) — sin
|
|
51
|
+
* reparse incremental no hay green-sharing ni reference-identity;
|
|
52
|
+
* 2. que la ventana de edición sea menor que la mitad del documento.
|
|
53
|
+
* El relleno da ambas a la vez: los bloques fuera de la ventana son los MISMOS
|
|
54
|
+
* objetos RedNode que cacheó el patch anterior.
|
|
55
|
+
*/
|
|
56
|
+
function padded(prefix: string, n = 240): string {
|
|
57
|
+
const filler: string[] = []
|
|
58
|
+
for (let i = 0; i < n; i++) filler.push(`relleno ${i}`)
|
|
59
|
+
return filler.join('\n') + '\n\n' + prefix
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('patchBlocksInto — windowed path (model-attached change range)', () => {
|
|
63
|
+
it('editar texto en medio corre por ventana (stats.windowed=true) y equivale al morph', () => {
|
|
64
|
+
const model = new BBCodeDocumentModel({
|
|
65
|
+
source: padded('uno\n\n[box]caja[/box]\n\n[quote]cita[/quote]\n\nfinal'),
|
|
66
|
+
})
|
|
67
|
+
const el = document.createElement('div')
|
|
68
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
69
|
+
|
|
70
|
+
model.applyTextUpdate(padded('uno\n\n[box]caja EDITADA[/box]\n\n[quote]cita[/quote]\n\nfinal'))
|
|
71
|
+
model.ensureAnalyzed()
|
|
72
|
+
|
|
73
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
74
|
+
expect(stats.mode).toBe('blocks')
|
|
75
|
+
expect(stats.windowed).toBe(true)
|
|
76
|
+
expect(stats.patched).toBeGreaterThanOrEqual(1)
|
|
77
|
+
// El rango viaja con el root: el modelo lo adjuntó tras applyTextUpdate.
|
|
78
|
+
expect((model.redRoot as unknown as { __changeRange?: unknown }).__changeRange).toBeTruthy()
|
|
79
|
+
|
|
80
|
+
const elExpected = morphDOM(model)
|
|
81
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('insertar al inicio es O(1) en mutaciones DOM a 400 bloques (regresión 500k)', () => {
|
|
85
|
+
const lines: string[] = []
|
|
86
|
+
for (let i = 0; i < 400; i++) lines.push(`linea ${i}`)
|
|
87
|
+
const src = lines.join('\n')
|
|
88
|
+
|
|
89
|
+
const model = new BBCodeDocumentModel({ source: src })
|
|
90
|
+
const el = document.createElement('div')
|
|
91
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
92
|
+
|
|
93
|
+
model.applyTextUpdate('AAA' + src)
|
|
94
|
+
model.ensureAnalyzed()
|
|
95
|
+
|
|
96
|
+
const mo = new MutationObserver(() => {})
|
|
97
|
+
mo.observe(el, { childList: true })
|
|
98
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
99
|
+
const mutations = mo.takeRecords().length
|
|
100
|
+
mo.disconnect()
|
|
101
|
+
|
|
102
|
+
expect(stats.windowed).toBe(true)
|
|
103
|
+
// O(1): huérfano (1) + bloque nuevo (1) + margen del parser — nunca O(n).
|
|
104
|
+
expect(mutations).toBeLessThanOrEqual(6)
|
|
105
|
+
|
|
106
|
+
const elExpected = morphDOM(model)
|
|
107
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('bloques FUERA de la ventana conservan su identidad DOM y estado runtime', () => {
|
|
111
|
+
// Box a al INICIO, relleno en el medio, box c al FINAL. Editar un párrafo
|
|
112
|
+
// del relleno deja ambos boxes fuera de la ventana de edición.
|
|
113
|
+
const filler: string[] = []
|
|
114
|
+
for (let i = 0; i < 240; i++) filler.push(`relleno ${i}`)
|
|
115
|
+
const body = filler.join('\n')
|
|
116
|
+
|
|
117
|
+
const model = new BBCodeDocumentModel({ source: `[box]a[/box]\n\n${body}\n\n[box]c[/box]` })
|
|
118
|
+
const el = document.createElement('div')
|
|
119
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
120
|
+
|
|
121
|
+
const details = el.querySelectorAll('details')
|
|
122
|
+
const boxA = details[0]
|
|
123
|
+
const boxC = details[1]
|
|
124
|
+
;(boxA as HTMLDetailsElement).open = true
|
|
125
|
+
|
|
126
|
+
model.applyTextUpdate(`[box]a[/box]\n\n${body.replace('relleno 5', 'relleno 5 EDITADO')}\n\n[box]c[/box]`)
|
|
127
|
+
model.ensureAnalyzed()
|
|
128
|
+
|
|
129
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
130
|
+
expect(stats.windowed).toBe(true)
|
|
131
|
+
|
|
132
|
+
const after = el.querySelectorAll('details')
|
|
133
|
+
// Fuera de la ventana: ni se movieron ni se reconstruyeron.
|
|
134
|
+
expect(after[0]).toBe(boxA)
|
|
135
|
+
expect((after[0] as HTMLDetailsElement).open).toBe(true)
|
|
136
|
+
expect(after[1]).toBe(boxC)
|
|
137
|
+
// Y el contenido editado del medio sí llegó.
|
|
138
|
+
expect(el.textContent).toContain('relleno 5 EDITADO')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('borrar una región ENTERA del medio (ventana antigua por endOld) no deja nodos fantasma', () => {
|
|
142
|
+
// El escenario que rompía un windowing por índice: borrar muchos bloques
|
|
143
|
+
// del medio. La ventana vieja se ubica con endOld (coordenadas viejas),
|
|
144
|
+
// así que todos los runs eliminados quedan dentro y se quitan del DOM.
|
|
145
|
+
const lines: string[] = []
|
|
146
|
+
for (let i = 0; i < 200; i++) lines.push(`linea ${i}`)
|
|
147
|
+
const src = ['[box]inicio[/box]', ...lines, '[box]fin[/box]'].join('\n')
|
|
148
|
+
|
|
149
|
+
const model = new BBCodeDocumentModel({ source: src })
|
|
150
|
+
const el = document.createElement('div')
|
|
151
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
152
|
+
|
|
153
|
+
// Borrar las 200 líneas del medio.
|
|
154
|
+
const newSrc = '[box]inicio[/box]\n[box]fin[/box]'
|
|
155
|
+
model.applyTextUpdate(newSrc)
|
|
156
|
+
model.ensureAnalyzed()
|
|
157
|
+
|
|
158
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
159
|
+
// La ventana cubre casi todo el doc viejo → full reconcile es lo correcto,
|
|
160
|
+
// pero el DOM debe quedar exacto (sin nodos fantasma).
|
|
161
|
+
expect(stats.mode).toBe('blocks')
|
|
162
|
+
|
|
163
|
+
const elExpected = morphDOM(model)
|
|
164
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
165
|
+
// Exactamente 2 details, no 202.
|
|
166
|
+
expect(el.querySelectorAll('details').length).toBe(2)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('cambiar el text run antes de [code] (fusión de textNodes) mantiene la alineación por ventana', () => {
|
|
170
|
+
const model = new BBCodeDocumentModel({ source: padded('[quote]cita[/quote]\n\n[code]x[/code]') })
|
|
171
|
+
const el = document.createElement('div')
|
|
172
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
173
|
+
|
|
174
|
+
// Más líneas en blanco → el run de '\n\n' crece a '\n\n\n\n'.
|
|
175
|
+
model.applyTextUpdate(padded('[quote]cita[/quote]\n\n\n\n[code]x[/code]'))
|
|
176
|
+
model.ensureAnalyzed()
|
|
177
|
+
|
|
178
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
179
|
+
expect(stats.windowed).toBe(true)
|
|
180
|
+
|
|
181
|
+
const elExpected = morphDOM(model)
|
|
182
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('append al final (cambio tras el último bloque) funciona por ventana', () => {
|
|
186
|
+
const model = new BBCodeDocumentModel({ source: padded('uno\n\ndos\n\ntres') })
|
|
187
|
+
const el = document.createElement('div')
|
|
188
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
189
|
+
|
|
190
|
+
model.applyTextUpdate(padded('uno\n\ndos\n\ntres\n\ncuatro agregado'))
|
|
191
|
+
model.ensureAnalyzed()
|
|
192
|
+
|
|
193
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
194
|
+
expect(stats.windowed).toBe(true)
|
|
195
|
+
|
|
196
|
+
const elExpected = morphDOM(model)
|
|
197
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('backspace al final (edición en EOF, ningún bloque nuevo extiende el rango) no crashea', () => {
|
|
201
|
+
// Regresión: en EOF el árbol NUEVO no tiene ningún bloque con
|
|
202
|
+
// `range.end > change.start` (el último bloque termina justo en el punto
|
|
203
|
+
// de la edición) → `firstChanged` quedaba en `n` → `winEnd = n+1` →
|
|
204
|
+
// `blocks[winEnd - 1]` era undefined (TypeError fuera del try/catch).
|
|
205
|
+
const model = new BBCodeDocumentModel({ source: padded('uno\n\ndos\n\nfinal') })
|
|
206
|
+
const el = document.createElement('div')
|
|
207
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
208
|
+
|
|
209
|
+
// Borrar la última letra: el nuevo último bloque termina EXACTO en el
|
|
210
|
+
// punto de la edición (caso que antes crasheaba).
|
|
211
|
+
model.applyTextUpdate(padded('uno\n\ndos\n\nfina'))
|
|
212
|
+
model.ensureAnalyzed()
|
|
213
|
+
|
|
214
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
215
|
+
expect(stats.windowed).toBe(true)
|
|
216
|
+
|
|
217
|
+
const elExpected = morphDOM(model)
|
|
218
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
219
|
+
expect(el.textContent).toContain('fina')
|
|
220
|
+
expect(el.textContent).not.toContain('final')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('borrar el último bloque completo (región de cola) funciona por ventana', () => {
|
|
224
|
+
const model = new BBCodeDocumentModel({ source: padded('uno\n\ndos\n\n[box]caja final[/box]') })
|
|
225
|
+
const el = document.createElement('div')
|
|
226
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
227
|
+
|
|
228
|
+
// Quitar el último bloque: el rango de cambio llega hasta el final del doc.
|
|
229
|
+
model.applyTextUpdate(padded('uno\n\ndos'))
|
|
230
|
+
model.ensureAnalyzed()
|
|
231
|
+
|
|
232
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
233
|
+
expect(stats.windowed).toBe(true)
|
|
234
|
+
|
|
235
|
+
const elExpected = morphDOM(model)
|
|
236
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
237
|
+
expect(el.querySelectorAll('details').length).toBe(0)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('un editor completo de mitad de doc (ventana enorme) cae al camino full y es correcto', () => {
|
|
241
|
+
const model = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc\n\nd\n\ne' })
|
|
242
|
+
const el = document.createElement('div')
|
|
243
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
244
|
+
|
|
245
|
+
// Reemplazar casi todo: la ventana supera la mitad → full reconcile.
|
|
246
|
+
model.applyTextUpdate('a\n\n[heading]NUEVO[/heading]\n\n[box]contenido[/box]\n\nz')
|
|
247
|
+
model.ensureAnalyzed()
|
|
248
|
+
|
|
249
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
250
|
+
expect(stats.windowed).toBeUndefined()
|
|
251
|
+
|
|
252
|
+
const elExpected = morphDOM(model)
|
|
253
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('un documento nuevo en el mismo contenedor (hint presente pero todo churn) cae al camino full', () => {
|
|
257
|
+
const modelA = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc' })
|
|
258
|
+
const el = document.createElement('div')
|
|
259
|
+
patchBlocksInto(el, modelA.redRoot!, WIN_OPTS)
|
|
260
|
+
|
|
261
|
+
// Model B: otra instancia, ids completamente nuevos. Pasar un change chico
|
|
262
|
+
// (hint desactualizado/incompatible) debe ser detectado por el churn guard
|
|
263
|
+
// (bloques fuera de la ventana no son reference-identical) → full reconcile.
|
|
264
|
+
const modelB = new BBCodeDocumentModel({ source: 'x\n\ny\n\nz\n\nw' })
|
|
265
|
+
const stats = patchBlocksInto(el, modelB.redRoot!, {
|
|
266
|
+
renderer,
|
|
267
|
+
minWindowedBlocks: 0,
|
|
268
|
+
change: { start: 1, end: 2, endOld: 2 },
|
|
269
|
+
})
|
|
270
|
+
expect(stats.windowed).toBeUndefined()
|
|
271
|
+
expect(stats.mode).toBe('full')
|
|
272
|
+
|
|
273
|
+
const elExpected = morphDOM(modelB)
|
|
274
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(elExpected.innerHTML))
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('secuencia diferencial: el DOM patcheado equivale al morph completo tras cada edición', () => {
|
|
278
|
+
// Doc GRANDE para que el reparse incremental (≥2500 chars) active el
|
|
279
|
+
// camino por ventana en cada paso: el relleno nunca cambia, así que todos
|
|
280
|
+
// los bloques fuera de la ventana son reference-identical.
|
|
281
|
+
const big = padded('')
|
|
282
|
+
// Cada fuente es el resultado realista de la edición anterior: inline en
|
|
283
|
+
// el medio, prepend (re-key del primer bloque), cambio de elemento con
|
|
284
|
+
// hijos, append, borrado de bloque, insert tipo Enter, run boundaries,
|
|
285
|
+
// borrado de bloque del medio, edición de [code].
|
|
286
|
+
const sources = [
|
|
287
|
+
'[box]a[/box]\n\nprimer parrafo\n\n[quote]cita original[/quote]\n\n[code]code[/code]\n\nfinal',
|
|
288
|
+
// inline en el medio
|
|
289
|
+
'[box]a[/box]\n\nprimer parrafo EDITADO\n\n[quote]cita original[/quote]\n\n[code]code[/code]\n\nfinal',
|
|
290
|
+
// prepend de un heading
|
|
291
|
+
'[heading]Top[/heading]\n\n[box]a[/box]\n\nprimer parrafo EDITADO\n\n[quote]cita original[/quote]\n\n[code]code[/code]\n\nfinal',
|
|
292
|
+
// cambio de quote (elemento con hijos)
|
|
293
|
+
'[heading]Top[/heading]\n\n[box]a[/box]\n\nprimer parrafo EDITADO\n\n[quote]cita MODIFICADA[/quote]\n\n[code]code[/code]\n\nfinal',
|
|
294
|
+
// append al final
|
|
295
|
+
'[heading]Top[/heading]\n\n[box]a[/box]\n\nprimer parrafo EDITADO\n\n[quote]cita MODIFICADA[/quote]\n\n[code]code[/code]\n\nfinal\n\nultimo bloque nuevo',
|
|
296
|
+
// borrar el box inicial
|
|
297
|
+
'[heading]Top[/heading]\n\nprimer parrafo EDITADO\n\n[quote]cita MODIFICADA[/quote]\n\n[code]code[/code]\n\nfinal\n\nultimo bloque nuevo',
|
|
298
|
+
// insert tipo Enter en el medio
|
|
299
|
+
'[heading]Top[/heading]\n\nprimer parrafo EDITADO\n\n[center]centrado[/center]\n\n[quote]cita MODIFICADA[/quote]\n\n[code]code[/code]\n\nfinal\n\nultimo bloque nuevo',
|
|
300
|
+
// crecer el text run antes de [code]
|
|
301
|
+
'[heading]Top[/heading]\n\nprimer parrafo EDITADO\n\n[center]centrado[/center]\n\n[quote]cita MODIFICADA[/quote]\n\n\n\n\n[code]code[/code]\n\nfinal\n\nultimo bloque nuevo',
|
|
302
|
+
// borrar el centro
|
|
303
|
+
'[heading]Top[/heading]\n\nprimer parrafo EDITADO\n\n[quote]cita MODIFICADA[/quote]\n\n\n\n\n[code]code[/code]\n\nfinal\n\nultimo bloque nuevo',
|
|
304
|
+
// editar el código
|
|
305
|
+
'[heading]Top[/heading]\n\nprimer parrafo EDITADO\n\n[quote]cita MODIFICADA[/quote]\n\n\n\n\n[code]code EDITADO[/code]\n\nfinal\n\nultimo bloque nuevo',
|
|
306
|
+
// editar el último bloque (append región)
|
|
307
|
+
'[heading]Top[/heading]\n\nprimer parrafo EDITADO\n\n[quote]cita MODIFICADA[/quote]\n\n\n\n\n[code]code EDITADO[/code]\n\nfinal\n\nultimo bloque NUEVO',
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
const bigSources = sources.map((s) => big + '\n\n' + s)
|
|
311
|
+
const model = new BBCodeDocumentModel({ source: bigSources[0] })
|
|
312
|
+
const el = document.createElement('div')
|
|
313
|
+
patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
314
|
+
|
|
315
|
+
for (let i = 1; i < bigSources.length; i++) {
|
|
316
|
+
model.applyTextUpdate(bigSources[i])
|
|
317
|
+
model.ensureAnalyzed()
|
|
318
|
+
|
|
319
|
+
const stats = patchBlocksInto(el, model.redRoot!, WIN_OPTS)
|
|
320
|
+
expect(stats.mode).toBe('blocks')
|
|
321
|
+
|
|
322
|
+
const elExpected = morphDOM(model)
|
|
323
|
+
expect(
|
|
324
|
+
stripIds(el.innerHTML),
|
|
325
|
+
`divergencia tras la edición ${i}: ${bigSources[i].slice(0, 60)}`,
|
|
326
|
+
).toBe(stripIds(elExpected.innerHTML))
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('DocumentModel — lastChangeRange (fuente del hint)', () => {
|
|
332
|
+
it('applyTextUpdate expone {start, end, endOld} en coordenadas nuevas/viejas', () => {
|
|
333
|
+
const model = new BBCodeDocumentModel({ source: 'hola mundo' })
|
|
334
|
+
expect(model.lastChangeRange).toBeNull() // el bootstrap es un rebuild
|
|
335
|
+
|
|
336
|
+
// Editar "mundo" → "MUNDO": 5 caracteres en la posición 5..10.
|
|
337
|
+
model.applyTextUpdate('hola MUNDO')
|
|
338
|
+
const range = model.lastChangeRange!
|
|
339
|
+
expect(range.start).toBe(5)
|
|
340
|
+
expect(range.end).toBe(10) // 'MUNDO' (5 chars) desde 5
|
|
341
|
+
expect(range.endOld).toBe(10) // 'mundo' (5 chars) viejo
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('un rebuild limpia el rango y el root no lleva hint', () => {
|
|
345
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
346
|
+
model.applyTextUpdate('hola mundo')
|
|
347
|
+
expect(model.lastChangeRange).not.toBeNull()
|
|
348
|
+
|
|
349
|
+
model.rebuild('otro documento')
|
|
350
|
+
expect(model.lastChangeRange).toBeNull()
|
|
351
|
+
expect(
|
|
352
|
+
(model.redRoot as unknown as { __changeRange?: unknown }).__changeRange,
|
|
353
|
+
).toBeNull()
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('el rango viaja adjunto al root que lo produjo', () => {
|
|
357
|
+
const model = new BBCodeDocumentModel({ source: 'a\n\nb\n\nc' })
|
|
358
|
+
model.applyTextUpdate('a\n\nb EDITADO\n\nc')
|
|
359
|
+
const carried = (model.redRoot as unknown as { __changeRange?: { start: number } }).__changeRange
|
|
360
|
+
expect(carried?.start).toBeGreaterThanOrEqual(0)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { bindBoxDrawer, toggleBoxWithDrawer } from '../Visuals/BoxDrawer'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* jsdom has neither layout nor the Web Animations API, so both are stubbed.
|
|
7
|
+
*
|
|
8
|
+
* The stub deliberately keeps `cancel()` from firing its event synchronously:
|
|
9
|
+
* real animation events are dispatched on a later frame, and every bug this
|
|
10
|
+
* suite covers comes from a superseded animation's event landing *after* the
|
|
11
|
+
* next one has already taken over.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const COLLAPSED = 40
|
|
15
|
+
const EXPANDED = 200
|
|
16
|
+
|
|
17
|
+
interface FakeAnimation {
|
|
18
|
+
keyframes: { height: [string, string] }
|
|
19
|
+
duration: number
|
|
20
|
+
cancelled: boolean
|
|
21
|
+
cancel: () => void
|
|
22
|
+
addEventListener: (type: string, cb: () => void) => void
|
|
23
|
+
fire: (type: 'finish' | 'cancel') => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeBox() {
|
|
27
|
+
const host = document.createElement('div')
|
|
28
|
+
host.innerHTML = '<details><summary>title</summary><p>content</p></details>'
|
|
29
|
+
const details = host.querySelector('details') as HTMLDetailsElement
|
|
30
|
+
|
|
31
|
+
/** One-shot override standing in for the height a running animation paints. */
|
|
32
|
+
let painted: number | null = null
|
|
33
|
+
|
|
34
|
+
details.getBoundingClientRect = () => {
|
|
35
|
+
if (painted !== null) {
|
|
36
|
+
const height = painted
|
|
37
|
+
painted = null
|
|
38
|
+
return { height } as DOMRect
|
|
39
|
+
}
|
|
40
|
+
return { height: details.open ? EXPANDED : COLLAPSED } as DOMRect
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const anims: FakeAnimation[] = []
|
|
44
|
+
details.animate = ((keyframes: any, options: any) => {
|
|
45
|
+
const listeners: Record<string, Array<() => void>> = {}
|
|
46
|
+
const anim: FakeAnimation = {
|
|
47
|
+
keyframes,
|
|
48
|
+
duration: options.duration,
|
|
49
|
+
cancelled: false,
|
|
50
|
+
cancel: () => { anim.cancelled = true },
|
|
51
|
+
addEventListener: (type, cb) => { (listeners[type] ||= []).push(cb) },
|
|
52
|
+
fire: (type) => { (listeners[type] || []).forEach(cb => cb()) },
|
|
53
|
+
}
|
|
54
|
+
anims.push(anim)
|
|
55
|
+
return anim as unknown as Animation
|
|
56
|
+
}) as typeof details.animate
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
host,
|
|
60
|
+
details,
|
|
61
|
+
anims,
|
|
62
|
+
/** Simulate an interruption: the next height read reports mid-flight. */
|
|
63
|
+
setPaintedHeight: (h: number) => { painted = h },
|
|
64
|
+
/** Drive a toggle all the way to its resting state. */
|
|
65
|
+
settle: () => anims[anims.length - 1].fire('finish'),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('BoxDrawer', () => {
|
|
70
|
+
it('opens from the collapsed height to the expanded one', () => {
|
|
71
|
+
const box = makeBox()
|
|
72
|
+
|
|
73
|
+
toggleBoxWithDrawer(box.details)
|
|
74
|
+
|
|
75
|
+
expect(box.anims[0].keyframes.height).toEqual([`${COLLAPSED}px`, `${EXPANDED}px`])
|
|
76
|
+
expect(box.details.style.overflow).toBe('hidden')
|
|
77
|
+
// Stays open during the animation so the content is there to be uncovered.
|
|
78
|
+
expect(box.details.open).toBe(true)
|
|
79
|
+
|
|
80
|
+
box.settle()
|
|
81
|
+
expect(box.details.open).toBe(true)
|
|
82
|
+
expect(box.details.style.overflow).toBe('')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps the content mounted while closing, then closes at the end', () => {
|
|
86
|
+
const box = makeBox()
|
|
87
|
+
toggleBoxWithDrawer(box.details)
|
|
88
|
+
box.settle()
|
|
89
|
+
|
|
90
|
+
toggleBoxWithDrawer(box.details)
|
|
91
|
+
|
|
92
|
+
expect(box.anims[1].keyframes.height).toEqual([`${EXPANDED}px`, `${COLLAPSED}px`])
|
|
93
|
+
expect(box.details.open).toBe(true)
|
|
94
|
+
|
|
95
|
+
box.settle()
|
|
96
|
+
expect(box.details.open).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('resumes from the painted height instead of snapping', () => {
|
|
100
|
+
const box = makeBox()
|
|
101
|
+
toggleBoxWithDrawer(box.details)
|
|
102
|
+
|
|
103
|
+
box.setPaintedHeight(120)
|
|
104
|
+
toggleBoxWithDrawer(box.details)
|
|
105
|
+
|
|
106
|
+
expect(box.anims[0].cancelled).toBe(true)
|
|
107
|
+
expect(box.anims[1].keyframes.height).toEqual(['120px', `${COLLAPSED}px`])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('reverses a close back into an open', () => {
|
|
111
|
+
const box = makeBox()
|
|
112
|
+
toggleBoxWithDrawer(box.details)
|
|
113
|
+
box.settle()
|
|
114
|
+
|
|
115
|
+
toggleBoxWithDrawer(box.details) // closing
|
|
116
|
+
box.setPaintedHeight(150)
|
|
117
|
+
toggleBoxWithDrawer(box.details) // reversed mid-close
|
|
118
|
+
|
|
119
|
+
// Direction comes from the running intent; reading `open` here would say
|
|
120
|
+
// "open" — the box is held open while it closes — and close it again.
|
|
121
|
+
expect(box.anims[2].keyframes.height).toEqual(['150px', `${EXPANDED}px`])
|
|
122
|
+
|
|
123
|
+
box.settle()
|
|
124
|
+
expect(box.details.open).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('ignores a superseded animation\'s late cancel', () => {
|
|
128
|
+
const box = makeBox()
|
|
129
|
+
toggleBoxWithDrawer(box.details)
|
|
130
|
+
box.setPaintedHeight(120)
|
|
131
|
+
toggleBoxWithDrawer(box.details)
|
|
132
|
+
|
|
133
|
+
box.anims[0].fire('cancel')
|
|
134
|
+
|
|
135
|
+
// Without the ownership guard the outgoing animation strips the incoming
|
|
136
|
+
// one's clipping and the content spills out of a small box.
|
|
137
|
+
expect(box.details.style.overflow).toBe('hidden')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('ignores a superseded animation\'s late finish', () => {
|
|
141
|
+
const box = makeBox()
|
|
142
|
+
toggleBoxWithDrawer(box.details)
|
|
143
|
+
box.settle()
|
|
144
|
+
|
|
145
|
+
toggleBoxWithDrawer(box.details) // closing
|
|
146
|
+
box.setPaintedHeight(150)
|
|
147
|
+
toggleBoxWithDrawer(box.details) // reversed to opening
|
|
148
|
+
|
|
149
|
+
box.anims[1].fire('finish')
|
|
150
|
+
|
|
151
|
+
// The close's `open = false` landing late would freeze the box shut.
|
|
152
|
+
expect(box.details.open).toBe(true)
|
|
153
|
+
expect(box.details.style.overflow).toBe('hidden')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('scales the duration by the distance left to travel', () => {
|
|
157
|
+
const box = makeBox()
|
|
158
|
+
|
|
159
|
+
toggleBoxWithDrawer(box.details)
|
|
160
|
+
expect(box.anims[0].duration).toBe(400)
|
|
161
|
+
|
|
162
|
+
// Interrupted 10px above collapsed: a sliver of travel, floored so it still
|
|
163
|
+
// reads as motion rather than a snap.
|
|
164
|
+
box.setPaintedHeight(COLLAPSED + 10)
|
|
165
|
+
toggleBoxWithDrawer(box.details)
|
|
166
|
+
expect(box.anims[1].duration).toBe(120)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('restores the author\'s overflow across a chain of interruptions', () => {
|
|
170
|
+
const box = makeBox()
|
|
171
|
+
box.details.style.overflow = 'auto'
|
|
172
|
+
|
|
173
|
+
toggleBoxWithDrawer(box.details)
|
|
174
|
+
box.setPaintedHeight(120)
|
|
175
|
+
toggleBoxWithDrawer(box.details)
|
|
176
|
+
box.settle()
|
|
177
|
+
|
|
178
|
+
expect(box.details.style.overflow).toBe('auto')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('falls back to a plain toggle without the animation API', () => {
|
|
182
|
+
const box = makeBox()
|
|
183
|
+
delete (box.details as Partial<HTMLDetailsElement>).animate
|
|
184
|
+
|
|
185
|
+
toggleBoxWithDrawer(box.details)
|
|
186
|
+
|
|
187
|
+
expect(box.details.open).toBe(true)
|
|
188
|
+
expect(box.details.style.overflow).toBe('')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('binds and unbinds a delegated summary handler', () => {
|
|
192
|
+
const box = makeBox()
|
|
193
|
+
const dispose = bindBoxDrawer(box.host)
|
|
194
|
+
const summary = box.details.querySelector('summary')!
|
|
195
|
+
|
|
196
|
+
const first = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
197
|
+
summary.dispatchEvent(first)
|
|
198
|
+
expect(first.defaultPrevented).toBe(true)
|
|
199
|
+
expect(box.anims).toHaveLength(1)
|
|
200
|
+
|
|
201
|
+
dispose()
|
|
202
|
+
|
|
203
|
+
summary.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
|
204
|
+
expect(box.anims).toHaveLength(1)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('leaves clicks outside a summary alone', () => {
|
|
208
|
+
const box = makeBox()
|
|
209
|
+
bindBoxDrawer(box.host)
|
|
210
|
+
|
|
211
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
212
|
+
box.details.querySelector('p')!.dispatchEvent(event)
|
|
213
|
+
|
|
214
|
+
expect(event.defaultPrevented).toBe(false)
|
|
215
|
+
expect(box.anims).toHaveLength(0)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
import { MarkdownExporter } from '../Visitors/MarkdownExporter'
|
|
6
|
+
|
|
7
|
+
describe('Box & Spoilerbox Rich Title Support', () => {
|
|
8
|
+
it('parses and renders images, colors, and bold formatting inside box title', () => {
|
|
9
|
+
const source = `[box=[b][color=#efefef]▎[/color][/b]💛⠀[b][color=#efefef]Gifts from People (,,>﹏<,,)[/color][/b] [img]https://example.com/tja000.gif[/img][img]https://example.com/nyeee.gif[/img]]Body content[/box]`
|
|
10
|
+
const model = new BBCodeDocumentModel({ source })
|
|
11
|
+
const box = model.redRoot!.children.find(c => c.kind === 'box')!
|
|
12
|
+
|
|
13
|
+
expect(box).toBeDefined()
|
|
14
|
+
expect(box.metadata.rawTitle).toBe('[b][color=#efefef]▎[/color][/b]💛⠀[b][color=#efefef]Gifts from People (,,>﹏<,,)[/color][/b] [img]https://example.com/tja000.gif[/img][img]https://example.com/nyeee.gif[/img]')
|
|
15
|
+
expect(box.metadata.title).toBe('▎💛⠀Gifts from People (,,>﹏<,,) https://example.com/tja000.gifhttps://example.com/nyeee.gif')
|
|
16
|
+
|
|
17
|
+
const titleNodes = box.metadata.titleNodes as typeof box.children
|
|
18
|
+
expect(titleNodes).toBeDefined()
|
|
19
|
+
expect(titleNodes.length).toBeGreaterThan(0)
|
|
20
|
+
|
|
21
|
+
// Check images in titleNodes
|
|
22
|
+
const imageNodes = titleNodes.filter(n => n.kind === 'image')
|
|
23
|
+
expect(imageNodes.length).toBe(2)
|
|
24
|
+
expect(imageNodes[0].metadata.src).toBe('https://example.com/tja000.gif')
|
|
25
|
+
expect(imageNodes[1].metadata.src).toBe('https://example.com/nyeee.gif')
|
|
26
|
+
|
|
27
|
+
// Render HTML
|
|
28
|
+
const renderer = new HTMLRenderer()
|
|
29
|
+
const html = renderer.render(model.redRoot!)
|
|
30
|
+
|
|
31
|
+
expect(html).toContain('<details')
|
|
32
|
+
expect(html).toContain('class="box"')
|
|
33
|
+
expect(html).toContain('<summary>')
|
|
34
|
+
expect(html).toContain('style="color:#efefef;">▎</span>')
|
|
35
|
+
expect(html).toContain('Gifts from People (,,>﹏<,,)')
|
|
36
|
+
expect(html).toContain('<img')
|
|
37
|
+
expect(html).toContain('src="https://example.com/tja000.gif"')
|
|
38
|
+
expect(html).toContain('src="https://example.com/nyeee.gif"')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders testforquasar fixture correctly', () => {
|
|
42
|
+
const source = `[box=[b][color=#efefef]▎[/color][/b]💛⠀[b][color=#efefef]Gifts from People (,,>﹏<,,)[/color][/b] [img]https://blogger.googleusercontent.com/img/b/tja000.gif[/img][img]https://blogger.googleusercontent.com/img/b/nyeee.gif[/img][img]https://blogger.googleusercontent.com/img/b/vy_2.gif[/img]][color=#efefef]Thank you so much[/color][/box]`
|
|
43
|
+
const model = new BBCodeDocumentModel({ source })
|
|
44
|
+
const renderer = new HTMLRenderer()
|
|
45
|
+
const html = renderer.render(model.redRoot!)
|
|
46
|
+
|
|
47
|
+
expect(html).toContain('<summary>')
|
|
48
|
+
expect(html).toContain('src="https://blogger.googleusercontent.com/img/b/tja000.gif"')
|
|
49
|
+
expect(html).toContain('src="https://blogger.googleusercontent.com/img/b/nyeee.gif"')
|
|
50
|
+
expect(html).toContain('src="https://blogger.googleusercontent.com/img/b/vy_2.gif"')
|
|
51
|
+
expect(html).toContain('style="color:#efefef;">Thank you so much</span>')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders testforquasar2 fixture correctly', () => {
|
|
55
|
+
const source = `[box=[b][color=#ff4040][c]D[/c][/color][color=#ff9240][c]O[/c][/color][c] [/c][color=#ffe440][c]N[/c][/color][color=#c8ff40][c]O[/c][/color][color=#76ff40][c]T[/c][/color][c] [/c][color=#40ff5b][c]C[/c][/color][color=#40ffad][c]L[/c][/color][color=#40ffff][c]I[/c][/color][color=#40adff][c]C[/c][/color][color=#405bff][c]K[/c][/color][c] [/c][color=#7640ff][c]H[/c][/color][color=#c840ff][c]E[/c][/color][color=#ff40e4][c]R[/c][/color][color=#ff4092][c]E[/c][/color][/b]
|
|
56
|
+
][notice][size=200][color=#ccd7be]𝓹[/color][color=#bacef9]𝓮[/color][color=#f38fe2]𝓮[/color][color=#eea5b7]𝓴[/color] [color=#bcc4c7]𝓪[/color] [color=#9c85f1]𝓫[/color][color=#ccc793]𝓸[/color][color=#d390c9]𝓸[/color] [color=#d1abb8]>[/color][color=#bee1e8].[/color][color=#c4eefb]<[/color][/size]
|
|
57
|
+
[/notice]
|
|
58
|
+
[/box]`
|
|
59
|
+
const model = new BBCodeDocumentModel({ source })
|
|
60
|
+
const renderer = new HTMLRenderer()
|
|
61
|
+
const html = renderer.render(model.redRoot!)
|
|
62
|
+
|
|
63
|
+
expect(html).toContain('<details')
|
|
64
|
+
expect(html).toContain('<summary>')
|
|
65
|
+
expect(html).toContain('style="color:#ff4040;"><code')
|
|
66
|
+
expect(html).toContain('>D</code>')
|
|
67
|
+
expect(html).toContain('style="color:#ff9240;"><code')
|
|
68
|
+
expect(html).toContain('>O</code>')
|
|
69
|
+
expect(html).toContain('<div')
|
|
70
|
+
expect(html).toContain('class="notice"')
|
|
71
|
+
expect(html).toContain('style="font-size:200%;"')
|
|
72
|
+
expect(html).toContain('𝓹')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('preserves rich title during BBCode export', () => {
|
|
76
|
+
const source = `[box=[b][color=#efefef]▎[/color][/b]💛⠀[img]https://example.com/tja000.gif[/img]]Content[/box]`
|
|
77
|
+
const model = new BBCodeDocumentModel({ source })
|
|
78
|
+
const exporter = new BBCodeExporter()
|
|
79
|
+
const exported = exporter.export(model.redRoot!)
|
|
80
|
+
|
|
81
|
+
expect(exported).toContain('[box=[b][color=#efefef]▎[/color][/b]💛⠀[img]https://example.com/tja000.gif[/img]]')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('exports rich title to Markdown', () => {
|
|
85
|
+
const source = `[box=[b]Bold Title[/b]]Content[/box]`
|
|
86
|
+
const model = new BBCodeDocumentModel({ source })
|
|
87
|
+
const exporter = new MarkdownExporter()
|
|
88
|
+
const exported = exporter.export(model.redRoot!)
|
|
89
|
+
|
|
90
|
+
expect(exported).toContain('**Bold Title**')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('allows offset lookups inside the rich title', () => {
|
|
94
|
+
const source = `[box=[img]https://example.com/tja000.gif[/img]]Content[/box]`
|
|
95
|
+
const model = new BBCodeDocumentModel({ source })
|
|
96
|
+
const box = model.redRoot!.children.find(c => c.kind === 'box')!
|
|
97
|
+
const imageNode = (box.metadata.titleNodes as typeof box.children)[0]
|
|
98
|
+
|
|
99
|
+
expect(imageNode.kind === 'image').toBe(true)
|
|
100
|
+
// Offset inside [img]...[/img] should locate the image node
|
|
101
|
+
const found = model.redRoot!.findNodeAtOffset(10)
|
|
102
|
+
expect(found).toBeDefined()
|
|
103
|
+
expect(found?.kind === 'text' || found?.kind === 'image').toBe(true)
|
|
104
|
+
})
|
|
105
|
+
})
|