@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,150 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
import { PluginAPI } from '../Plugins/PluginAPI'
|
|
4
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
5
|
+
import { BBCodeExporter } from '../Visitors/BBCodeExporter'
|
|
6
|
+
import type { RedNode } from '../Syntax/RedNode'
|
|
7
|
+
import type { NodeKind } from '../Types/core'
|
|
8
|
+
import type { TagDefinition } from '../Model/TagRegistry'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A plugin tag must live the FULL life of a tag: lex → parse → tree → render
|
|
12
|
+
* → export → re-parse. Before `ParseOptions.extraTags`, a registered tag
|
|
13
|
+
* could render and export but never parse — the parser turned it into
|
|
14
|
+
* literal text, so the rest of the pipeline had nothing to work with. The
|
|
15
|
+
* platform's plugin promise starts at the parser or it doesn't start.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function findKind(root: RedNode, kind: string): RedNode | null {
|
|
19
|
+
let found: RedNode | null = null
|
|
20
|
+
root.walk(n => {
|
|
21
|
+
if (!found && n.kind === kind) found = n
|
|
22
|
+
})
|
|
23
|
+
return found
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const blurTag: TagDefinition = {
|
|
27
|
+
name: 'blur',
|
|
28
|
+
kind: 'blur' as NodeKind,
|
|
29
|
+
label: 'Blur',
|
|
30
|
+
category: 'text',
|
|
31
|
+
isInline: true,
|
|
32
|
+
isSelfClosing: false,
|
|
33
|
+
canHaveChildren: true,
|
|
34
|
+
toRenderNode: (ctx) => ({
|
|
35
|
+
kind: 'blur',
|
|
36
|
+
text: '',
|
|
37
|
+
children: [{ kind: 'text', text: `⟦blur⟧${ctx.visitChildren(ctx.node)}⟦/blur⟧`, children: [], props: {} }],
|
|
38
|
+
props: {},
|
|
39
|
+
}),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const wiggleTag: TagDefinition = {
|
|
43
|
+
name: 'wiggle',
|
|
44
|
+
kind: 'wiggle' as NodeKind,
|
|
45
|
+
label: 'Wiggle',
|
|
46
|
+
isInline: true,
|
|
47
|
+
isSelfClosing: false,
|
|
48
|
+
canHaveChildren: true,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('plugin tags — full pipeline', () => {
|
|
52
|
+
it('parses a registered tag as a real container, with attrs and nesting', () => {
|
|
53
|
+
const model = new BBCodeDocumentModel({ source: '' })
|
|
54
|
+
model.tagRegistry.register(blurTag)
|
|
55
|
+
model.tagRegistry.register(wiggleTag)
|
|
56
|
+
|
|
57
|
+
model.rebuild('hola [blur]mundo [b]fuerte[/b][/blur] y [wiggle=3]algo[/wiggle]')
|
|
58
|
+
|
|
59
|
+
const blur = findKind(model.redRoot!, 'blur')!
|
|
60
|
+
expect(blur).toBeTruthy()
|
|
61
|
+
expect(findKind(blur, 'bold')).toBeTruthy() // nesting inside the plugin tag
|
|
62
|
+
const wiggle = findKind(model.redRoot!, 'wiggle')!
|
|
63
|
+
expect(wiggle.text).toBe('=3') // attrs preserved like any builtin
|
|
64
|
+
|
|
65
|
+
// The tree still partitions the source: the plugin tag owns its delimiters.
|
|
66
|
+
expect(blur.range.end - blur.range.start).toBe('[blur]mundo [b]fuerte[/b][/blur]'.length)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('unregistered tags still fall to literal text — prose stays visible', () => {
|
|
70
|
+
const model = new BBCodeDocumentModel({ source: 'combo de [90 misses] con [Gateron Yellow]' })
|
|
71
|
+
expect(findKind(model.redRoot!, 'custom')).toBeNull()
|
|
72
|
+
const html = new HTMLRenderer().render(model.redRoot!)
|
|
73
|
+
expect(html).toContain('[90 misses]')
|
|
74
|
+
expect(html).toContain('[Gateron Yellow]')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders through the registry handler, and safely without it', () => {
|
|
78
|
+
const model = new BBCodeDocumentModel({ source: '' })
|
|
79
|
+
model.tagRegistry.register(blurTag)
|
|
80
|
+
model.rebuild('[blur]contenido[/blur]')
|
|
81
|
+
|
|
82
|
+
const withRegistry = new HTMLRenderer({ registry: model.tagRegistry }).render(model.redRoot!)
|
|
83
|
+
expect(withRegistry).toContain('⟦blur⟧')
|
|
84
|
+
expect(withRegistry).toContain('contenido')
|
|
85
|
+
|
|
86
|
+
// Without the registry the wrapper is unknown, but content must survive.
|
|
87
|
+
const bare = new HTMLRenderer().render(model.redRoot!)
|
|
88
|
+
expect(bare).toContain('contenido')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('exports under its registered name and round-trips', () => {
|
|
92
|
+
const model = new BBCodeDocumentModel({ source: '' })
|
|
93
|
+
model.tagRegistry.register(blurTag)
|
|
94
|
+
model.tagRegistry.register(wiggleTag)
|
|
95
|
+
const source = 'hola [blur]mundo[/blur] y [wiggle=3]algo[/wiggle]'
|
|
96
|
+
model.rebuild(source)
|
|
97
|
+
|
|
98
|
+
const exporter = new BBCodeExporter(model.tagRegistry)
|
|
99
|
+
const exported = exporter.export(model.redRoot!)
|
|
100
|
+
expect(exported).toBe(source)
|
|
101
|
+
|
|
102
|
+
// Fixpoint: parsing the export reproduces the same export.
|
|
103
|
+
model.rebuild(exported)
|
|
104
|
+
expect(exporter.export(model.redRoot!)).toBe(exported)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('works through PluginAPI, and unregistering returns the tag to prose', () => {
|
|
108
|
+
const model = new BBCodeDocumentModel({ source: '' })
|
|
109
|
+
const api = new PluginAPI(model)
|
|
110
|
+
api.registerPlugin(
|
|
111
|
+
{ name: 'blur-plugin', version: '1.0.0' },
|
|
112
|
+
{ tags: [blurTag] },
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
model.rebuild('[blur]x[/blur]')
|
|
116
|
+
expect(findKind(model.redRoot!, 'blur')).toBeTruthy()
|
|
117
|
+
|
|
118
|
+
api.unregisterPlugin('blur-plugin')
|
|
119
|
+
model.rebuild('[blur]x[/blur]')
|
|
120
|
+
expect(findKind(model.redRoot!, 'blur')).toBeNull()
|
|
121
|
+
// Back to what unknown tags always were: visible text.
|
|
122
|
+
expect(new HTMLRenderer().render(model.redRoot!)).toContain('[blur]')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('incremental editing with plugin tags matches a full rebuild', () => {
|
|
126
|
+
let src = ''
|
|
127
|
+
for (let i = 0; i < 60; i++) src += `parrafo ${i} con [blur]nucleo ${i}[/blur] normal\n\n`
|
|
128
|
+
|
|
129
|
+
const reuse = new BBCodeDocumentModel({ source: '' })
|
|
130
|
+
const control = new BBCodeDocumentModel({ source: '', incremental: false })
|
|
131
|
+
reuse.tagRegistry.register(blurTag)
|
|
132
|
+
control.tagRegistry.register(blurTag)
|
|
133
|
+
reuse.rebuild(src)
|
|
134
|
+
control.rebuild(src)
|
|
135
|
+
|
|
136
|
+
const renderer = new HTMLRenderer()
|
|
137
|
+
for (let edit = 0; edit < 30; edit++) {
|
|
138
|
+
const at = (edit * 137) % (reuse.source.length - 10)
|
|
139
|
+
const next = reuse.source.slice(0, at) + 'x' + reuse.source.slice(at)
|
|
140
|
+
reuse.applyTextUpdate(next)
|
|
141
|
+
control.applyTextUpdate(next)
|
|
142
|
+
|
|
143
|
+
// Sin los ids: son identidades por modelo, y aquí hay dos modelos
|
|
144
|
+
// independientes. Lo que debe coincidir es la estructura.
|
|
145
|
+
const sinIds = (html: string) => html.replace(/ data-node-id="[^"]*"/g, '')
|
|
146
|
+
expect(sinIds(renderer.render(reuse.redRoot!))).toBe(sinIds(renderer.render(control.redRoot!)))
|
|
147
|
+
expect(reuse.redRoot!.green).toBe(reuse.greenRoot)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { TagRegistry } from '../Model/TagRegistry'
|
|
3
|
+
import { BBCodeExporter } from '../Visitors/BBCodeExporter'
|
|
4
|
+
import { processStudioAST } from '@miliastry/quasar-studio'
|
|
5
|
+
import { REFERENCE_DOCUMENT, REFERENCE_GRADIENT_LAYER } from './referenceDocument'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Round-tripping a document through the Studio must not invent or drop layout.
|
|
9
|
+
*
|
|
10
|
+
* The name of this suite always promised that, but the body only rendered the
|
|
11
|
+
* document and `console.log`ged the result — `expect` was imported and never
|
|
12
|
+
* called, so it could not fail. It also read an untracked `.milia` fixture,
|
|
13
|
+
* which is what finally broke it. Both are fixed here: the invariant its name
|
|
14
|
+
* describes is now actually asserted, against `referenceDocument.ts`.
|
|
15
|
+
*
|
|
16
|
+
* Braille blanks (`⠀`, U+2800) are load-bearing in these posts — authors use
|
|
17
|
+
* them as padding because osu! collapses ordinary whitespace. Gaining or losing
|
|
18
|
+
* one silently reflows somebody's profile.
|
|
19
|
+
*/
|
|
20
|
+
describe('Problematic Section Export', () => {
|
|
21
|
+
const exportWithLayers = (source: string, layers: unknown[]) => {
|
|
22
|
+
const registry = new TagRegistry()
|
|
23
|
+
const { redRoot } = processStudioAST(source, layers as never, {})
|
|
24
|
+
return new BBCodeExporter(registry).export(redRoot)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const brailleCount = (s: string) => (s.match(/⠀/g) || []).length
|
|
28
|
+
|
|
29
|
+
it('preserves every braille blank through a plain round trip', () => {
|
|
30
|
+
const exported = exportWithLayers(REFERENCE_DOCUMENT, [])
|
|
31
|
+
expect(brailleCount(exported)).toBe(brailleCount(REFERENCE_DOCUMENT))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('preserves every braille blank through a gradient layer', () => {
|
|
35
|
+
// A gradient splits text into one node per character; the padding must come
|
|
36
|
+
// out the far side with the same count it went in with.
|
|
37
|
+
const exported = exportWithLayers(REFERENCE_DOCUMENT, [REFERENCE_GRADIENT_LAYER])
|
|
38
|
+
expect(brailleCount(exported)).toBe(brailleCount(REFERENCE_DOCUMENT))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not add a trailing blank line', () => {
|
|
42
|
+
const exported = exportWithLayers(REFERENCE_DOCUMENT, [])
|
|
43
|
+
const trailing = (s: string) => s.length - s.replace(/\n+$/, '').length
|
|
44
|
+
expect(trailing(exported)).toBe(trailing(REFERENCE_DOCUMENT))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { TagRegistry } from '../Model/TagRegistry'
|
|
3
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
4
|
+
import { processStudioAST } from '@miliastry/quasar-studio'
|
|
5
|
+
import { REFERENCE_DOCUMENT, REFERENCE_GRADIENT_LAYER } from './referenceDocument'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A substantial document rendered with and without a Studio gradient layer.
|
|
9
|
+
*
|
|
10
|
+
* This used to assert nothing at all: it rendered both variants and wrote
|
|
11
|
+
* them to `orig.html` and `grad.html` in the repo root — tracked files, so
|
|
12
|
+
* every test run dirtied the working tree, and no failure was possible. What
|
|
13
|
+
* it should have been checking is below.
|
|
14
|
+
*/
|
|
15
|
+
describe('Problematic Section HTML Output', () => {
|
|
16
|
+
// Was an untracked `.milia` read from disk; see `referenceDocument.ts`.
|
|
17
|
+
const text = REFERENCE_DOCUMENT
|
|
18
|
+
const layers = [REFERENCE_GRADIENT_LAYER]
|
|
19
|
+
|
|
20
|
+
const render = (studioLayers: unknown[]) => {
|
|
21
|
+
const registry = new TagRegistry()
|
|
22
|
+
const renderer = new HTMLRenderer({ registry })
|
|
23
|
+
const { redRoot } = processStudioAST(text, studioLayers as never, {})
|
|
24
|
+
return renderer.render(redRoot)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('renders the document without a gradient layer', () => {
|
|
28
|
+
const html = render([])
|
|
29
|
+
|
|
30
|
+
expect(html.length).toBeGreaterThan(1000)
|
|
31
|
+
// Structure of the source survives to the output.
|
|
32
|
+
expect(html).toContain('data-node-id')
|
|
33
|
+
// No per-character colouring without a gradient layer.
|
|
34
|
+
expect(html).not.toMatch(/color:\s*#e8b04b/i)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('a gradient layer colours the text, and only the text', () => {
|
|
38
|
+
const plain = render([])
|
|
39
|
+
const gradient = render(layers)
|
|
40
|
+
|
|
41
|
+
expect(gradient).not.toBe(plain)
|
|
42
|
+
// The layer's own stops reach the output as inline colours.
|
|
43
|
+
expect(gradient).toMatch(/color:\s*#e8b04b/i)
|
|
44
|
+
// Colorear no puede cambiar el TEXTO, que es la invariante de verdad
|
|
45
|
+
// (contar `data-node-id` no servía: el degradado parte el texto en un
|
|
46
|
+
// nodo por carácter, así que el número cambia legítimamente).
|
|
47
|
+
const texto = (s: string) => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim()
|
|
48
|
+
expect(texto(gradient)).toBe(texto(plain))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rendering is deterministic', () => {
|
|
52
|
+
// Node ids come from a monotonic per-process counter, so two parses of
|
|
53
|
+
// the same text legitimately number their nodes differently. Identity is
|
|
54
|
+
// not content: strip it and the output must be identical.
|
|
55
|
+
const withoutIds = (html: string) => html.replace(/ data-node-id="[^"]*"/g, '')
|
|
56
|
+
expect(withoutIds(render(layers))).toBe(withoutIds(render(layers)))
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
3
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
4
|
+
import type { RedNode } from '../Syntax/RedNode'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Red-subtree reuse: on the incremental path, subtrees whose green is shared
|
|
8
|
+
* by reference must be ADOPTED (same RedNode objects) instead of rebuilt —
|
|
9
|
+
* building red was the largest phase of a keystroke.
|
|
10
|
+
*
|
|
11
|
+
* The property demanded is the engine's usual one: the resulting tree must be
|
|
12
|
+
* IDENTICAL — kind, text, range, structure, metadata — to what a full rebuild
|
|
13
|
+
* of the same final text produces. Ids are exempt (reuse is what makes them
|
|
14
|
+
* stable; a fresh rebuild has no history), and object identity is the point.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function bigDoc(): string {
|
|
18
|
+
// Comfortably above MIN_SOURCE_LENGTH (2500) so the incremental path engages.
|
|
19
|
+
let src = ''
|
|
20
|
+
for (let i = 0; i < 40; i++) {
|
|
21
|
+
src += `parrafo numero ${i} con [b]negrita[/b] y [color=#ff66ab]color[/color]\n\n`
|
|
22
|
+
}
|
|
23
|
+
src += '[notice]interior del notice con [i]cursiva[/i] y texto largo para editar[/notice]\n\n'
|
|
24
|
+
for (let i = 0; i < 20; i++) {
|
|
25
|
+
src += `cola numero ${i} despues del notice\n\n`
|
|
26
|
+
}
|
|
27
|
+
return src
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Walk both trees asserting deep equality of everything but ids. */
|
|
31
|
+
function expectSameTree(a: RedNode, b: RedNode, path = 'root'): void {
|
|
32
|
+
expect(a.kind, `${path}: kind`).toBe(b.kind)
|
|
33
|
+
expect(a.text, `${path}: text`).toBe(b.text)
|
|
34
|
+
expect(a.range.start, `${path}: range.start`).toBe(b.range.start)
|
|
35
|
+
expect(a.range.end, `${path}: range.end`).toBe(b.range.end)
|
|
36
|
+
expect(JSON.stringify(a.metadata), `${path}: metadata`).toBe(JSON.stringify(b.metadata))
|
|
37
|
+
expect(a.children.length, `${path}: childCount`).toBe(b.children.length)
|
|
38
|
+
for (let i = 0; i < a.children.length; i++) {
|
|
39
|
+
expectSameTree(a.children[i], b.children[i], `${path}.${a.children[i].kind}[${i}]`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mulberry32(seed: number): () => number {
|
|
44
|
+
let a = seed >>> 0
|
|
45
|
+
return () => {
|
|
46
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0
|
|
47
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
48
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
49
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('red-subtree reuse', () => {
|
|
54
|
+
it('adopts untouched subtrees as the same objects, with correct shifted ranges', () => {
|
|
55
|
+
const model = new BBCodeDocumentModel({ source: bigDoc() })
|
|
56
|
+
const root = model.redRoot!
|
|
57
|
+
const noticeIdx = root.children.findIndex(c => c.kind === 'notice')
|
|
58
|
+
expect(noticeIdx).toBeGreaterThan(0)
|
|
59
|
+
|
|
60
|
+
const beforeBlock = root.children[noticeIdx - 2] // a paragraph before the notice
|
|
61
|
+
const afterBlock = root.children[root.children.length - 2]
|
|
62
|
+
const afterStart = afterBlock.range.start
|
|
63
|
+
|
|
64
|
+
// Type one char inside the notice.
|
|
65
|
+
const src = model.source
|
|
66
|
+
const at = src.indexOf('texto largo') + 5
|
|
67
|
+
model.applyTextUpdate(src.slice(0, at) + 'X' + src.slice(at))
|
|
68
|
+
|
|
69
|
+
const newRoot = model.redRoot!
|
|
70
|
+
expect(newRoot).not.toBe(root)
|
|
71
|
+
|
|
72
|
+
// Prefix subtree: same object, same position.
|
|
73
|
+
expect(newRoot.children[noticeIdx - 2]).toBe(beforeBlock)
|
|
74
|
+
// Suffix subtree: same object, range shifted by the inserted char.
|
|
75
|
+
const afterNow = newRoot.children[newRoot.children.length - 2]
|
|
76
|
+
expect(afterNow).toBe(afterBlock)
|
|
77
|
+
expect(afterNow.range.start).toBe(afterStart + 1)
|
|
78
|
+
// Adopted nodes are correctly parented into the new tree.
|
|
79
|
+
expect(afterNow.parent).toBe(newRoot)
|
|
80
|
+
expect(afterNow.index).toBe(newRoot.children.length - 2)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('reuseRed: false keeps the old behavior (no shared objects)', () => {
|
|
84
|
+
const model = new BBCodeDocumentModel({ source: bigDoc(), reuseRed: false })
|
|
85
|
+
const root = model.redRoot!
|
|
86
|
+
const firstBlock = root.children[0]
|
|
87
|
+
|
|
88
|
+
const src = model.source
|
|
89
|
+
const at = src.indexOf('texto largo') + 5
|
|
90
|
+
model.applyTextUpdate(src.slice(0, at) + 'X' + src.slice(at))
|
|
91
|
+
|
|
92
|
+
expect(model.redRoot!.children[0]).not.toBe(firstBlock)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('differential: reuse tree is identical to a full rebuild, over seeded edits', () => {
|
|
96
|
+
const renderer = new HTMLRenderer()
|
|
97
|
+
const rand = mulberry32(777)
|
|
98
|
+
const insertables = ['a', 'X', ' ', '\n', '[', ']', '[b]', '[/b]', '[i]', '[*]', '[notice]', '[/notice]']
|
|
99
|
+
|
|
100
|
+
const reuse = new BBCodeDocumentModel({ source: bigDoc() })
|
|
101
|
+
const control = new BBCodeDocumentModel({ source: bigDoc(), incremental: false })
|
|
102
|
+
|
|
103
|
+
for (let edit = 0; edit < 120; edit++) {
|
|
104
|
+
const src = reuse.source
|
|
105
|
+
const pos = Math.floor(rand() * (src.length + 1))
|
|
106
|
+
const next = rand() < 0.7
|
|
107
|
+
? src.slice(0, pos) + insertables[Math.floor(rand() * insertables.length)] + src.slice(pos)
|
|
108
|
+
: src.slice(0, pos) + src.slice(Math.min(pos + 1 + Math.floor(rand() * 3), src.length))
|
|
109
|
+
|
|
110
|
+
reuse.applyTextUpdate(next)
|
|
111
|
+
control.applyTextUpdate(next)
|
|
112
|
+
|
|
113
|
+
expectSameTree(reuse.redRoot!, control.redRoot!)
|
|
114
|
+
expect(renderer.render(reuse.redRoot!).replace(/ data-node-id="[^"]*"/g, ''))
|
|
115
|
+
.toBe(renderer.render(control.redRoot!).replace(/ data-node-id="[^"]*"/g, ''))
|
|
116
|
+
expect(reuse.redRoot!.green).toBe(reuse.greenRoot)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('ids stay unique after adoption plus fresh nodes', () => {
|
|
121
|
+
const model = new BBCodeDocumentModel({ source: bigDoc() })
|
|
122
|
+
const rand = mulberry32(4242)
|
|
123
|
+
|
|
124
|
+
for (let edit = 0; edit < 40; edit++) {
|
|
125
|
+
const src = model.source
|
|
126
|
+
const pos = Math.floor(rand() * (src.length + 1))
|
|
127
|
+
model.applyTextUpdate(src.slice(0, pos) + 'y' + src.slice(pos))
|
|
128
|
+
|
|
129
|
+
const ids: string[] = []
|
|
130
|
+
model.redRoot!.walk(n => { ids.push(String(n.id)) })
|
|
131
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug — dump the windowed reconcile decisions for the minimal delete-20k
|
|
3
|
+
* divergence: change range, old/new window keys, orphan decisions, walk.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it } from 'vitest'
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
10
|
+
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
11
|
+
import { patchBlocksInto } from '../Visitors/BlockPatcher'
|
|
12
|
+
|
|
13
|
+
const renderer = new HTMLRenderer()
|
|
14
|
+
|
|
15
|
+
function loadFixture(): string {
|
|
16
|
+
const candidates = [
|
|
17
|
+
join(process.cwd(), 'packages', 'quasar', '500KCharsTest'),
|
|
18
|
+
join(process.cwd(), '500KCharsTest'),
|
|
19
|
+
join(__dirname, '..', '..', '500KCharsTest'),
|
|
20
|
+
]
|
|
21
|
+
const p = candidates.find(c => existsSync(c))
|
|
22
|
+
if (!p) throw new Error(`Fixture not found in: ${candidates.join(', ')}`)
|
|
23
|
+
return readFileSync(p, 'utf-8')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const insertAt = (s: string, pos: number, text: string): string => s.slice(0, pos) + text + s.slice(pos)
|
|
27
|
+
function afterBlank(s: string, pos: number): number {
|
|
28
|
+
const i = s.indexOf('\n\n', pos)
|
|
29
|
+
return i === -1 ? s.length : i + 2
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('windowed debug: minimal delete-20k', () => {
|
|
33
|
+
it('dumps orphan + walk decisions', () => {
|
|
34
|
+
;(globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ = true
|
|
35
|
+
let src = loadFixture()
|
|
36
|
+
let model = new BBCodeDocumentModel({ source: src })
|
|
37
|
+
const el = document.createElement('div')
|
|
38
|
+
patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
|
|
39
|
+
|
|
40
|
+
// Step 1: insert box at 50%
|
|
41
|
+
src = insertAt(src, afterBlank(src, Math.floor(src.length * 0.5)), '[box]caja nueva[/box]\n\n')
|
|
42
|
+
model.applyTextUpdate(src)
|
|
43
|
+
model.ensureAnalyzed()
|
|
44
|
+
patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
|
|
45
|
+
|
|
46
|
+
// Step 2: insert quote at 60%
|
|
47
|
+
src = insertAt(src, afterBlank(src, Math.floor(src.length * 0.6)), '[quote]cita nueva[/quote]\n\n')
|
|
48
|
+
model.applyTextUpdate(src)
|
|
49
|
+
model.ensureAnalyzed()
|
|
50
|
+
patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
|
|
51
|
+
|
|
52
|
+
// Step 3: delete 20k at 50% — the diverging edit
|
|
53
|
+
console.log('[dbg] --- STEP 3: delete 20k at 50% ---')
|
|
54
|
+
const pos = Math.floor(src.length * 0.5)
|
|
55
|
+
const next = src.slice(0, pos) + src.slice(pos + 20_000)
|
|
56
|
+
model.applyTextUpdate(next)
|
|
57
|
+
model.ensureAnalyzed()
|
|
58
|
+
console.log('[dbg] change source pos', pos)
|
|
59
|
+
patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
|
|
60
|
+
;(globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ = false
|
|
61
|
+
}, 300_000)
|
|
62
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { REFERENCE_DOCUMENT_WITH_GRADIENT } from './referenceDocument'
|
|
3
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: the built-in validators must actually fire.
|
|
7
|
+
*
|
|
8
|
+
* Three of the four were unreachable, so `analyze()` returned zero diagnostics
|
|
9
|
+
* on *any* document and `ErrorCheckerWindow` — a whole window whose job is
|
|
10
|
+
* listing problems — was permanently empty. None of them set `range` either, so
|
|
11
|
+
* even a firing diagnostic could not be clicked through to its position.
|
|
12
|
+
*
|
|
13
|
+
* unknown-tag required kind 'custom', but the parser turns unknown tags
|
|
14
|
+
* into `text` nodes before the analyzer sees them
|
|
15
|
+
* deprecated-tag looked up `deprecated[node.text]`, but `text` holds the
|
|
16
|
+
* tag's attributes, not its name
|
|
17
|
+
* nested-structure required non-text children of [code], but the parser
|
|
18
|
+
* keeps code content as raw text, so there never are any
|
|
19
|
+
*
|
|
20
|
+
* `unknown-tag` is still unreachable by design — see the note at the bottom.
|
|
21
|
+
*/
|
|
22
|
+
function diagnose(source: string) {
|
|
23
|
+
const model = new BBCodeDocumentModel({ source })
|
|
24
|
+
const result = model.analyze()
|
|
25
|
+
return result.diagnostics.items
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function codes(source: string): string[] {
|
|
29
|
+
return diagnose(source).map(d => d.code)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('SemanticAnalyzer — built-in validators', () => {
|
|
33
|
+
it('flags a deprecated tag exactly once, with its range', () => {
|
|
34
|
+
const source = '[strike]x[/strike]'
|
|
35
|
+
const found = diagnose(source)
|
|
36
|
+
|
|
37
|
+
expect(found.map(d => d.code)).toEqual(['deprecated-tag'])
|
|
38
|
+
// Several nodes share offset 0 here (document, paragraph, the tag itself),
|
|
39
|
+
// so an offset-only check reported this three times.
|
|
40
|
+
expect(found[0].range).toEqual({ start: 0, end: source.length })
|
|
41
|
+
expect(found[0].message).toContain('[s]')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not flag the modern spelling', () => {
|
|
45
|
+
expect(codes('[s]x[/s]')).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('flags BBCode tags inside a code block', () => {
|
|
49
|
+
expect(codes('[code][b]x[/b][/code]')).toEqual(['nested-tags-in-code', 'nested-tags-in-code'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not flag a code block without tags', () => {
|
|
53
|
+
expect(codes('[code]const a = 1[/code]')).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('flags an empty tag', () => {
|
|
57
|
+
const found = diagnose('[b][/b]')
|
|
58
|
+
expect(found.map(d => d.code)).toEqual(['empty-tag'])
|
|
59
|
+
expect(found[0].range).not.toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('every diagnostic carries a usable range', () => {
|
|
63
|
+
// ErrorCheckerWindow guards on `if (err.range)` to jump to the problem, so
|
|
64
|
+
// a diagnostic without one is invisible in practice.
|
|
65
|
+
for (const source of ['[strike]x[/strike]', '[code][b]x[/b][/code]', '[b][/b]']) {
|
|
66
|
+
for (const d of diagnose(source)) {
|
|
67
|
+
expect(d.range).not.toBeNull()
|
|
68
|
+
expect(d.range!.start).toBeGreaterThanOrEqual(0)
|
|
69
|
+
expect(d.range!.end).toBeGreaterThan(d.range!.start)
|
|
70
|
+
expect(d.range!.end).toBeLessThanOrEqual(source.length)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('unclosed tags', () => {
|
|
76
|
+
// The one BBCode mistake people actually make. The legacy parser closes
|
|
77
|
+
// these silently by design, so without this nothing tells the author.
|
|
78
|
+
it('flags a tag never closed', () => {
|
|
79
|
+
expect(codes('[b]hola')).toEqual(['unclosed-tag'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('flags each unclosed tag separately', () => {
|
|
83
|
+
expect(codes('[b][i]hola')).toEqual(['unclosed-tag', 'unclosed-tag'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('flags a tag auto-closed by a mismatched close', () => {
|
|
87
|
+
// [i] is never closed; the legacy rules close it when [/b] arrives.
|
|
88
|
+
expect(codes('[b][i]x[/b]')).toEqual(['unclosed-tag'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('says nothing about properly closed tags', () => {
|
|
92
|
+
expect(codes('[b]hola[/b]')).toEqual([])
|
|
93
|
+
expect(codes('[b][i]x[/i][/b]')).toEqual([])
|
|
94
|
+
expect(codes('[color=red]x[/color]')).toEqual([])
|
|
95
|
+
expect(codes('[url=https://osu.ppy.sh]x[/url]')).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is case-insensitive about the closing tag', () => {
|
|
99
|
+
expect(codes('[B]x[/B]')).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does not flag [*], which has no closing form', () => {
|
|
103
|
+
expect(codes('[list]\n[*]uno\n[*]dos\n[/list]')).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not flag brackets in ordinary prose', () => {
|
|
107
|
+
expect(codes('Combo de [90 misses] con teclado [Gateron]')).toEqual([])
|
|
108
|
+
expect(codes('hola[/b]')).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('stays quiet on well-formed documents', () => {
|
|
113
|
+
// False positives are worse than silence: a checker that cries wolf on
|
|
114
|
+
// ordinary text gets ignored.
|
|
115
|
+
expect(codes('[b]hola[/b] [i]mundo[/i]')).toEqual([])
|
|
116
|
+
expect(codes('texto sin ninguna etiqueta')).toEqual([])
|
|
117
|
+
expect(codes('[url=https://osu.ppy.sh]enlace[/url]')).toEqual([])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('produces no noise on the real reference document', () => {
|
|
121
|
+
const found = diagnose(REFERENCE_DOCUMENT_WITH_GRADIENT)
|
|
122
|
+
// This is a real, valid document: anything reported here is a false
|
|
123
|
+
// positive that would bury genuine problems in the checker window.
|
|
124
|
+
expect(found.filter(d => d.severity === 'error')).toEqual([])
|
|
125
|
+
expect(found.filter(d => d.severity === 'warning')).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// NOTE: `unknown-tag` is deliberately still dormant. The parser renders
|
|
129
|
+
// unrecognised tags as literal text on purpose — `[Gateron]`, `[90 misses]`
|
|
130
|
+
// are ordinary prose, not typos — so warning on them would be pure noise.
|
|
131
|
+
// Reviving it is a product decision, tracked as roadmap point 15.
|
|
132
|
+
it('does not warn about unknown tags (parser renders them as text)', () => {
|
|
133
|
+
expect(codes('[unknowntag]hola[/unknowntag]')).toEqual([])
|
|
134
|
+
expect(codes('Combo de [90 misses] con teclado [Gateron]')).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
})
|