@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark — 500k-character engine test on REAL osu! BBCode.
|
|
3
|
+
*
|
|
4
|
+
* Uses the real user-profile document at `packages/quasar/500KCharsTest`
|
|
5
|
+
* (~547 KB, thousands of color-gradient inlines, boxes, notices, imagemaps).
|
|
6
|
+
* Run with `PROFILE=1` for the diagnostic numbers (the default run is a
|
|
7
|
+
* correctness smoke test).
|
|
8
|
+
*
|
|
9
|
+
* The flow mirrors production exactly: ONE model, `applyTextUpdate` per edit,
|
|
10
|
+
* `patchBlocksInto(container, model.redRoot)` per patch. Each DOM path gets
|
|
11
|
+
* its own container (own patch cache), primed on the previous state so every
|
|
12
|
+
* measurement honestly consumes the fresh change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
19
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
20
|
+
import { patchBlocksInto } from '../Visitors/BlockPatcher'
|
|
21
|
+
|
|
22
|
+
const PROFILE = process.env.PROFILE === '1'
|
|
23
|
+
|
|
24
|
+
function loadFixture(): string {
|
|
25
|
+
const candidates = [
|
|
26
|
+
join(process.cwd(), 'packages', 'quasar', '500KCharsTest'),
|
|
27
|
+
join(process.cwd(), '500KCharsTest'),
|
|
28
|
+
join(__dirname, '..', '..', '500KCharsTest'),
|
|
29
|
+
]
|
|
30
|
+
const p = candidates.find(c => existsSync(c))
|
|
31
|
+
if (!p) throw new Error(`Fixture not found in: ${candidates.join(', ')}`)
|
|
32
|
+
return readFileSync(p, 'utf-8')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bench(fn: () => void): number {
|
|
36
|
+
const t0 = performance.now()
|
|
37
|
+
fn()
|
|
38
|
+
return performance.now() - t0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('Quasar @ 500k chars (real fixture)', () => {
|
|
42
|
+
it('engine correctness + (PROFILE=1) diagnostics on the 547KB fixture', () => {
|
|
43
|
+
const src = loadFixture()
|
|
44
|
+
const n = src.length
|
|
45
|
+
if (PROFILE) console.log(`[500k] fixture chars=${n} lines=${src.split('\n').length}`)
|
|
46
|
+
expect(n).toBeGreaterThan(500_000)
|
|
47
|
+
|
|
48
|
+
const renderer = new HTMLRenderer()
|
|
49
|
+
let renders = 0
|
|
50
|
+
const counting = new Proxy(renderer, {
|
|
51
|
+
get(target, prop, receiver) {
|
|
52
|
+
const v = Reflect.get(target, prop, receiver)
|
|
53
|
+
if (prop === 'render' && typeof v === 'function') {
|
|
54
|
+
return (node: unknown) => {
|
|
55
|
+
renders++
|
|
56
|
+
return v.call(target, node)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return v
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
let model!: BBCodeDocumentModel
|
|
64
|
+
const tNew = bench(() => {
|
|
65
|
+
model = new BBCodeDocumentModel({ source: src })
|
|
66
|
+
})
|
|
67
|
+
if (PROFILE) console.log(`[500k] bootstrap(new model+parse): ${tNew.toFixed(1)}ms`)
|
|
68
|
+
|
|
69
|
+
const blocks = model.redRoot?.children.length ?? 0
|
|
70
|
+
if (PROFILE) console.log(`[500k] top-level blocks=${blocks}`)
|
|
71
|
+
|
|
72
|
+
if (PROFILE) {
|
|
73
|
+
const sizes = model.redRoot!.children.map((b) => b.range.end - b.range.start).sort((a, b) => b - a)
|
|
74
|
+
console.log(
|
|
75
|
+
`[500k] block-size profile: max=${sizes[0]} median=${sizes[Math.floor(sizes.length / 2)]} ` +
|
|
76
|
+
`mean=${Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length)}`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const mid = Math.floor(src.length * 0.6)
|
|
81
|
+
const edits: Array<[string, string]> = [
|
|
82
|
+
['start-insert', '[heading]TOP[/heading]\n\n' + src],
|
|
83
|
+
['mid-insert', src.slice(0, mid) + '[heading]MID[/heading]\n\n' + src.slice(mid)],
|
|
84
|
+
['end-append', src + '\n\n[box]fin[/box]'],
|
|
85
|
+
['inline-char', src.replace('hxovc', 'hxovcX')],
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
for (const [name, next] of edits) {
|
|
89
|
+
// Prime both containers on the previous state.
|
|
90
|
+
const elWin = document.createElement('div')
|
|
91
|
+
const elFull = document.createElement('div')
|
|
92
|
+
patchBlocksInto(elWin, model.redRoot!, { renderer })
|
|
93
|
+
patchBlocksInto(elFull, model.redRoot!, { renderer })
|
|
94
|
+
|
|
95
|
+
// Apply the edit once; the root below is the ONE the model now holds.
|
|
96
|
+
const tParse = bench(() => model.applyTextUpdate(next))
|
|
97
|
+
const root = model.redRoot!
|
|
98
|
+
if (PROFILE && model.lastReparseTimings) {
|
|
99
|
+
const t = model.lastReparseTimings
|
|
100
|
+
console.log(
|
|
101
|
+
`[500k] reparse path=${model.lastReparsePath} fallback=${model.lastReparseFallbackReason} ` +
|
|
102
|
+
`findAffected=${t.findAffected.toFixed(1)}ms safe=${t.safeBoundary.toFixed(1)}ms ` +
|
|
103
|
+
`parse=${t.parse.toFixed(1)}ms buildRed=${t.buildRed.toFixed(1)}ms mutate=${t.mutate.toFixed(1)}ms`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
renders = 0
|
|
108
|
+
let statsWin: ReturnType<typeof patchBlocksInto>
|
|
109
|
+
const tWin = bench(() => {
|
|
110
|
+
statsWin = patchBlocksInto(elWin, root, { renderer: counting, minWindowedBlocks: 0 })
|
|
111
|
+
})
|
|
112
|
+
const winRenders = renders
|
|
113
|
+
|
|
114
|
+
renders = 0
|
|
115
|
+
let statsFull: ReturnType<typeof patchBlocksInto>
|
|
116
|
+
const tFull = bench(() => {
|
|
117
|
+
statsFull = patchBlocksInto(elFull, root, { renderer: counting, change: undefined as never })
|
|
118
|
+
})
|
|
119
|
+
const fullRenders = renders
|
|
120
|
+
|
|
121
|
+
// Default production path: no forced option — the patcher picks the
|
|
122
|
+
// windowed vs full decision on its own (MIN_WINDOWED_BLOCKS applies).
|
|
123
|
+
const elDef = document.createElement('div')
|
|
124
|
+
model.applyTextUpdate(src)
|
|
125
|
+
patchBlocksInto(elDef, model.redRoot!, { renderer })
|
|
126
|
+
model.applyTextUpdate(next)
|
|
127
|
+
const tDefault = bench(() => {
|
|
128
|
+
patchBlocksInto(elDef, root, { renderer: counting })
|
|
129
|
+
})
|
|
130
|
+
const defaultMs = tDefault
|
|
131
|
+
|
|
132
|
+
if (PROFILE) {
|
|
133
|
+
console.log(
|
|
134
|
+
`[500k] ${name}: parse=${tParse.toFixed(1)}ms ` +
|
|
135
|
+
`windowed=${tWin.toFixed(2)}ms renders=${winRenders} ` +
|
|
136
|
+
`(${statsWin!.mode}/${statsWin!.windowed ? 'win' : 'full'}, ${statsWin!.patched}/${statsWin!.total}) ` +
|
|
137
|
+
`defaultPath=${defaultMs.toFixed(2)}ms ` +
|
|
138
|
+
`full=${tFull.toFixed(2)}ms renders=${fullRenders} (${statsFull!.patched}/${statsFull!.total})`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect(stripIds(elWin.innerHTML)).toBe(stripIds(elFull.innerHTML))
|
|
143
|
+
|
|
144
|
+
model.applyTextUpdate(src)
|
|
145
|
+
}
|
|
146
|
+
}, 120_000)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
function stripIds(html: string): string {
|
|
150
|
+
return html.replace(/ data-node-id="[^"]*"/g, '')
|
|
151
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edits battery — every kind of edit on the REAL 547KB osu! fixture.
|
|
3
|
+
*
|
|
4
|
+
* Loads `packages/quasar/500KCharsTest` (~547 KB: 60 imagemaps, hundreds of
|
|
5
|
+
* `[centre]`/`[colour]` gradient inlines, links) and drives it through 24
|
|
6
|
+
* edit phases covering every edit class:
|
|
7
|
+
*
|
|
8
|
+
* - inline typing (1 char at start/giant block/paragraph, backspace,
|
|
9
|
+
* char delete, word replace, tag-boundary edit)
|
|
10
|
+
* - blank-run growth/shrink
|
|
11
|
+
* - special blocks (imagemap content, url text)
|
|
12
|
+
* - structural block edits (prepend heading, insert box/quote/code,
|
|
13
|
+
* delete first/last/middle block, convert paragraph→heading, append)
|
|
14
|
+
* - big edits (8KB paste, 20KB delete, replace imagemap, nuke-most)
|
|
15
|
+
*
|
|
16
|
+
* The invariant is the engine's core contract: after EVERY edit the
|
|
17
|
+
* windowed-patched DOM must equal the full re-render (`morphHTML` of
|
|
18
|
+
* `renderer.render(root)`). That is the differential that proves the
|
|
19
|
+
* incremental paths never leave the DOM diverging — no matter where the
|
|
20
|
+
* edit lands or what it does. `stats.windowed` is recorded per phase so a
|
|
21
|
+
* regression that silently downgrades to the full walk is visible.
|
|
22
|
+
*
|
|
23
|
+
* A separate phase simulates holding a key down: 60 consecutive 1-char
|
|
24
|
+
* appends, each applied and patched separately, with a MutationObserver
|
|
25
|
+
* proving the DOM churn stays O(1)-per-keystroke (not O(blocks)).
|
|
26
|
+
*
|
|
27
|
+
* Run with `PROFILE=1` for the per-phase timings; the default run is pure
|
|
28
|
+
* correctness.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest'
|
|
32
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
35
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
36
|
+
import { morphHTML } from '../Visitors/DOMMorpher'
|
|
37
|
+
import { patchBlocksInto } from '../Visitors/BlockPatcher'
|
|
38
|
+
|
|
39
|
+
const PROFILE = process.env.PROFILE === '1'
|
|
40
|
+
|
|
41
|
+
function loadFixture(): string {
|
|
42
|
+
const candidates = [
|
|
43
|
+
join(process.cwd(), 'packages', 'quasar', '500KCharsTest'),
|
|
44
|
+
join(process.cwd(), '500KCharsTest'),
|
|
45
|
+
join(__dirname, '..', '..', '500KCharsTest'),
|
|
46
|
+
]
|
|
47
|
+
const p = candidates.find(c => existsSync(c))
|
|
48
|
+
if (!p) throw new Error(`Fixture not found in: ${candidates.join(', ')}`)
|
|
49
|
+
return readFileSync(p, 'utf-8')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Strip data-node-id so the differential is structural, not identity-based. */
|
|
53
|
+
function stripIds(html: string): string {
|
|
54
|
+
return html.replace(/ data-node-id="[^"]*"/g, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ground truth: a fresh element morphed with the full render of the root. */
|
|
58
|
+
function morphDOM(model: BBCodeDocumentModel, renderer: HTMLRenderer): HTMLDivElement {
|
|
59
|
+
const el = document.createElement('div')
|
|
60
|
+
if (model.redRoot) morphHTML(el, renderer.render(model.redRoot))
|
|
61
|
+
return el
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const insertAt = (s: string, pos: number, text: string): string =>
|
|
65
|
+
s.slice(0, pos) + text + s.slice(pos)
|
|
66
|
+
const del = (s: string, start: number, end: number): string => s.slice(0, start) + s.slice(end)
|
|
67
|
+
|
|
68
|
+
/** Position right after the next blank line (realistic Enter + paste spot). */
|
|
69
|
+
function afterBlank(s: string, pos: number): number {
|
|
70
|
+
const i = s.indexOf('\n\n', pos)
|
|
71
|
+
return i === -1 ? s.length : i + 2
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Phase {
|
|
75
|
+
name: string
|
|
76
|
+
edit: (src: string) => string
|
|
77
|
+
/** True: the windowed path MUST fire (small local edit). False: bail is fine. */
|
|
78
|
+
expectWindowed: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('Quasar @ 500k — every edit kind on the real fixture', () => {
|
|
82
|
+
it('batería de 24 ediciones con diferencial contra el morph completo', () => {
|
|
83
|
+
const src0 = loadFixture()
|
|
84
|
+
const renderer = new HTMLRenderer()
|
|
85
|
+
const winOpts = { renderer, minWindowedBlocks: 0 }
|
|
86
|
+
|
|
87
|
+
let current = src0
|
|
88
|
+
let model = new BBCodeDocumentModel({ source: current })
|
|
89
|
+
const el = document.createElement('div')
|
|
90
|
+
patchBlocksInto(el, model.redRoot!, winOpts) // prime the patch cache
|
|
91
|
+
|
|
92
|
+
const phases: Phase[] = [
|
|
93
|
+
{ name: 'type-1char-at-start', expectWindowed: true, edit: (s) => insertAt(s, 0, 'X') },
|
|
94
|
+
{
|
|
95
|
+
name: 'type-1char-in-giant-gradient',
|
|
96
|
+
expectWindowed: true,
|
|
97
|
+
edit: (s) => insertAt(s, s.indexOf('◆') + 1, 'X'),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'type-1char-in-paragraph',
|
|
101
|
+
expectWindowed: true,
|
|
102
|
+
edit: (s) => {
|
|
103
|
+
const p = s.indexOf('hxovc | Feel comfortable checking!')
|
|
104
|
+
return p === -1 ? s : insertAt(s, p, 'X')
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{ name: 'delete-1char-middle', expectWindowed: true, edit: (s) => del(s, s.indexOf('hxovc'), s.indexOf('hxovc') + 1) },
|
|
108
|
+
{ name: 'backspace-at-end', expectWindowed: true, edit: (s) => s.slice(0, -1) },
|
|
109
|
+
{ name: 'replace-word', expectWindowed: true, edit: (s) => s.replace('Twitch', 'TwitchTV') },
|
|
110
|
+
{
|
|
111
|
+
name: 'edit-at-tag-boundary',
|
|
112
|
+
expectWindowed: true,
|
|
113
|
+
edit: (s) => insertAt(s, s.indexOf('[/color]') + 8, 'Y'),
|
|
114
|
+
},
|
|
115
|
+
{ name: 'grow-blank-run', expectWindowed: true, edit: (s) => s.replace('\n\n', '\n\n\n\n\n') },
|
|
116
|
+
{
|
|
117
|
+
name: 'shrink-blank-run',
|
|
118
|
+
expectWindowed: true,
|
|
119
|
+
edit: (s) => {
|
|
120
|
+
const i = s.indexOf('\n\n\n\n')
|
|
121
|
+
return i === -1 ? s.replace('\n\n', '\n') : s.slice(0, i) + '\n\n' + s.slice(i + 4)
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'edit-inside-imagemap',
|
|
126
|
+
expectWindowed: true,
|
|
127
|
+
edit: (s) => s.replace('hxovc.s-ul.eu/84A4eZfV', 'hxovc.s-ul.eu/84A4eZfV2'),
|
|
128
|
+
},
|
|
129
|
+
{ name: 'edit-url-link-text', expectWindowed: true, edit: (s) => s.replace('Chess', 'ChessTV') },
|
|
130
|
+
{ name: 'prepend-heading-block', expectWindowed: true, edit: (s) => '[heading]TOP[/heading]\n\n' + s },
|
|
131
|
+
{
|
|
132
|
+
name: 'insert-box-block-mid',
|
|
133
|
+
expectWindowed: true,
|
|
134
|
+
edit: (s) => insertAt(s, afterBlank(s, Math.floor(s.length * 0.5)), '[box]caja nueva[/box]\n\n'),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'insert-quote-block-mid',
|
|
138
|
+
expectWindowed: true,
|
|
139
|
+
edit: (s) => insertAt(s, afterBlank(s, Math.floor(s.length * 0.6)), '[quote]cita nueva[/quote]\n\n'),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'insert-code-block-mid',
|
|
143
|
+
expectWindowed: true,
|
|
144
|
+
edit: (s) => insertAt(s, afterBlank(s, Math.floor(s.length * 0.4)), '[code]codigo nuevo[/code]\n\n'),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'convert-paragraph-to-heading',
|
|
148
|
+
expectWindowed: true,
|
|
149
|
+
edit: (s) => s.replace('hxovc | Feel comfortable checking!', '[heading]WELCOME[/heading]'),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'delete-middle-imagemap',
|
|
153
|
+
expectWindowed: true,
|
|
154
|
+
edit: (s) => {
|
|
155
|
+
const a = s.indexOf('[imagemap]')
|
|
156
|
+
if (a === -1) return s
|
|
157
|
+
const b = s.indexOf('[/imagemap]', a)
|
|
158
|
+
const end = b === -1 ? s.length : b + '[/imagemap]'.length
|
|
159
|
+
const after = s.indexOf('\n\n', end)
|
|
160
|
+
return del(s, a, after === -1 ? end : after + 2)
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{ name: 'append-block-at-end', expectWindowed: true, edit: (s) => s + '\n\n[box]fin[/box]' },
|
|
164
|
+
{ name: 'delete-last-block', expectWindowed: true, edit: (s) => s.slice(0, s.lastIndexOf('\n\n')) },
|
|
165
|
+
{
|
|
166
|
+
name: 'delete-first-block',
|
|
167
|
+
expectWindowed: true,
|
|
168
|
+
edit: (s) => {
|
|
169
|
+
const i = s.indexOf('\n\n')
|
|
170
|
+
return i === -1 ? s : s.slice(i + 2)
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'paste-8kb-mid',
|
|
175
|
+
expectWindowed: false, // 300 bloques nuevos: el churn cubre casi la mitad del doc → baila a full (correcto)
|
|
176
|
+
edit: (s) => {
|
|
177
|
+
const chunk = Array.from({ length: 300 }, () => '[centre][size=100][color=#ABCDEF]▬[/color][/size]').join('\n\n')
|
|
178
|
+
return insertAt(s, afterBlank(s, Math.floor(s.length * 0.3)), chunk + '\n\n')
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'delete-20k-mid',
|
|
183
|
+
// Regresión del 2026-08-10: borrar 20k en el medio tras un paste de
|
|
184
|
+
// 8KB re-keyea 24 runs sobrevivientes a ~24 posiciones de la banda de
|
|
185
|
+
// identidad del patcher — la ventana vieja los perdía, el walk insertaba
|
|
186
|
+
// duplicados y el DOM divergía (452 vs 428 childNodes). El fix extiende
|
|
187
|
+
// la banda mientras el run viejo sea twin (reference-identical) de la
|
|
188
|
+
// ventana nueva, así que este caso ahora es windowed Y correcto.
|
|
189
|
+
expectWindowed: true,
|
|
190
|
+
edit: (s) => del(s, Math.floor(s.length * 0.5), Math.floor(s.length * 0.5) + 20_000),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'replace-imagemap-content',
|
|
194
|
+
expectWindowed: true,
|
|
195
|
+
edit: (s) => s.replace('Sarichus', 'Sarichus2'),
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'nuke-most-of-doc',
|
|
199
|
+
expectWindowed: false, // window > half → must bail to full and still be exact
|
|
200
|
+
edit: (s) => s.slice(0, 800) + s.slice(s.length - 800),
|
|
201
|
+
},
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
const results: Array<{
|
|
205
|
+
name: string
|
|
206
|
+
parseMs: number
|
|
207
|
+
patchMs: number
|
|
208
|
+
windowed: boolean
|
|
209
|
+
patched: number
|
|
210
|
+
total: number
|
|
211
|
+
}> = []
|
|
212
|
+
|
|
213
|
+
for (const phase of phases) {
|
|
214
|
+
const next = phase.edit(current)
|
|
215
|
+
if (next === current) {
|
|
216
|
+
if (PROFILE) console.log(`[500k-edits] ${phase.name}: SKIP (edit sin cambio)`)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const t0 = performance.now()
|
|
221
|
+
model.applyTextUpdate(next)
|
|
222
|
+
model.ensureAnalyzed()
|
|
223
|
+
const parseMs = performance.now() - t0
|
|
224
|
+
const root = model.redRoot!
|
|
225
|
+
|
|
226
|
+
const t1 = performance.now()
|
|
227
|
+
const stats = patchBlocksInto(el, root, winOpts)
|
|
228
|
+
const patchMs = performance.now() - t1
|
|
229
|
+
|
|
230
|
+
const expected = morphDOM(model, renderer)
|
|
231
|
+
expect(stripIds(el.innerHTML), `divergencia tras "${phase.name}"`).toBe(stripIds(expected.innerHTML))
|
|
232
|
+
|
|
233
|
+
if (phase.expectWindowed) {
|
|
234
|
+
expect(
|
|
235
|
+
stats.windowed,
|
|
236
|
+
`"${phase.name}" debió ir por la ventana pero fue ${stats.mode}/${stats.windowed ? 'win' : 'full'}`,
|
|
237
|
+
).toBe(true)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (PROFILE && model.lastReparsePath) {
|
|
241
|
+
console.log(
|
|
242
|
+
`[500k-edits] └ reparse=${model.lastReparsePath}${model.lastReparseFallbackReason ? ` (${model.lastReparseFallbackReason})` : ''}`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
results.push({
|
|
247
|
+
name: phase.name,
|
|
248
|
+
parseMs,
|
|
249
|
+
patchMs,
|
|
250
|
+
windowed: !!stats.windowed,
|
|
251
|
+
patched: stats.patched,
|
|
252
|
+
total: stats.total,
|
|
253
|
+
})
|
|
254
|
+
current = next
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (PROFILE) {
|
|
258
|
+
console.log('\n[500k-edits] ---- batería (windowed forzado) ----')
|
|
259
|
+
for (const r of results) {
|
|
260
|
+
console.log(
|
|
261
|
+
`[500k-edits] ${r.name.padEnd(28)} parse=${r.parseMs.toFixed(1).padStart(7)}ms ` +
|
|
262
|
+
`patch=${r.patchMs.toFixed(2).padStart(7)}ms ` +
|
|
263
|
+
`${r.windowed ? 'WIN ' : 'FULL'} patched=${r.patched}/${r.total}`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
const win = results.filter((r) => r.windowed).length
|
|
267
|
+
const worst = results.reduce((m, r) => Math.max(m, r.patchMs), 0)
|
|
268
|
+
console.log(`[500k-edits] windowed=${win}/${results.length} worst-patch=${worst.toFixed(2)}ms`)
|
|
269
|
+
}
|
|
270
|
+
}, 300_000)
|
|
271
|
+
|
|
272
|
+
it('ráfaga de teclas (60x append de 1 char) — DOM churn O(1) por tecla', () => {
|
|
273
|
+
let s = loadFixture()
|
|
274
|
+
const renderer = new HTMLRenderer()
|
|
275
|
+
const winOpts = { renderer, minWindowedBlocks: 0 }
|
|
276
|
+
|
|
277
|
+
let model = new BBCodeDocumentModel({ source: s })
|
|
278
|
+
const el = document.createElement('div')
|
|
279
|
+
patchBlocksInto(el, model.redRoot!, winOpts)
|
|
280
|
+
|
|
281
|
+
const mo = new MutationObserver(() => {})
|
|
282
|
+
mo.observe(el, { childList: true })
|
|
283
|
+
|
|
284
|
+
const patchTimes: number[] = []
|
|
285
|
+
let winCount = 0
|
|
286
|
+
let maxMutations = 0
|
|
287
|
+
let worstPatch = 0
|
|
288
|
+
|
|
289
|
+
for (let k = 0; k < 60; k++) {
|
|
290
|
+
s += 'a'
|
|
291
|
+
model.applyTextUpdate(s)
|
|
292
|
+
model.ensureAnalyzed()
|
|
293
|
+
const t0 = performance.now()
|
|
294
|
+
const stats = patchBlocksInto(el, model.redRoot!, winOpts)
|
|
295
|
+
const dt = performance.now() - t0
|
|
296
|
+
patchTimes.push(dt)
|
|
297
|
+
worstPatch = Math.max(worstPatch, dt)
|
|
298
|
+
if (stats.windowed) winCount++
|
|
299
|
+
maxMutations = Math.max(maxMutations, mo.takeRecords().length)
|
|
300
|
+
}
|
|
301
|
+
mo.disconnect()
|
|
302
|
+
|
|
303
|
+
// Correctness after the burst: the DOM must equal the full re-render.
|
|
304
|
+
const expected = morphDOM(model, renderer)
|
|
305
|
+
expect(stripIds(el.innerHTML)).toBe(stripIds(expected.innerHTML))
|
|
306
|
+
|
|
307
|
+
const sorted = [...patchTimes].sort((a, b) => a - b)
|
|
308
|
+
const p50 = sorted[Math.floor(sorted.length / 2)]
|
|
309
|
+
const p95 = sorted[Math.floor(sorted.length * 0.95)]
|
|
310
|
+
if (PROFILE) {
|
|
311
|
+
console.log(
|
|
312
|
+
`[500k-edits] ráfaga: win=${winCount}/60 maxMutations/tecla=${maxMutations} ` +
|
|
313
|
+
`patch p50=${p50.toFixed(2)}ms p95=${p95.toFixed(2)}ms worst=${worstPatch.toFixed(2)}ms`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// O(1) per keystroke: never an O(blocks) rebuild storm in the burst.
|
|
318
|
+
expect(maxMutations).toBeLessThanOrEqual(12)
|
|
319
|
+
expect(winCount).toBeGreaterThanOrEqual(55)
|
|
320
|
+
}, 300_000)
|
|
321
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { transformOffset, transformRange } from '../Collab/positions'
|
|
3
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
4
|
+
import type { TextChange } from '../Incremental/ChangeTracker'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Position transforms are collaboration's ground floor: a caret or remote
|
|
8
|
+
* cursor must keep pointing at the same CONTENT while text shifts under it.
|
|
9
|
+
*
|
|
10
|
+
* The central property, fuzz-checked below: for any position outside an
|
|
11
|
+
* edit's replaced span, the character it pointed at before is the character
|
|
12
|
+
* it points at after. Positions inside deleted text have no content left to
|
|
13
|
+
* point at, so they collapse to the edit boundary — asserted separately.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function apply(source: string, c: TextChange): string {
|
|
17
|
+
return source.slice(0, c.start) + c.text + source.slice(c.end)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mulberry32(seed: number): () => number {
|
|
21
|
+
let a = seed >>> 0
|
|
22
|
+
return () => {
|
|
23
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0
|
|
24
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
25
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
26
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('transformOffset', () => {
|
|
31
|
+
const insert: TextChange = { start: 5, end: 5, text: 'XY' }
|
|
32
|
+
const del: TextChange = { start: 3, end: 7, text: '' }
|
|
33
|
+
const replace: TextChange = { start: 3, end: 7, text: 'Z' }
|
|
34
|
+
|
|
35
|
+
it('before the edit: unchanged', () => {
|
|
36
|
+
expect(transformOffset(2, insert)).toBe(2)
|
|
37
|
+
expect(transformOffset(2, del)).toBe(2)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('after the edit: shifted by the length delta', () => {
|
|
41
|
+
expect(transformOffset(9, insert)).toBe(11)
|
|
42
|
+
expect(transformOffset(9, del)).toBe(5)
|
|
43
|
+
expect(transformOffset(9, replace)).toBe(6)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('exactly at an insertion point: bias decides the side', () => {
|
|
47
|
+
expect(transformOffset(5, insert, 'right')).toBe(7)
|
|
48
|
+
expect(transformOffset(5, insert, 'left')).toBe(5)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('inside deleted text: collapses to the edit boundary', () => {
|
|
52
|
+
expect(transformOffset(5, del, 'left')).toBe(3)
|
|
53
|
+
expect(transformOffset(5, del, 'right')).toBe(3)
|
|
54
|
+
expect(transformOffset(5, replace, 'left')).toBe(3)
|
|
55
|
+
expect(transformOffset(5, replace, 'right')).toBe(4)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('composes over a sequence of changes', () => {
|
|
59
|
+
const changes: TextChange[] = [
|
|
60
|
+
{ start: 0, end: 0, text: 'aa' }, // caret at 5 → 7
|
|
61
|
+
{ start: 9, end: 9, text: 'bb' }, // after 7: unchanged
|
|
62
|
+
{ start: 1, end: 3, text: '' }, // before: 7 → 5
|
|
63
|
+
]
|
|
64
|
+
expect(transformOffset(5, changes)).toBe(5)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('property: positions outside the edit keep pointing at the same character', () => {
|
|
68
|
+
const rand = mulberry32(2026)
|
|
69
|
+
const alphabet = 'abcdefghij[]/ \n'
|
|
70
|
+
for (let round = 0; round < 2000; round++) {
|
|
71
|
+
let src = ''
|
|
72
|
+
const len = 10 + Math.floor(rand() * 60)
|
|
73
|
+
for (let i = 0; i < len; i++) src += alphabet[Math.floor(rand() * alphabet.length)]
|
|
74
|
+
|
|
75
|
+
const start = Math.floor(rand() * src.length)
|
|
76
|
+
const end = start + Math.floor(rand() * (src.length - start + 1))
|
|
77
|
+
let text = ''
|
|
78
|
+
const insertLen = Math.floor(rand() * 5)
|
|
79
|
+
for (let i = 0; i < insertLen; i++) text += alphabet[Math.floor(rand() * alphabet.length)]
|
|
80
|
+
const change: TextChange = { start, end, text }
|
|
81
|
+
const after = apply(src, change)
|
|
82
|
+
|
|
83
|
+
for (let offset = 0; offset < src.length; offset++) {
|
|
84
|
+
const isInsertionPoint = start === end && offset === start
|
|
85
|
+
if (offset >= start && offset <= end && !(offset === end && !isInsertionPoint)) {
|
|
86
|
+
// Inside the replaced span (or at the insertion point): only assert
|
|
87
|
+
// the result lands within the replacement's bounds.
|
|
88
|
+
const mapped = transformOffset(offset, change)
|
|
89
|
+
expect(mapped).toBeGreaterThanOrEqual(start)
|
|
90
|
+
expect(mapped).toBeLessThanOrEqual(start + text.length)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const mapped = transformOffset(offset, change)
|
|
94
|
+
expect(after[mapped]).toBe(src[offset])
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('transformRange', () => {
|
|
101
|
+
it('keeps covering exactly the content it covered', () => {
|
|
102
|
+
// "hola mundo cruel" — range over "mundo" = [5, 10)
|
|
103
|
+
const range = { start: 5, end: 10 }
|
|
104
|
+
|
|
105
|
+
// Insertion exactly at the start lands OUTSIDE the range.
|
|
106
|
+
expect(transformRange(range, { start: 5, end: 5, text: 'XX' })).toEqual({ start: 7, end: 12 })
|
|
107
|
+
// Insertion exactly at the end lands OUTSIDE too.
|
|
108
|
+
expect(transformRange(range, { start: 10, end: 10, text: 'XX' })).toEqual({ start: 5, end: 10 })
|
|
109
|
+
// Insertion strictly inside grows it.
|
|
110
|
+
expect(transformRange(range, { start: 7, end: 7, text: 'XX' })).toEqual({ start: 5, end: 12 })
|
|
111
|
+
// Deletion strictly before shifts it whole.
|
|
112
|
+
expect(transformRange(range, { start: 0, end: 2, text: '' })).toEqual({ start: 3, end: 8 })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('never inverts: a range inside deleted text collapses to a point', () => {
|
|
116
|
+
const out = transformRange({ start: 4, end: 8 }, { start: 2, end: 10, text: '' })
|
|
117
|
+
expect(out.start).toBe(out.end)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('origin on model events', () => {
|
|
122
|
+
it('travels from applyTextUpdate to the document_changed event', () => {
|
|
123
|
+
const model = new BBCodeDocumentModel({ source: 'hola mundo' })
|
|
124
|
+
const origins: Array<string | undefined> = []
|
|
125
|
+
model.events.on('document_changed', e => origins.push(e.origin as string | undefined))
|
|
126
|
+
|
|
127
|
+
model.applyTextUpdate('hola mundo!')
|
|
128
|
+
model.applyTextUpdate('hola mundo!!', 'remote')
|
|
129
|
+
|
|
130
|
+
expect(origins).toEqual(['local', 'remote'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('a sync layer can ignore its own echo', () => {
|
|
134
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
135
|
+
let userEdits = 0
|
|
136
|
+
model.events.on('document_changed', e => {
|
|
137
|
+
if (e.origin === 'local') userEdits++
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
model.applyTextUpdate('hola!', 'sync')
|
|
141
|
+
model.applyTextUpdate('hola! y algo', 'local')
|
|
142
|
+
model.applyTextUpdate('hola! y algo mas')
|
|
143
|
+
|
|
144
|
+
expect(userEdits).toBe(2)
|
|
145
|
+
})
|
|
146
|
+
})
|