@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,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `findNodeAtOffset` con rangos semiabiertos.
|
|
6
|
+
*
|
|
7
|
+
* Regresión concreta: en un degradado cada carácter es su propio nodo de
|
|
8
|
+
* color. Preguntar por el principio de un nodo devolvía el nodo ANTERIOR,
|
|
9
|
+
* porque con el final inclusivo ese offset pertenecía a los dos y ganaba el
|
|
10
|
+
* primero en el recorrido. En el preview, hacer clic en la `m` de «Welcome»
|
|
11
|
+
* resaltaba la `o`.
|
|
12
|
+
*/
|
|
13
|
+
describe('findNodeAtOffset — fronteras', () => {
|
|
14
|
+
const gradiente = '[color=#111111]W[/color][color=#222222]e[/color][color=#333333]l[/color]'
|
|
15
|
+
|
|
16
|
+
it('el principio de un nodo devuelve ESE nodo, no el anterior', () => {
|
|
17
|
+
const model = new BBCodeDocumentModel({ source: gradiente })
|
|
18
|
+
const raiz = model.redRoot!
|
|
19
|
+
|
|
20
|
+
// Los tres nodos de color, en orden.
|
|
21
|
+
const colores: typeof raiz[] = []
|
|
22
|
+
raiz.walk(n => { if (n.kind === 'color') colores.push(n) })
|
|
23
|
+
expect(colores).toHaveLength(3)
|
|
24
|
+
|
|
25
|
+
for (const nodo of colores) {
|
|
26
|
+
const encontrado = model.redRoot!.findNodeAtOffset(nodo.range.start)
|
|
27
|
+
// El nodo encontrado debe ser el propio o un descendiente suyo, nunca
|
|
28
|
+
// el vecino de la izquierda.
|
|
29
|
+
let actual = encontrado
|
|
30
|
+
let esDescendiente = false
|
|
31
|
+
while (actual) {
|
|
32
|
+
if (actual === nodo) { esDescendiente = true; break }
|
|
33
|
+
actual = actual.parent
|
|
34
|
+
}
|
|
35
|
+
expect(esDescendiente, `offset ${nodo.range.start} cayó fuera de su nodo`).toBe(true)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('cada carácter del texto resuelve a su propio nodo de color', () => {
|
|
40
|
+
const model = new BBCodeDocumentModel({ source: gradiente })
|
|
41
|
+
// Offsets de las letras W, e, l dentro de sus etiquetas.
|
|
42
|
+
for (const letra of ['W', 'e', 'l']) {
|
|
43
|
+
const offset = gradiente.indexOf(`]${letra}[`) + 1
|
|
44
|
+
const nodo = model.redRoot!.findNodeAtOffset(offset)
|
|
45
|
+
expect(nodo, `letra ${letra}`).toBeTruthy()
|
|
46
|
+
expect(nodo!.text || nodo!.parent?.text).toBeDefined()
|
|
47
|
+
// El texto alcanzable desde el nodo debe ser esa letra.
|
|
48
|
+
let texto = ''
|
|
49
|
+
nodo!.walk(n => { if (n.kind === 'text') texto += n.text })
|
|
50
|
+
expect(texto || nodo!.text, `letra ${letra}`).toBe(letra)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('el final del documento devuelve la raíz en vez de null', () => {
|
|
55
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
56
|
+
const raiz = model.redRoot!
|
|
57
|
+
expect(model.redRoot!.findNodeAtOffset(raiz.range.end)).toBe(raiz)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('un offset fuera del documento sigue siendo null', () => {
|
|
61
|
+
const model = new BBCodeDocumentModel({ source: 'hola' })
|
|
62
|
+
expect(model.redRoot!.findNodeAtOffset(999)).toBeNull()
|
|
63
|
+
expect(model.redRoot!.findNodeAtOffset(-1)).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
import { BBCodeExporter } from '../Visitors/BBCodeExporter'
|
|
4
|
+
import { ASTOptimizer } from '../Transformers/ASTOptimizer'
|
|
5
|
+
import { TagRegistry } from '../Model/TagRegistry'
|
|
6
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
7
|
+
|
|
8
|
+
// ─── 0. Deterministic PRNG ────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// This suite used bare `Math.random()`. It failed roughly 50% of runs, and
|
|
11
|
+
// when it failed the payload that broke the optimizer was gone — nothing to
|
|
12
|
+
// reproduce, nothing to regression-test. A fuzzer you cannot replay is not a
|
|
13
|
+
// fuzzer, it is a coin flip attached to your CI.
|
|
14
|
+
//
|
|
15
|
+
// The seed is fixed by default and overridable, so a failure is always
|
|
16
|
+
// reproducible: QUASAR_FUZZ_SEED=123456 npm test
|
|
17
|
+
|
|
18
|
+
const FUZZ_SEED = Number(process.env.QUASAR_FUZZ_SEED ?? 0x5EED_1A57)
|
|
19
|
+
|
|
20
|
+
/** mulberry32 — small, fast, good enough distribution for structural fuzzing. */
|
|
21
|
+
function mulberry32(seed: number): () => number {
|
|
22
|
+
let a = seed >>> 0
|
|
23
|
+
return () => {
|
|
24
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
25
|
+
let t = a
|
|
26
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
27
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
28
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── 1. Fuzzing Generator ─────────────────────────────────────
|
|
33
|
+
class BBCodeFuzzer {
|
|
34
|
+
private random: () => number
|
|
35
|
+
|
|
36
|
+
constructor(seed: number = FUZZ_SEED) {
|
|
37
|
+
this.random = mulberry32(seed)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private tags = [
|
|
41
|
+
{ open: '[b]', close: '[/b]' },
|
|
42
|
+
{ open: '[i]', close: '[/i]' },
|
|
43
|
+
{ open: '[color=red]', close: '[/color]' },
|
|
44
|
+
{ open: '[color=blue]', close: '[/color]' },
|
|
45
|
+
{ open: '[size=50]', close: '[/size]' },
|
|
46
|
+
{ open: '[size=20]', close: '[/size]' },
|
|
47
|
+
{ open: '[url=https://osu.ppy.sh]', close: '[/url]' },
|
|
48
|
+
{ open: '[notice]', close: '[/notice]' }
|
|
49
|
+
]
|
|
50
|
+
private words = ['osu!', 'peppy', 'click', 'circles', 'to', 'the', 'beat', 'combo', 'FC', 'SS']
|
|
51
|
+
|
|
52
|
+
private randomInt(max: number) {
|
|
53
|
+
return Math.floor(this.random() * max)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private randomWord() {
|
|
57
|
+
return this.words[this.randomInt(this.words.length)]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private randomSpace() {
|
|
61
|
+
const spaces = [' ', ' ', '\n', '\n\n']
|
|
62
|
+
return spaces[this.randomInt(spaces.length)]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Generates a random AST structure as BBCode string
|
|
66
|
+
generate(depth: number = 0, length: number = 5): string {
|
|
67
|
+
if (depth > 4) return this.randomWord()
|
|
68
|
+
|
|
69
|
+
let result = ''
|
|
70
|
+
for (let i = 0; i < length; i++) {
|
|
71
|
+
const choice = this.randomInt(10)
|
|
72
|
+
|
|
73
|
+
if (choice < 3) {
|
|
74
|
+
// Plain text
|
|
75
|
+
result += this.randomWord() + this.randomSpace()
|
|
76
|
+
} else if (choice < 6) {
|
|
77
|
+
// Tag pair (validly nested)
|
|
78
|
+
const tag = this.tags[this.randomInt(this.tags.length)]
|
|
79
|
+
result += `${tag.open}${this.generate(depth + 1, 2)}${tag.close}`
|
|
80
|
+
} else if (choice < 8) {
|
|
81
|
+
// Empty tag
|
|
82
|
+
const tag = this.tags[this.randomInt(this.tags.length)]
|
|
83
|
+
result += `${tag.open}${tag.close}`
|
|
84
|
+
} else if (choice === 8) {
|
|
85
|
+
// Orphan opening tag
|
|
86
|
+
const tag = this.tags[this.randomInt(this.tags.length)]
|
|
87
|
+
result += tag.open
|
|
88
|
+
} else {
|
|
89
|
+
// Orphan closing tag
|
|
90
|
+
const tag = this.tags[this.randomInt(this.tags.length)]
|
|
91
|
+
result += tag.close
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── 2. Validation Helpers ────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function getPlainText(node: RedNode): string {
|
|
102
|
+
if (node.kind === 'text') return node.text || ''
|
|
103
|
+
if (node.kind === 'spacing') return node.text || ''
|
|
104
|
+
if (node.kind === 'empty_line') return '\n\n'
|
|
105
|
+
return node.children.map(c => getPlainText(c)).join('')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── 3. The Test Suite ────────────────────────────────────────
|
|
109
|
+
describe('Empirical Fuzz Testing - AST Optimizer', () => {
|
|
110
|
+
const fuzzer = new BBCodeFuzzer()
|
|
111
|
+
const registry = new TagRegistry()
|
|
112
|
+
|
|
113
|
+
// Create 100 random BBCode payloads
|
|
114
|
+
const samples = Array.from({ length: 100 }, () => fuzzer.generate(0, 10))
|
|
115
|
+
|
|
116
|
+
// Specific pathological edge cases we know about
|
|
117
|
+
samples.push(
|
|
118
|
+
'[b][b]Redundant[/b][/b]',
|
|
119
|
+
'[color=red][b]Inverted[/b][/color]',
|
|
120
|
+
'[size=50][size=20]Overridden[/size][/size]',
|
|
121
|
+
'[b]Merge[/b] [b]Me[/b]',
|
|
122
|
+
'[b][color=red]A[/color][/b] [b][color=red]B[/color][/b]',
|
|
123
|
+
'[color=red][b]A[/b][/color] [color=red][b]B[/b][/color]',
|
|
124
|
+
'[b]Spaces [/b]',
|
|
125
|
+
'[[[[[not a tag]',
|
|
126
|
+
'[b][/i][/b]'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
it.each(samples.map((bbcode, i) => [i, bbcode]))('Fuzz Case %i', (_, source) => {
|
|
130
|
+
// 1. Parsing should not crash
|
|
131
|
+
const model = new BBCodeDocumentModel({ source, strictMode: false })
|
|
132
|
+
expect(model.redRoot).toBeDefined()
|
|
133
|
+
|
|
134
|
+
const originalText = getPlainText(model.redRoot!)
|
|
135
|
+
|
|
136
|
+
// 2. Optimization should not crash
|
|
137
|
+
const optimizer = new ASTOptimizer()
|
|
138
|
+
optimizer.transform(model)
|
|
139
|
+
|
|
140
|
+
// 3. Lossless Text: Optimization must NEVER delete or alter visible text/spacing
|
|
141
|
+
const optimizedText = getPlainText(model.redRoot!)
|
|
142
|
+
expect(optimizedText).toBe(originalText)
|
|
143
|
+
|
|
144
|
+
// 4. Exporting should not crash
|
|
145
|
+
const exporter = new BBCodeExporter(registry)
|
|
146
|
+
const exported1 = exporter.export(model.redRoot!)
|
|
147
|
+
|
|
148
|
+
// 5. Idempotence: optimizing an already optimized tree should produce no changes
|
|
149
|
+
const model2 = new BBCodeDocumentModel({ source: exported1, strictMode: false })
|
|
150
|
+
const optimizer2 = new ASTOptimizer()
|
|
151
|
+
const { transaction } = optimizer2.transform(model2)
|
|
152
|
+
|
|
153
|
+
// An idempotent optimizer shouldn't need to mutate the tree a second time
|
|
154
|
+
// We allow 0 operations!
|
|
155
|
+
if (transaction && transaction.operations.length > 0) {
|
|
156
|
+
console.log('--- IDEMPOTENCE FAILURE ---')
|
|
157
|
+
console.log('Exported:', exported1)
|
|
158
|
+
console.log('Operations:', JSON.stringify(transaction.operations.map(o => o.kind)))
|
|
159
|
+
}
|
|
160
|
+
expect(transaction?.operations.length || 0).toBe(0)
|
|
161
|
+
|
|
162
|
+
// 6. Round-trip stability
|
|
163
|
+
const exported2 = model2.redRoot ? exporter.export(model2.redRoot) : ''
|
|
164
|
+
expect(exported2).toBe(exported1)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural sharing (roadmap point 5 / S2).
|
|
3
|
+
*
|
|
4
|
+
* The interner used to be broken in a way no test could catch, because the
|
|
5
|
+
* tests only asserted that identical leaves came back as the same object —
|
|
6
|
+
* which they did. What they never checked was the thing that made it unusable:
|
|
7
|
+
* the shared node carried the FIRST occurrence's offsets, so switching it on
|
|
8
|
+
* made every position in the document lie.
|
|
9
|
+
*
|
|
10
|
+
* That failure is not expressible any more (green nodes have no positions),
|
|
11
|
+
* but the property it violated is what these tests assert: interning must be
|
|
12
|
+
* INVISIBLE. Same tree, same offsets, same output — just fewer objects.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
15
|
+
import { GreenNodePool, internGreenLeaf } from '../Syntax/GreenNodePool'
|
|
16
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
17
|
+
import { parseBBCode } from '../BBCode/Parser'
|
|
18
|
+
import { GreenNode } from '../Syntax/GreenNode'
|
|
19
|
+
import { greenToRedNode } from '../BBCode/BBCodeToGreenNode'
|
|
20
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
21
|
+
import { TagRegistry } from '../Model/TagRegistry'
|
|
22
|
+
import type { RedNode } from '../Syntax/RedNode'
|
|
23
|
+
|
|
24
|
+
const registry = new TagRegistry()
|
|
25
|
+
const renderer = new HTMLRenderer({ registry })
|
|
26
|
+
const stripIds = (h: string) => h.replace(/ data-node-id="[^"]*"/g, '')
|
|
27
|
+
|
|
28
|
+
/** Structural equality that deliberately ignores object identity. */
|
|
29
|
+
function sameShape(a: GreenNode, b: GreenNode): boolean {
|
|
30
|
+
if (a.kind !== b.kind || a.text !== b.text) return false
|
|
31
|
+
if (a.width !== b.width) return false
|
|
32
|
+
if (a.leadingWidth !== b.leadingWidth || a.trailingWidth !== b.trailingWidth) return false
|
|
33
|
+
if (a.children.length !== b.children.length) return false
|
|
34
|
+
for (let i = 0; i < a.children.length; i++) {
|
|
35
|
+
if (!sameShape(a.children[i] as GreenNode, b.children[i] as GreenNode)) return false
|
|
36
|
+
}
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function countObjects(root: GreenNode): { reachable: number; distinct: number } {
|
|
41
|
+
let reachable = 0
|
|
42
|
+
const seen = new Set<GreenNode>()
|
|
43
|
+
const stack: GreenNode[] = [root]
|
|
44
|
+
while (stack.length > 0) {
|
|
45
|
+
const n = stack.pop()!
|
|
46
|
+
reachable++
|
|
47
|
+
seen.add(n)
|
|
48
|
+
for (const c of n.children as GreenNode[]) stack.push(c)
|
|
49
|
+
}
|
|
50
|
+
return { reachable, distinct: seen.size }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function allRanges(root: RedNode): string[] {
|
|
54
|
+
const out: string[] = []
|
|
55
|
+
const visit = (n: RedNode): void => {
|
|
56
|
+
out.push(`${n.kind}[${n.range.start}..${n.range.end}]`)
|
|
57
|
+
for (const c of n.children) visit(c)
|
|
58
|
+
}
|
|
59
|
+
visit(root)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('GreenNodePool — structural sharing', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
GreenNodePool.instance.clear()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns the exact same object for identical leaves', () => {
|
|
69
|
+
const leaf1 = internGreenLeaf('text', 'Miliastry')
|
|
70
|
+
const leaf2 = internGreenLeaf('text', 'Miliastry')
|
|
71
|
+
|
|
72
|
+
expect(leaf1).toBe(leaf2)
|
|
73
|
+
expect(GreenNodePool.instance.stats.hits).toBe(1)
|
|
74
|
+
expect(GreenNodePool.instance.stats.misses).toBe(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('creates distinct nodes for different text payloads', () => {
|
|
78
|
+
expect(internGreenLeaf('text', 'Hola')).not.toBe(internGreenLeaf('text', 'Mundo'))
|
|
79
|
+
expect(GreenNodePool.instance.stats.size).toBe(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps leaves of the same text but different width apart', () => {
|
|
83
|
+
// `spacing` carries no text but occupies 1 character for `\n` and 2 for
|
|
84
|
+
// `\r\n`. Keying on text alone would merge them and shorten the document.
|
|
85
|
+
const lf = internGreenLeaf('spacing', '', 1)
|
|
86
|
+
const crlf = internGreenLeaf('spacing', '', 2)
|
|
87
|
+
|
|
88
|
+
expect(lf).not.toBe(crlf)
|
|
89
|
+
expect(lf.width).toBe(1)
|
|
90
|
+
expect(crlf.width).toBe(2)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const SOURCES = [
|
|
94
|
+
'[b]x[/b][b]x[/b][b]x[/b]',
|
|
95
|
+
'[color=#e8b04b]A[/color][color=#e8b04b]B[/color][color=#e8b04b]A[/color]',
|
|
96
|
+
'[centre][b]hola[/b] mundo[/centre]\n\notro parrafo\n\n[/b]huerfano',
|
|
97
|
+
'[list][*]uno[*]dos[/list][quote]cita[/quote]',
|
|
98
|
+
'texto plano sin etiquetas',
|
|
99
|
+
'',
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
for (const mode of ['leaves', 'full'] as const) {
|
|
103
|
+
describe(`mode '${mode}'`, () => {
|
|
104
|
+
for (const source of SOURCES) {
|
|
105
|
+
it(`is invisible for ${JSON.stringify(source.slice(0, 40))}`, () => {
|
|
106
|
+
const plain = parseBBCode(source)
|
|
107
|
+
const interned = parseBBCode(source, { interner: GreenNodePool.create(mode) })
|
|
108
|
+
|
|
109
|
+
// The tree must be structurally identical...
|
|
110
|
+
expect(sameShape(plain, interned)).toBe(true)
|
|
111
|
+
// ...the offsets the red tree derives must be identical...
|
|
112
|
+
expect(allRanges(greenToRedNode(interned))).toEqual(allRanges(greenToRedNode(plain)))
|
|
113
|
+
// ...and so must the rendered output.
|
|
114
|
+
expect(stripIds(renderer.render(greenToRedNode(interned))))
|
|
115
|
+
.toBe(stripIds(renderer.render(greenToRedNode(plain))))
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
it('actually shares objects on a repetitive document', () => {
|
|
122
|
+
const source = '[b]x[/b][b]x[/b][b]x[/b][b]x[/b]'
|
|
123
|
+
|
|
124
|
+
const plain = countObjects(parseBBCode(source))
|
|
125
|
+
const leaves = countObjects(parseBBCode(source, { interner: GreenNodePool.create('leaves') }))
|
|
126
|
+
const full = countObjects(parseBBCode(source, { interner: GreenNodePool.create('full') }))
|
|
127
|
+
|
|
128
|
+
expect(plain.distinct).toBe(plain.reachable) // nothing shared
|
|
129
|
+
expect(leaves.distinct).toBeLessThan(plain.distinct)
|
|
130
|
+
expect(full.distinct).toBeLessThan(leaves.distinct) // internal nodes too
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('is off by default on the document model', () => {
|
|
134
|
+
// It is a memory/latency trade and this engine's budget is latency, so it
|
|
135
|
+
// must stay opt-in. See `BBCodeDocumentModelOptions.interning`.
|
|
136
|
+
const source = '[b]x[/b][b]x[/b]'
|
|
137
|
+
const plain = new BBCodeDocumentModel({ source, autoAnalyze: false })
|
|
138
|
+
const shared = new BBCodeDocumentModel({ source, autoAnalyze: false, interning: 'full' })
|
|
139
|
+
|
|
140
|
+
expect(countObjects(plain.greenRoot!).distinct).toBe(countObjects(plain.greenRoot!).reachable)
|
|
141
|
+
expect(countObjects(shared.greenRoot!).distinct)
|
|
142
|
+
.toBeLessThan(countObjects(shared.greenRoot!).reachable)
|
|
143
|
+
// Same offsets either way — that is the whole contract.
|
|
144
|
+
expect(allRanges(shared.redRoot!)).toEqual(allRanges(plain.redRoot!))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('computes correct RedNode offsets even when green nodes are deduplicated', () => {
|
|
148
|
+
const source = 'Hola Mundo Hola'
|
|
149
|
+
const doc = new BBCodeDocumentModel({ source, interning: 'full' })
|
|
150
|
+
|
|
151
|
+
expect(doc.redRoot!.range).toEqual({ start: 0, end: source.length })
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BBCodeLexer — decisions on malformed input.
|
|
3
|
+
*
|
|
4
|
+
* The lexer had no tests of its own, which is backwards: its whole job is to
|
|
5
|
+
* make a defensible decision about garbage, and an editor's buffer is garbage
|
|
6
|
+
* most of the time — half-typed tags, lone brackets, unclosed raw blocks. The
|
|
7
|
+
* happy path was covered incidentally by every other suite; the states that
|
|
8
|
+
* actually exercise it were covered by nothing.
|
|
9
|
+
*
|
|
10
|
+
* Two properties matter here beyond "it returns the right tokens":
|
|
11
|
+
*
|
|
12
|
+
* - **Coverage.** Tokens must tile `[0..source.length)` with no gap and no
|
|
13
|
+
* overlap. The parser builds the partition invariant (point 14) on top of
|
|
14
|
+
* this, so a hole here becomes a hole in the tree.
|
|
15
|
+
* - **Case folding.** Tag names are lower-cased, and the fast path skips the
|
|
16
|
+
* allocation when there is nothing to fold. `[COLOR]` and `[color]` must
|
|
17
|
+
* stay indistinguishable to everything downstream.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from 'vitest'
|
|
20
|
+
import { REFERENCE_DOCUMENT, REFERENCE_DOCUMENT_WITH_GRADIENT } from './referenceDocument'
|
|
21
|
+
import { scanBBCode, type BBCodeToken } from '../Lexer/BBCodeLexer'
|
|
22
|
+
|
|
23
|
+
/** Compact form of a token stream, for readable expectations. */
|
|
24
|
+
const brief = (source: string): string[] =>
|
|
25
|
+
scanBBCode(source).map(t => {
|
|
26
|
+
switch (t.kind) {
|
|
27
|
+
case 'open': return `open:${t.tag}${t.attrs ? `(${t.attrs})` : ''}`
|
|
28
|
+
case 'close': return `close:${t.tag}`
|
|
29
|
+
case 'text': return `text:${JSON.stringify(t.value)}`
|
|
30
|
+
case 'newline': return `nl:${JSON.stringify(t.value)}`
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Tokens must tile the source exactly — no gaps, no overlaps, nothing dropped. */
|
|
35
|
+
function coverageGap(source: string): string | null {
|
|
36
|
+
let expected = 0
|
|
37
|
+
for (const t of scanBBCode(source)) {
|
|
38
|
+
if (t.start !== expected) {
|
|
39
|
+
return `token en [${t.start}..${t.end}] pero se esperaba empezar en ${expected}`
|
|
40
|
+
}
|
|
41
|
+
expected = t.end
|
|
42
|
+
}
|
|
43
|
+
return expected === source.length
|
|
44
|
+
? null
|
|
45
|
+
: `los tokens acaban en ${expected}, la fuente mide ${source.length}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every token's span must contain exactly the text it claims. */
|
|
49
|
+
function textMismatch(source: string): string | null {
|
|
50
|
+
for (const t of scanBBCode(source) as BBCodeToken[]) {
|
|
51
|
+
if (t.kind === 'text' || t.kind === 'newline') {
|
|
52
|
+
const slice = source.slice(t.start, t.end)
|
|
53
|
+
if (slice !== t.value) {
|
|
54
|
+
return `[${t.start}..${t.end}] contiene ${JSON.stringify(slice)} pero el token dice ${JSON.stringify(t.value)}`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('BBCodeLexer', () => {
|
|
62
|
+
describe('etiquetas válidas', () => {
|
|
63
|
+
it('abre y cierra', () => {
|
|
64
|
+
expect(brief('[b]x[/b]')).toEqual(['open:b', 'text:"x"', 'close:b'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('separa el nombre de los atributos', () => {
|
|
68
|
+
expect(brief('[color=#FF0000]x[/color]'))
|
|
69
|
+
.toEqual(['open:color(=#FF0000)', 'text:"x"', 'close:color'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('resuelve el `]` correcto con BBCode anidado en los atributos', () => {
|
|
73
|
+
expect(brief('[box=[b]t[/b]]c[/box]'))
|
|
74
|
+
.toEqual(['open:box(=[b]t[/b])', 'text:"c"', 'close:box'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('acepta `_` y `*` en el nombre', () => {
|
|
78
|
+
expect(brief('[*]')).toEqual(['open:*'])
|
|
79
|
+
expect(brief('[_custom]')).toEqual(['open:_custom'])
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('normalización de mayúsculas', () => {
|
|
84
|
+
// El camino rápido evita `toLowerCase()` cuando no hay nada que plegar,
|
|
85
|
+
// así que hay que comprobar las dos ramas.
|
|
86
|
+
it('pliega el nombre pero NO los atributos', () => {
|
|
87
|
+
expect(brief('[COLOR=#E8B04B]x[/COLOR]'))
|
|
88
|
+
.toEqual(['open:color(=#E8B04B)', 'text:"x"', 'close:color'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('mayúsculas y minúsculas dan el mismo nombre', () => {
|
|
92
|
+
for (const [a, b] of [['[B]', '[b]'], ['[/B]', '[/b]'], ['[BoX=1]', '[box=1]']]) {
|
|
93
|
+
expect([a, brief(a)]).toEqual([a, brief(b).map(x => x)])
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('un nombre mixto se pliega entero', () => {
|
|
98
|
+
expect(brief('[SpOiLeRbOx]')).toEqual(['open:spoilerbox'])
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('sintaxis inválida → texto literal', () => {
|
|
103
|
+
const cases: [string, string[]][] = [
|
|
104
|
+
['[', ['text:"["']],
|
|
105
|
+
[']', ['text:"]"']],
|
|
106
|
+
['[]', ['text:"["', 'text:"]"']],
|
|
107
|
+
['[/]', ['text:"["', 'text:"/]"']],
|
|
108
|
+
['[ ]', ['text:"["', 'text:" ]"']],
|
|
109
|
+
['[=]', ['text:"["', 'text:"=]"']],
|
|
110
|
+
['[/has space]', ['text:"["', 'text:"/has space]"']],
|
|
111
|
+
['x[y', ['text:"x"', 'text:"["', 'text:"y"']],
|
|
112
|
+
]
|
|
113
|
+
for (const [source, expected] of cases) {
|
|
114
|
+
it(JSON.stringify(source), () => {
|
|
115
|
+
expect(brief(source)).toEqual(expected)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('apertura y cierre NO son simétricos', () => {
|
|
121
|
+
// Esto sorprende y es deliberado, así que queda fijado aquí.
|
|
122
|
+
//
|
|
123
|
+
// En una APERTURA el nombre es la tirada inicial de caracteres válidos y
|
|
124
|
+
// todo lo demás pasa a ser atributos, por permisiva que quede la etiqueta.
|
|
125
|
+
// En un CIERRE el nombre debe llenar el hueco entero.
|
|
126
|
+
//
|
|
127
|
+
// No es inofensivo por accidente: el parser convierte las etiquetas que no
|
|
128
|
+
// conoce (`has`, `123`) en nodos `text`, así que el usuario ve lo que
|
|
129
|
+
// escribió. Por eso nadie lo había notado.
|
|
130
|
+
it('en apertura, lo que no es nombre se vuelve atributos', () => {
|
|
131
|
+
expect(brief('[has space]')).toEqual(['open:has(space)'])
|
|
132
|
+
expect(brief('[123+456]')).toEqual(['open:123(+456)'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('en cierre, el nombre debe llenar todo el hueco', () => {
|
|
136
|
+
expect(brief('[/has space]')).toEqual(['text:"["', 'text:"/has space]"'])
|
|
137
|
+
expect(brief('[/b x]')).toEqual(['text:"["', 'text:"/b x]"'])
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('saltos de línea', () => {
|
|
142
|
+
it('emite \\r\\n como un solo token', () => {
|
|
143
|
+
expect(brief('a\r\nb')).toEqual(['text:"a"', 'nl:"\\r\\n"', 'text:"b"'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('emite \\n y \\r sueltos por separado', () => {
|
|
147
|
+
expect(brief('a\n\rb')).toEqual(['text:"a"', 'nl:"\\n"', 'nl:"\\r"', 'text:"b"'])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('bloques raw', () => {
|
|
152
|
+
it('el contenido de [code] es literal', () => {
|
|
153
|
+
expect(brief('[code]raw [b]x[/b][/code]'))
|
|
154
|
+
.toEqual(['open:code', 'text:"raw [b]x[/b]"', 'close:code'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('[c] se comporta igual', () => {
|
|
158
|
+
expect(brief('[c][i]y[/i][/c]')).toEqual(['open:c', 'text:"[i]y[/i]"', 'close:c'])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('un [code] sin cerrar se traga el resto', () => {
|
|
162
|
+
expect(brief('[code]resto [b]x')).toEqual(['open:code', 'text:"resto [b]x"'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('encuentra el cierre aunque difiera en mayúsculas', () => {
|
|
166
|
+
// El source en minúsculas se calcula de forma perezosa; este es el único
|
|
167
|
+
// caso que lo necesita, y por eso importa que siga funcionando.
|
|
168
|
+
expect(brief('[code]x[/CODE]')).toEqual(['open:code', 'text:"x"', 'close:code'])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('[code] vacío no emite token de texto', () => {
|
|
172
|
+
expect(brief('[code][/code]')).toEqual(['open:code', 'close:code'])
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('los tokens cubren la fuente exactamente', () => {
|
|
177
|
+
// Was two untracked `.milia` files read from disk; they vanished and took
|
|
178
|
+
// seven tests with them. See `referenceDocument.ts`.
|
|
179
|
+
const fixtures = {
|
|
180
|
+
'reference document': REFERENCE_DOCUMENT,
|
|
181
|
+
'reference document with gradient': REFERENCE_DOCUMENT_WITH_GRADIENT,
|
|
182
|
+
}
|
|
183
|
+
for (const [name, source] of Object.entries(fixtures)) {
|
|
184
|
+
it(`fixture ${name}`, () => {
|
|
185
|
+
expect(coverageGap(source)).toBeNull()
|
|
186
|
+
expect(textMismatch(source)).toBeNull()
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cases = [
|
|
191
|
+
'', '[', ']', '[]', '[b]', '[/b]', '[b]x[/b]', '[[[[[[', ']]]]]]',
|
|
192
|
+
'[code]x', '[code]x[/code]', '[c]x', 'a\r\nb', '\r', '\n\n\n',
|
|
193
|
+
'[box=[b]t[/b]]c[/box]', '🎵[b]á€ñ[/b]🎵', '[has space]', '[/b x]',
|
|
194
|
+
]
|
|
195
|
+
for (const source of cases) {
|
|
196
|
+
it(JSON.stringify(source), () => {
|
|
197
|
+
expect(coverageGap(source)).toBeNull()
|
|
198
|
+
expect(textMismatch(source)).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('fuzz con semilla', () => {
|
|
204
|
+
function mulberry32(seed: number): () => number {
|
|
205
|
+
let a = seed >>> 0
|
|
206
|
+
return () => {
|
|
207
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
208
|
+
let t = a
|
|
209
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
210
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
211
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const PIECES = [
|
|
216
|
+
'[b]', '[/b]', '[COLOR=#FF0000]', '[/COLOR]', '[size=50]', '[', ']', '[[',
|
|
217
|
+
']]', '[/', '[]', '[/]', '[ ]', '[has space]', '[123+456]', '[_custom]',
|
|
218
|
+
'[*]', '[code]', '[/code]', '[c]', '[/c]', '[box=[b]t[/b]]', 'texto',
|
|
219
|
+
' ', '\n', '\r\n', '🎵', 'á€ñ', '[b', 'b]', '[=]', '[b =x]',
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
for (const seed of [1, 42, 1337, 0x5eed1a57]) {
|
|
223
|
+
it(`semilla ${seed}: 2000 documentos se cubren enteros`, () => {
|
|
224
|
+
const rnd = mulberry32(seed)
|
|
225
|
+
for (let i = 0; i < 2000; i++) {
|
|
226
|
+
const n = 1 + Math.floor(rnd() * 30)
|
|
227
|
+
let source = ''
|
|
228
|
+
for (let j = 0; j < n; j++) source += PIECES[Math.floor(rnd() * PIECES.length)]
|
|
229
|
+
|
|
230
|
+
const gap = coverageGap(source)
|
|
231
|
+
if (gap) throw new Error(`semilla ${seed} #${i}: ${gap}\n src=${JSON.stringify(source)}`)
|
|
232
|
+
const mismatch = textMismatch(source)
|
|
233
|
+
if (mismatch) throw new Error(`semilla ${seed} #${i}: ${mismatch}\n src=${JSON.stringify(source)}`)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
})
|