| … | … | @@ -0,0 +1,78 @@ |
| 1 | // The conformance harness for markdown-strip.md. |
| 2 | // |
| 3 | // The 36 vectors ARE the specification's oracle: "an implementation is conformant when it |
| 4 | // reproduces EVERY vector byte-for-byte" (strip §8). So this file asserts on bytes, never on |
| 5 | // shapes — a test that accepted "close enough" would certify exactly the class of defect §8 |
| 6 | // exists to catch (an implementation using character offsets instead of UTF-8 byte offsets, |
| 7 | // which is invisible until a non-ASCII author posts). |
| 8 | // |
| 9 | // Facets in the vector file are the compact form { byteStart, byteEnd, type, value }; strip() |
| 10 | // returns app.bsky.richtext.facet shape. expandFacet() below is the ONLY translation, and it is |
| 11 | // deliberately in the test rather than in lib/ so that lib/ owns one representation. |
| 12 | |
| 13 | import test from 'brittle' |
| 14 | import { readFileSync } from 'node:fs' |
| 15 | import { strip } from '../lib/strip.js' |
| 16 | |
| 17 | const VECTORS = JSON.parse( |
| 18 | readFileSync(new URL('../spec/vectors/markdown-strip.json', import.meta.url), 'utf8') |
| 19 | ).vectors |
| 20 | |
| 21 | // Compact vector facet -> the app.bsky.richtext.facet shape strip() emits. |
| 22 | // strip §6.5: features arrays contain EXACTLY ONE feature; we never emit a multi-feature facet. |
| 23 | function expandFacet (f) { |
| 24 | const feature = |
| 25 | f.type === 'link' ? { $type: 'app.bsky.richtext.facet#link', uri: f.value } |
| 26 | : f.type === 'mention' ? { $type: 'app.bsky.richtext.facet#mention', did: f.value } |
| 27 | : f.type === 'tag' ? { $type: 'app.bsky.richtext.facet#tag', tag: f.value } |
| 28 | : null |
| 29 | if (!feature) throw new Error('vector uses an unknown facet type: ' + f.type) |
| 30 | return { index: { byteStart: f.byteStart, byteEnd: f.byteEnd }, features: [feature] } |
| 31 | } |
| 32 | |
| 33 | const utf8 = (s) => Buffer.from(s, 'utf8') |
| 34 | |
| 35 | test('strip: all 36 vectors reproduce byte-for-byte', (t) => { |
| 36 | t.is(VECTORS.length, 36, 'the vector set is the one the spec pins') |
| 37 | |
| 38 | for (const v of VECTORS) { |
| 39 | const got = strip(v.source) |
| 40 | |
| 41 | // Bytes, not strings: two different strings can compare equal after normalisation, and |
| 42 | // strip §5.5 forbids Unicode normalisation precisely because it would change the CID. |
| 43 | t.alike(utf8(got.text), utf8(v.text), v.name + ': text — ' + v.why) |
| 44 | t.alike(got.facets, v.facets.map(expandFacet), v.name + ': facets — ' + v.why) |
| 45 | } |
| 46 | }) |
| 47 | |
| 48 | test('strip: is total — never throws (strip §2)', (t) => { |
| 49 | const hostile = [ |
| 50 | '', ' ', ' ', '[', '[](', '![', '```', ':::', '~~~', '|', |
| 51 | '[[', '[^', '<https://', '#'.repeat(100), '\r\n\r\n', |
| 52 | String.fromCharCode(0xd800) // a lone surrogate: not valid UTF-8, must not throw |
| 53 | ] |
| 54 | for (const s of hostile) { |
| 55 | try { |
| 56 | const r = strip(s) |
| 57 | t.ok(typeof r.text === 'string' && Array.isArray(r.facets), 'total on ' + JSON.stringify(s)) |
| 58 | } catch (err) { |
| 59 | t.fail('threw on ' + JSON.stringify(s) + ': ' + err.message) |
| 60 | } |
| 61 | } |
| 62 | }) |
| 63 | |
| 64 | test('strip: facet invariants hold on every vector (strip §6)', (t) => { |
| 65 | for (const v of VECTORS) { |
| 66 | const { text, facets } = strip(v.source) |
| 67 | const bytes = utf8(text).length |
| 68 | let prev = -1 |
| 69 | for (const f of facets) { |
| 70 | const { byteStart, byteEnd } = f.index |
| 71 | t.ok(byteStart >= 0 && byteEnd <= bytes, v.name + ': facet within text bounds') |
| 72 | t.ok(byteEnd > byteStart, v.name + ': facet is non-empty (§6.3 drops empty ranges)') |
| 73 | t.ok(byteStart >= prev, v.name + ': facets sorted by byteStart (§6.1)') |
| 74 | t.is(f.features.length, 1, v.name + ': exactly one feature per facet (§6.5)') |
| 75 | prev = byteEnd |
| 76 | } |
| 77 | } |
| 78 | }) |