@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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. 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
+ })