@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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler-path profiling harness (opt-in).
|
|
3
|
+
*
|
|
4
|
+
* Measures the exact compiler pipeline the app runs per keystroke:
|
|
5
|
+
* Lexer → Parser → GreenNodePool → red build (reuse) → SemanticAnalyzer
|
|
6
|
+
* → HTMLRenderer.render → DOMMorpher.morphHTML (the visual node).
|
|
7
|
+
*
|
|
8
|
+
* The app additionally wraps this in `BBCodePipeline` (MiliastryNovaFeatures),
|
|
9
|
+
* which defers the parse to `requestIdleCallback` above SYNC_THRESHOLD=5000 —
|
|
10
|
+
* the quasar engine itself is synchronous, and this harness times the engine.
|
|
11
|
+
*
|
|
12
|
+
* Opt-in so `npm test` stays silent:
|
|
13
|
+
* QUASAR_PROFILE=1 npx vitest run src/Tests/CompilerPathProfiling.test.ts
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { writeFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
19
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
20
|
+
import { morphHTML } from '../Visitors/DOMMorpher'
|
|
21
|
+
import { REFERENCE_DOCUMENT } from './referenceDocument'
|
|
22
|
+
|
|
23
|
+
const PROFILE = process.env.QUASAR_PROFILE === '1'
|
|
24
|
+
/** Report sink: vitest swallows console.log when stdout is not a TTY. */
|
|
25
|
+
const reportLines: string[] = []
|
|
26
|
+
function report(line = ''): void {
|
|
27
|
+
reportLines.push(line)
|
|
28
|
+
console.log(line)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Timing helpers ─────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function summarize(samples: number[]): { mean: number; min: number; p50: number; p95: number } {
|
|
34
|
+
const s = [...samples].sort((a, b) => a - b)
|
|
35
|
+
const n = s.length
|
|
36
|
+
return {
|
|
37
|
+
mean: s.reduce((a, b) => a + b, 0) / n,
|
|
38
|
+
min: s[0],
|
|
39
|
+
p50: s[Math.floor(n * 0.5)],
|
|
40
|
+
p95: s[Math.min(n - 1, Math.floor(n * 0.95))],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timeIt(fn: () => void): number {
|
|
45
|
+
const t0 = performance.now()
|
|
46
|
+
fn()
|
|
47
|
+
return (performance.now() - t0) * 1000 // µs
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** warmup (JIT) then measure; returns µs samples. */
|
|
51
|
+
function bench(measure: number, warmup: number, fn: () => void): number[] {
|
|
52
|
+
for (let i = 0; i < warmup; i++) fn()
|
|
53
|
+
const out: number[] = []
|
|
54
|
+
for (let i = 0; i < measure; i++) out.push(timeIt(fn))
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function row(name: string, samples: number[]): void {
|
|
59
|
+
const s = summarize(samples)
|
|
60
|
+
report(
|
|
61
|
+
` ${name.padEnd(20)} mean ${s.mean.toFixed(1).padStart(8)} µs | min ${s.min.toFixed(1).padStart(8)} | p50 ${s.p50.toFixed(1).padStart(8)} | p95 ${s.p95.toFixed(1).padStart(8)}`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Scenarios ──────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe.skipIf(!PROFILE)('compiler path profiling (QUASAR_PROFILE=1)', () => {
|
|
68
|
+
it(
|
|
69
|
+
'rebuild / keystroke / render / morph across document sizes',
|
|
70
|
+
() => {
|
|
71
|
+
const docs: Array<[string, string]> = [
|
|
72
|
+
['small ~1.2KB', REFERENCE_DOCUMENT],
|
|
73
|
+
['mid ~4.8KB', REFERENCE_DOCUMENT.repeat(4)],
|
|
74
|
+
['large ~19KB', REFERENCE_DOCUMENT.repeat(16)],
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
report(
|
|
78
|
+
'\nNOTE: app pipeline defers parse to requestIdleCallback above 5KB ' +
|
|
79
|
+
'(BBCodePipeline SYNC_THRESHOLD); engine itself is sync.',
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
for (const [docName, source] of docs) {
|
|
83
|
+
report(`\n=== ${docName} (${(source.length / 1024).toFixed(1)} KB) ===`)
|
|
84
|
+
|
|
85
|
+
// ── 1. Full load / rebuild (constructor = lex+parse+pool+buildRed+analyze) ──
|
|
86
|
+
const loads = bench(40, 10, () => { new BBCodeDocumentModel({ source }) })
|
|
87
|
+
row('load (rebuild)', loads)
|
|
88
|
+
|
|
89
|
+
const probe = new BBCodeDocumentModel({ source })
|
|
90
|
+
let greenCount = 0
|
|
91
|
+
probe.greenRoot!.walk(() => { greenCount++ })
|
|
92
|
+
const probeHtml = new HTMLRenderer().render(probe.redRoot!)
|
|
93
|
+
report(
|
|
94
|
+
` green nodes: ${greenCount} · html: ${probeHtml.length} chars · ` +
|
|
95
|
+
`analyze: ${((probe.lastAnalyze?.duration ?? 0) * 1000).toFixed(1)} µs / ${probe.lastAnalyze?.nodesAnalyzed ?? 0} nodes`,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// ── 2. Keystroke (incremental, reuseRed) — insert 'X' at middle ──
|
|
99
|
+
const model = new BBCodeDocumentModel({ source })
|
|
100
|
+
const mid = Math.floor(source.length / 2)
|
|
101
|
+
const sInsert = source.slice(0, mid) + 'X' + source.slice(mid)
|
|
102
|
+
const sRevert = source
|
|
103
|
+
|
|
104
|
+
model.applyTextUpdate(sInsert); model.ensureAnalyzed()
|
|
105
|
+
model.applyTextUpdate(sRevert); model.ensureAnalyzed()
|
|
106
|
+
|
|
107
|
+
const totalSamples: number[] = []
|
|
108
|
+
const reparseSamples: number[] = []
|
|
109
|
+
const parseSamples: number[] = []
|
|
110
|
+
const buildRedSamples: number[] = []
|
|
111
|
+
const miscReparseSamples: number[] = []
|
|
112
|
+
const analyzeSamples: number[] = []
|
|
113
|
+
for (let i = 0; i < 120; i++) {
|
|
114
|
+
const t = timeIt(() => { model.applyTextUpdate(sInsert); model.ensureAnalyzed() })
|
|
115
|
+
totalSamples.push(t)
|
|
116
|
+
analyzeSamples.push((model.lastAnalyze?.duration ?? 0) * 1000)
|
|
117
|
+
reparseSamples.push(t - (model.lastAnalyze?.duration ?? 0) * 1000)
|
|
118
|
+
const timings = model.lastReparseTimings
|
|
119
|
+
if (timings) {
|
|
120
|
+
parseSamples.push(timings.parse * 1000)
|
|
121
|
+
buildRedSamples.push(timings.buildRed * 1000)
|
|
122
|
+
miscReparseSamples.push(
|
|
123
|
+
(timings.findAffected + timings.safeBoundary + timings.mutate + timings.other) * 1000,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
timeIt(() => { model.applyTextUpdate(sRevert); model.ensureAnalyzed() })
|
|
127
|
+
}
|
|
128
|
+
row('keystroke total', totalSamples)
|
|
129
|
+
row(' diff+reparse', reparseSamples)
|
|
130
|
+
row(' parse', parseSamples)
|
|
131
|
+
row(' buildRed', buildRedSamples)
|
|
132
|
+
row(' find+mutate', miscReparseSamples)
|
|
133
|
+
row(' analyze', analyzeSamples)
|
|
134
|
+
|
|
135
|
+
// ── 3. Render to HTML ──
|
|
136
|
+
const renderer = new HTMLRenderer()
|
|
137
|
+
const htmlSamples = bench(80, 20, () => { renderer.render(model.redRoot!) })
|
|
138
|
+
row('renderToHTML', htmlSamples)
|
|
139
|
+
|
|
140
|
+
// Two HTML states that differ by exactly the inserted character, for morph.
|
|
141
|
+
model.applyTextUpdate(sInsert); model.ensureAnalyzed()
|
|
142
|
+
const htmlA = renderer.render(model.redRoot!)
|
|
143
|
+
model.applyTextUpdate(sRevert); model.ensureAnalyzed()
|
|
144
|
+
const htmlB = renderer.render(model.redRoot!)
|
|
145
|
+
expect(htmlA.length).toBeGreaterThan(0)
|
|
146
|
+
|
|
147
|
+
// ── 4. Morph to DOM (the visual node) ──
|
|
148
|
+
const coldSamples = bench(30, 10, () => {
|
|
149
|
+
morphHTML(document.createElement('div'), htmlA)
|
|
150
|
+
})
|
|
151
|
+
row('morph cold (innerHTML)', coldSamples)
|
|
152
|
+
|
|
153
|
+
// Baseline: what a naive full innerHTML swap costs. If morph ≈ this,
|
|
154
|
+
// the cost is DOM parsing of the full document, not the diff walk.
|
|
155
|
+
const rawSamples = bench(30, 10, () => {
|
|
156
|
+
const d = document.createElement('div')
|
|
157
|
+
d.innerHTML = htmlA
|
|
158
|
+
})
|
|
159
|
+
row('baseline innerHTML=', rawSamples)
|
|
160
|
+
|
|
161
|
+
const live = document.createElement('div')
|
|
162
|
+
morphHTML(live, htmlA)
|
|
163
|
+
const warmSamples: number[] = []
|
|
164
|
+
for (let i = 0; i < 120; i++) {
|
|
165
|
+
warmSamples.push(timeIt(() => morphHTML(live, i % 2 ? htmlA : htmlB)))
|
|
166
|
+
}
|
|
167
|
+
row('morph warm (diff)', warmSamples)
|
|
168
|
+
|
|
169
|
+
// Per-keystroke compiler budget (sync part): keystroke total + render + morph warm.
|
|
170
|
+
const total = summarize(totalSamples)
|
|
171
|
+
const render = summarize(htmlSamples)
|
|
172
|
+
const morph = summarize(warmSamples)
|
|
173
|
+
const syncMs = (total.mean + render.mean + morph.mean) / 1000
|
|
174
|
+
report(
|
|
175
|
+
` → sync compiler cost/keystroke ≈ ${syncMs.toFixed(2)} ms ` +
|
|
176
|
+
`(${(1000 / Math.max(syncMs, 0.001)).toFixed(0)} keystrokes/s)`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const outPath = join(__dirname, '..', '..', 'bench-report.txt')
|
|
181
|
+
writeFileSync(outPath, reportLines.join('\n') + '\n')
|
|
182
|
+
report(`\nReport written to ${outPath}`)
|
|
183
|
+
},
|
|
184
|
+
120_000,
|
|
185
|
+
)
|
|
186
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { morphHTML } from '../Visitors/DOMMorpher'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The morpher had no tests at all, which is uncomfortable for something that
|
|
6
|
+
* mutates the DOM the user is looking at.
|
|
7
|
+
*
|
|
8
|
+
* The central property is simple: after morphing, the container must be
|
|
9
|
+
* structurally identical to what `innerHTML = newHTML` would have produced.
|
|
10
|
+
* Anything else means the preview silently disagrees with the renderer. That is
|
|
11
|
+
* asserted below over hand-picked edit shapes plus a seeded random corpus.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function container(html: string): HTMLElement {
|
|
15
|
+
const el = document.createElement('div')
|
|
16
|
+
el.innerHTML = html
|
|
17
|
+
return el
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function expected(html: string): HTMLElement {
|
|
21
|
+
const el = document.createElement('div')
|
|
22
|
+
el.innerHTML = html
|
|
23
|
+
return el
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Morph `from` into `to` and assert the result matches a fresh render. */
|
|
27
|
+
function expectMorphMatches(from: string, to: string): HTMLElement {
|
|
28
|
+
const el = container(from)
|
|
29
|
+
morphHTML(el, to)
|
|
30
|
+
expect(el.innerHTML).toBe(expected(to).innerHTML)
|
|
31
|
+
return el
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mulberry32(seed: number) {
|
|
35
|
+
return function () {
|
|
36
|
+
seed |= 0; seed = (seed + 0x6D2B79F5) | 0
|
|
37
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
|
38
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
39
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('DOMMorpher', () => {
|
|
44
|
+
it('produces the same DOM as a fresh render, for every edit shape', () => {
|
|
45
|
+
const base = '<p>uno</p><p>dos</p><p>tres</p>'
|
|
46
|
+
|
|
47
|
+
expectMorphMatches(base, base) // sin cambios
|
|
48
|
+
expectMorphMatches(base, '<p>UNO</p><p>dos</p><p>tres</p>') // cambio al principio
|
|
49
|
+
expectMorphMatches(base, '<p>uno</p><p>dos</p><p>TRES</p>') // cambio al final
|
|
50
|
+
expectMorphMatches(base, '<p>uno</p><p>DOS</p><p>tres</p>') // cambio en medio
|
|
51
|
+
expectMorphMatches(base, '<p>cero</p><p>uno</p><p>dos</p><p>tres</p>')// inserción al principio
|
|
52
|
+
expectMorphMatches(base, '<p>uno</p><p>dos</p><p>tres</p><p>cuatro</p>') // al final
|
|
53
|
+
expectMorphMatches(base, '<p>uno</p><p>nuevo</p><p>dos</p><p>tres</p>') // en medio
|
|
54
|
+
expectMorphMatches(base, '<p>dos</p><p>tres</p>') // borrado al principio
|
|
55
|
+
expectMorphMatches(base, '<p>uno</p><p>dos</p>') // borrado al final
|
|
56
|
+
expectMorphMatches(base, '<p>uno</p><p>tres</p>') // borrado en medio
|
|
57
|
+
expectMorphMatches(base, '') // vaciado
|
|
58
|
+
expectMorphMatches(base, '<div>otra cosa</div>') // etiqueta distinta
|
|
59
|
+
expectMorphMatches(base, '<p>uno</p>texto suelto<p>tres</p>') // elemento → texto
|
|
60
|
+
expectMorphMatches('<b>a</b>', '<b><i>a</i></b>') // anidamiento nuevo
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('inserts before the preserved suffix, not at the end', () => {
|
|
64
|
+
// Regression: trimming a common suffix means a plain `appendChild` would
|
|
65
|
+
// put the new node in the wrong place.
|
|
66
|
+
expectMorphMatches('<p>a</p><p>z</p>', '<p>a</p><p>m</p><p>z</p>')
|
|
67
|
+
expectMorphMatches('<p>z</p>', '<p>a</p><p>z</p>')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps the identical prefix as the very same DOM nodes', () => {
|
|
71
|
+
// This is the point of the morpher: untouched nodes must not be replaced,
|
|
72
|
+
// or the runtime state they hold is lost.
|
|
73
|
+
const el = container('<p id="a">uno</p><p id="b">dos</p>')
|
|
74
|
+
const first = el.children[0]
|
|
75
|
+
morphHTML(el, '<p id="a">uno</p><p id="b">DOS</p>')
|
|
76
|
+
expect(el.children[0]).toBe(first)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does not rewrite every sibling when a node is inserted at the front', () => {
|
|
80
|
+
// This is the A8 defect, pinned. Pairing children by index means inserting
|
|
81
|
+
// one node shifts every following sibling, so each compares against the
|
|
82
|
+
// wrong counterpart and gets its content rewritten in place.
|
|
83
|
+
//
|
|
84
|
+
// Measured against the previous implementation: of 200 paragraphs, it left
|
|
85
|
+
// 0 intact. Suffix trimming brings that to 200 — the insert is the only
|
|
86
|
+
// DOM operation performed.
|
|
87
|
+
const from = Array.from({ length: 200 }, (_, i) => `<p>l${i}</p>`).join('')
|
|
88
|
+
const el = container(from)
|
|
89
|
+
const original = Array.from(el.children).map(n => ({ node: n, text: n.textContent }))
|
|
90
|
+
|
|
91
|
+
morphHTML(el, '<p>nueva</p>' + from)
|
|
92
|
+
|
|
93
|
+
const intact = original.filter(o => o.node.textContent === o.text).length
|
|
94
|
+
expect(intact).toBe(200)
|
|
95
|
+
expect(el.innerHTML).toBe(expected('<p>nueva</p>' + from).innerHTML)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('preserves an open <details> across a morph', () => {
|
|
99
|
+
const el = container('<details><summary>s</summary><p>uno</p></details>')
|
|
100
|
+
const details = el.querySelector('details')!
|
|
101
|
+
details.setAttribute('open', '')
|
|
102
|
+
|
|
103
|
+
morphHTML(el, '<details><summary>s</summary><p>DOS</p></details>')
|
|
104
|
+
|
|
105
|
+
expect(el.querySelector('details')).toBe(details)
|
|
106
|
+
expect(el.querySelector('details')!.hasAttribute('open')).toBe(true)
|
|
107
|
+
expect(el.querySelector('p')!.textContent).toBe('DOS')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('syncs attributes, adding and removing', () => {
|
|
111
|
+
const el = container('<p class="viejo" data-x="1">t</p>')
|
|
112
|
+
morphHTML(el, '<p class="nuevo">t</p>')
|
|
113
|
+
const p = el.children[0]
|
|
114
|
+
expect(p.getAttribute('class')).toBe('nuevo')
|
|
115
|
+
expect(p.hasAttribute('data-x')).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('matches a fresh render across a seeded random corpus', () => {
|
|
119
|
+
const rnd = mulberry32(1234)
|
|
120
|
+
const piezas = [
|
|
121
|
+
'<p>a</p>', '<p>b</p>', '<b>x</b>', '<i>y</i>', 'texto ',
|
|
122
|
+
'<div class="n">n</div>', '<span data-node-id="n1">s</span>',
|
|
123
|
+
'<details><summary>s</summary><p>d</p></details>', '<br>',
|
|
124
|
+
]
|
|
125
|
+
const build = () => {
|
|
126
|
+
let h = ''
|
|
127
|
+
const n = Math.floor(rnd() * 10)
|
|
128
|
+
for (let i = 0; i < n; i++) h += piezas[Math.floor(rnd() * piezas.length)]
|
|
129
|
+
return h
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < 2000; i++) {
|
|
133
|
+
const from = build()
|
|
134
|
+
const to = build()
|
|
135
|
+
const el = container(from)
|
|
136
|
+
morphHTML(el, to)
|
|
137
|
+
// `morphHTML` short-circuits to innerHTML when the container starts empty,
|
|
138
|
+
// which is trivially correct; the interesting case is the incremental one.
|
|
139
|
+
expect(el.innerHTML).toBe(expected(to).innerHTML)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
4
|
+
import { patchBlocksInto } from '../Visitors/BlockPatcher'
|
|
5
|
+
|
|
6
|
+
function makeLines(n: number): string {
|
|
7
|
+
const parts: string[] = []
|
|
8
|
+
for (let i = 0; i < n; i++) parts.push(`line ${i}`)
|
|
9
|
+
return parts.join('\n')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('dom patch worst case (perf diagnostic)', () => {
|
|
13
|
+
it('repro: 40k lines, insert a-burst in middle and at start', () => {
|
|
14
|
+
const source = makeLines(5000)
|
|
15
|
+
const model = new BBCodeDocumentModel({ source })
|
|
16
|
+
const renderer = new HTMLRenderer()
|
|
17
|
+
let renderCalls = 0
|
|
18
|
+
const origRender = renderer.render.bind(renderer)
|
|
19
|
+
;(renderer as unknown as { render: (n: unknown) => string }).render = (n: unknown) => {
|
|
20
|
+
renderCalls++
|
|
21
|
+
return origRender(n as never)
|
|
22
|
+
}
|
|
23
|
+
const el = document.createElement('div')
|
|
24
|
+
|
|
25
|
+
const t0 = performance.now()
|
|
26
|
+
const s1 = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
27
|
+
const tInit = performance.now() - t0
|
|
28
|
+
console.log('[perf] init: ', JSON.stringify(s1), 'renderCalls:', renderCalls, 't:', tInit.toFixed(1), 'ms', 'rootChildren:', model.redRoot!.children.length)
|
|
29
|
+
|
|
30
|
+
// ── Edit A: insert 'a' * 2000 en la MITAD (dentro de una línea) ──
|
|
31
|
+
renderCalls = 0
|
|
32
|
+
const mid = Math.floor(model.source.length / 2)
|
|
33
|
+
const edited = model.source.slice(0, mid) + 'a'.repeat(2000) + model.source.slice(mid)
|
|
34
|
+
const t1 = performance.now()
|
|
35
|
+
model.applyTextUpdate(edited)
|
|
36
|
+
const tParse = performance.now() - t1
|
|
37
|
+
const t2 = performance.now()
|
|
38
|
+
const s2 = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
39
|
+
const tPatch = performance.now() - t2
|
|
40
|
+
console.log('[perf] mid-insert:', JSON.stringify(s2), 'renderCalls:', renderCalls, 'parse:', tParse.toFixed(1), 'ms', 'patch:', tPatch.toFixed(1), 'ms')
|
|
41
|
+
|
|
42
|
+
// ── Edit B: insert 'a' * 2000 al INICIO ──
|
|
43
|
+
renderCalls = 0
|
|
44
|
+
const edited2 = 'a'.repeat(2000) + edited
|
|
45
|
+
const t3 = performance.now()
|
|
46
|
+
model.applyTextUpdate(edited2)
|
|
47
|
+
const tParse2 = performance.now() - t3
|
|
48
|
+
const t4 = performance.now()
|
|
49
|
+
const s3 = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
50
|
+
const tPatch2 = performance.now() - t4
|
|
51
|
+
console.log('[perf] start-insert:', JSON.stringify(s3), 'renderCalls:', renderCalls, 'parse:', tParse2.toFixed(1), 'ms', 'patch:', tPatch2.toFixed(1), 'ms')
|
|
52
|
+
|
|
53
|
+
// ── Edit C: teclear 1 carácter en el medio ──
|
|
54
|
+
renderCalls = 0
|
|
55
|
+
const edited3 = edited2.slice(0, mid + 1000) + 'x' + edited2.slice(mid + 1000)
|
|
56
|
+
model.applyTextUpdate(edited3)
|
|
57
|
+
const s4 = patchBlocksInto(el, model.redRoot!, { renderer })
|
|
58
|
+
console.log('[perf] single-key:', JSON.stringify(s4), 'renderCalls:', renderCalls)
|
|
59
|
+
}, 120000)
|
|
60
|
+
})
|