@lovett/ui 0.0.11 → 0.2.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/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommentBody under hostile and malformed markdown — ADR-148 AC#5, AC#6, AC#8.
|
|
3
|
+
*
|
|
4
|
+
* Three assertions per corpus entry (AC#6):
|
|
5
|
+
* 1. rendering does not throw (and React logs no error);
|
|
6
|
+
* 2. nothing user-controlled became executable — no script/iframe/object/
|
|
7
|
+
* embed element, no `on*` handler attribute, no `javascript:` / `data:` /
|
|
8
|
+
* `vbscript:` href;
|
|
9
|
+
* 3. no text was silently dropped — every VISIBLE piece of the input is in
|
|
10
|
+
* `textContent`. "Visible" is what a reader of the source would expect
|
|
11
|
+
* to see: link and mention TEXT, never a destination (a URL is an
|
|
12
|
+
* attribute in every renderer); a horizontal rule has no words.
|
|
13
|
+
*
|
|
14
|
+
* Cross-comment isolation (AC#5): two bodies rendered as `comment.tsx` does —
|
|
15
|
+
* each its own `<CommentBody>` — and the second one's DOM must be
|
|
16
|
+
* byte-identical whether the first is benign or a construct CommonMark leaves
|
|
17
|
+
* open (an unclosed fence swallows the rest of ITS comment, by spec).
|
|
18
|
+
*
|
|
19
|
+
* URL allowlist (AC#8): exactly the three mention schemes pass through; every
|
|
20
|
+
* other scheme goes to `defaultUrlTransform`, and the dangerous ones render
|
|
21
|
+
* with a null or empty href (`defaultUrlTransform` returns '' and React emits
|
|
22
|
+
* the attribute empty — inert either way).
|
|
23
|
+
*
|
|
24
|
+
* The underline marker is spelled as an escape (`PP`) so the file never holds
|
|
25
|
+
* the syntax literally and stays plain UTF-8 for the CLAUDE.md section 8 greps.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
29
|
+
import { fireEvent, render } from '@testing-library/react'
|
|
30
|
+
import { CommentBody, type MentionTarget } from '../comment-body'
|
|
31
|
+
|
|
32
|
+
/** `++` — two U+002B PLUS SIGN. */
|
|
33
|
+
const PP = '\u{2B}\u{2B}'
|
|
34
|
+
|
|
35
|
+
const EXECUTABLE =
|
|
36
|
+
'script, iframe, object, embed, [onerror], [onload], [onclick], [onmouseover], [onfocus], [srcdoc]'
|
|
37
|
+
const DANGEROUS_HREF = /^\s*(?:javascript|data|vbscript)\s*:/i
|
|
38
|
+
|
|
39
|
+
interface Case {
|
|
40
|
+
readonly name: string
|
|
41
|
+
readonly body: string
|
|
42
|
+
/** Every entry must appear in `textContent`. Empty when the input has no words. */
|
|
43
|
+
readonly visible: readonly string[]
|
|
44
|
+
/** A selector that must match, for cases whose STRUCTURE is the point. */
|
|
45
|
+
readonly selector?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const LONG_LINE = 'lorem ipsum '.repeat(170).slice(0, 2000)
|
|
49
|
+
const ADVERSARIAL_LINE = `a${PP} `.repeat(500)
|
|
50
|
+
|
|
51
|
+
const CORPUS: readonly Case[] = [
|
|
52
|
+
{ name: 'hello [', body: 'hello [', visible: ['hello ['] },
|
|
53
|
+
{ name: 'hello ]', body: 'hello ]', visible: ['hello ]'] },
|
|
54
|
+
{ name: 'hello [world', body: 'hello [world', visible: ['hello [world'] },
|
|
55
|
+
{ name: 'hello ](url)', body: 'hello ](url)', visible: ['hello ](url)'] },
|
|
56
|
+
{ name: 'hello `world', body: 'hello `world', visible: ['hello `world'] },
|
|
57
|
+
{ name: 'hello **world', body: 'hello **world', visible: ['hello **world'] },
|
|
58
|
+
{ name: 'hello _world', body: 'hello _world', visible: ['hello _world'] },
|
|
59
|
+
{ name: 'a double quote', body: '"', visible: ['"'] },
|
|
60
|
+
{ name: 'a single quote', body: "'", visible: ["'"] },
|
|
61
|
+
{ name: 'one backslash', body: '\\', visible: ['\\'] },
|
|
62
|
+
// `\\` is a CommonMark escape for one backslash; `\\\\` for two. Rendering
|
|
63
|
+
// the escaped form is the spec, not loss.
|
|
64
|
+
{ name: 'two backslashes', body: '\\\\', visible: ['\\'] },
|
|
65
|
+
{ name: 'four backslashes', body: '\\\\\\\\', visible: ['\\\\'] },
|
|
66
|
+
{ name: '<div>', body: '<div>', visible: ['<div>'] },
|
|
67
|
+
{ name: '<div', body: '<div', visible: ['<div'] },
|
|
68
|
+
{ name: '</div>', body: '</div>', visible: ['</div>'] },
|
|
69
|
+
{ name: 'inline <div> in prose', body: 'hello <div>world</div>', visible: ['hello <div>world</div>'] },
|
|
70
|
+
{ name: '<script>', body: '<script>alert(1)</script>', visible: ['<script>alert(1)</script>'] },
|
|
71
|
+
{ name: 'inline <script> in prose', body: 'hi <script>alert(1)</script> there', visible: ['hi <script>alert(1)</script> there'] },
|
|
72
|
+
{ name: '<img onerror>', body: 'x <img src=x onerror=alert(1)> y', visible: ['x <img src=x onerror=alert(1)> y'] },
|
|
73
|
+
{ name: 'raw <a href="javascript:">', body: '<a href="javascript:alert(1)">click</a>', visible: ['<a href="javascript:alert(1)">click</a>'] },
|
|
74
|
+
{ name: '[click](javascript:alert(1))', body: '[click](javascript:alert(1))', visible: ['click'], selector: 'a' },
|
|
75
|
+
{ name: '[click](data:text/html,x)', body: '[click](data:text/html,x)', visible: ['click'], selector: 'a' },
|
|
76
|
+
{ name: '[click](vbscript:msgbox)', body: '[click](vbscript:msgbox)', visible: ['click'], selector: 'a' },
|
|
77
|
+
{ name: '@[foo](https://example.com)', body: '@[foo](https://example.com)', visible: ['@', 'foo'], selector: 'a[href="https://example.com"]' },
|
|
78
|
+
{ name: '@[foo](user:not-valid)', body: '@[foo](user:not-valid)', visible: ['@', 'foo'] },
|
|
79
|
+
{ name: 'an unclosed fence with two lines after it', body: '```\nline one\nline two', visible: ['line one', 'line two'], selector: 'pre' },
|
|
80
|
+
{ name: 'a bare fence renders a code block', body: '```\nconst a = 1\n```', visible: ['const a = 1'], selector: 'pre' },
|
|
81
|
+
{ name: 'an unclosed fence with a language', body: '```ts\nconst a = 1\nconst b = 2', visible: ['const a = 1', 'const b = 2'] },
|
|
82
|
+
{ name: `${PP}unclosed`, body: `${PP}unclosed`, visible: [`${PP}unclosed`] },
|
|
83
|
+
{ name: `${PP}text${PP} renders an underline`, body: `${PP}text${PP}`, visible: ['text'], selector: 'u' },
|
|
84
|
+
{ name: 'nested **bold and _italic_**', body: 'nested **bold and _italic_**', visible: ['nested', 'bold and', 'italic'], selector: 'strong em' },
|
|
85
|
+
{ name: '[`code`](https://example.com)', body: '[`code`](https://example.com)', visible: ['code'], selector: 'a code' },
|
|
86
|
+
{ name: 'literal * asterisks *', body: 'literal * asterisks *', visible: ['literal * asterisks *'] },
|
|
87
|
+
{ name: 'a 2,000-character single line', body: LONG_LINE, visible: [LONG_LINE.trimEnd()] },
|
|
88
|
+
{ name: 'a 2,000-character line of unpaired markers', body: ADVERSARIAL_LINE, visible: [ADVERSARIAL_LINE.trimEnd()] },
|
|
89
|
+
{ name: 'a body that is only ---', body: '---', visible: [], selector: 'hr' },
|
|
90
|
+
{ name: 'an empty body', body: '', visible: [] },
|
|
91
|
+
{ name: 'only whitespace', body: ' \n\t\n', visible: [] },
|
|
92
|
+
{ name: 'a GFM table (out of the editor subset, still renders)', body: '| a | b |\n|---|---|\n| 1 | 2 |', visible: ['a', 'b', '1', '2'], selector: 'table' },
|
|
93
|
+
{ name: 'a task list', body: '- [ ] open\n- [x] done', visible: ['open', 'done'] },
|
|
94
|
+
{ name: 'an autolink literal', body: 'see https://example.com/path now', visible: ['see', 'https://example.com/path', 'now'], selector: 'a[href="https://example.com/path"]' },
|
|
95
|
+
{ name: 'an angle-bracket javascript destination', body: '[x](<javascript:alert(1)>)', visible: ['x'] },
|
|
96
|
+
{ name: 'an entity-encoded javascript scheme', body: '[x](javascript:alert(1))', visible: ['x'] },
|
|
97
|
+
{ name: 'an upper-case javascript scheme', body: '[x](JAVASCRIPT:alert(1))', visible: ['x'] },
|
|
98
|
+
{ name: 'an image with a javascript source', body: ')', visible: ['alt'] },
|
|
99
|
+
{ name: 'an image with a safe source', body: '', visible: ['photo'] },
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
function renderBody(body: string, onSelectMention?: (target: MentionTarget) => void) {
|
|
103
|
+
return render(
|
|
104
|
+
<CommentBody
|
|
105
|
+
bodyMd={body}
|
|
106
|
+
{...(onSelectMention === undefined ? {} : { onSelectMention })}
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('CommentBody — malformed-markdown corpus (AC#6)', () => {
|
|
112
|
+
expect(LONG_LINE).toHaveLength(2000)
|
|
113
|
+
expect(ADVERSARIAL_LINE).toHaveLength(2000)
|
|
114
|
+
|
|
115
|
+
for (const { name, body, visible, selector } of CORPUS) {
|
|
116
|
+
it(name, () => {
|
|
117
|
+
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
118
|
+
let container: HTMLElement | undefined
|
|
119
|
+
|
|
120
|
+
// 1. No throw — and no React error either (a boundary would log, not throw).
|
|
121
|
+
expect(() => {
|
|
122
|
+
container = renderBody(body).container
|
|
123
|
+
}).not.toThrow()
|
|
124
|
+
expect(errors).not.toHaveBeenCalled()
|
|
125
|
+
errors.mockRestore()
|
|
126
|
+
if (container === undefined) throw new Error('unreachable: render returned nothing')
|
|
127
|
+
|
|
128
|
+
// 2. Nothing executable reached the DOM.
|
|
129
|
+
expect(container.querySelector(EXECUTABLE)).toBeNull()
|
|
130
|
+
for (const anchor of Array.from(container.querySelectorAll('a'))) {
|
|
131
|
+
expect(anchor.getAttribute('href') ?? '').not.toMatch(DANGEROUS_HREF)
|
|
132
|
+
}
|
|
133
|
+
for (const image of Array.from(container.querySelectorAll('img'))) {
|
|
134
|
+
expect(image.getAttribute('src') ?? '').not.toMatch(DANGEROUS_HREF)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 3. Nothing visible was dropped.
|
|
138
|
+
const text = container.textContent ?? ''
|
|
139
|
+
for (const piece of visible) expect(text).toContain(piece)
|
|
140
|
+
|
|
141
|
+
if (selector !== undefined) expect(container.querySelector(selector)).not.toBeNull()
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Images are outside the composer's subset (ADR-148 D3; ADR-151 owns
|
|
146
|
+
// attachments) but typeable. They render as alt text and nothing else — no
|
|
147
|
+
// `<img>` at all, so no request to a user-supplied URL and no empty-`src`
|
|
148
|
+
// element (React used to warn about that one) when a dangerous URL is
|
|
149
|
+
// stripped.
|
|
150
|
+
it('an image renders its alt text and no <img>, whatever the source', () => {
|
|
151
|
+
for (const [body, alt] of [
|
|
152
|
+
[')', 'alt'],
|
|
153
|
+
['', 'photo'],
|
|
154
|
+
['', 'diagram'],
|
|
155
|
+
] as const) {
|
|
156
|
+
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
157
|
+
const { container, unmount } = renderBody(body)
|
|
158
|
+
expect(errors, body).not.toHaveBeenCalled()
|
|
159
|
+
errors.mockRestore()
|
|
160
|
+
expect(container.querySelector('img'), body).toBeNull()
|
|
161
|
+
expect(container.textContent, body).toBe(alt)
|
|
162
|
+
unmount()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('an image with no alt text renders nothing, and no <img>', () => {
|
|
167
|
+
const { container } = renderBody('')
|
|
168
|
+
expect(container.querySelector('img')).toBeNull()
|
|
169
|
+
expect(container.textContent).toBe('')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// AC#5 — cross-comment isolation
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** A second comment exercising every construct the composer can emit. */
|
|
178
|
+
const SECOND_BODY = [
|
|
179
|
+
`second **bold** *italic* ${PP}under${PP} ~~gone~~ [link](https://example.com) \`code\` @[Ada](user:usr_1)`,
|
|
180
|
+
'',
|
|
181
|
+
'# heading',
|
|
182
|
+
'',
|
|
183
|
+
'- item one',
|
|
184
|
+
'- item two',
|
|
185
|
+
'',
|
|
186
|
+
'> quoted',
|
|
187
|
+
'',
|
|
188
|
+
'```',
|
|
189
|
+
'fenced',
|
|
190
|
+
'```',
|
|
191
|
+
'',
|
|
192
|
+
'---',
|
|
193
|
+
'',
|
|
194
|
+
'last line',
|
|
195
|
+
].join('\n')
|
|
196
|
+
|
|
197
|
+
const BENIGN_FIRST = 'a perfectly ordinary first comment'
|
|
198
|
+
|
|
199
|
+
/** `[name, body, visible words]` — the words a reader of the source expects to see. */
|
|
200
|
+
const OPEN_FIRSTS: ReadonlyArray<readonly [name: string, body: string, visible: readonly string[]]> = [
|
|
201
|
+
['an unclosed fence', '```\nline one\nline two', ['line one', 'line two']],
|
|
202
|
+
['an unclosed fence with a language', '```ts\nconst x = 1', ['const x = 1']],
|
|
203
|
+
[`an unclosed ${PP}`, `${PP}unclosed`, [`${PP}unclosed`]],
|
|
204
|
+
['an open [', 'hello [', ['hello [']],
|
|
205
|
+
['an open [ with text after', 'hello [world and more', ['hello [world and more']],
|
|
206
|
+
['an unclosed **', 'hello **world', ['hello **world']],
|
|
207
|
+
['an unclosed backtick', 'hello `world', ['hello `world']],
|
|
208
|
+
['an unclosed raw tag', '<div', ['<div']],
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
/** Render two bodies the way `comment.tsx` does — each its own instance — and return the second's DOM. */
|
|
212
|
+
function secondBodyHtml(first: string): string {
|
|
213
|
+
const { container, unmount } = render(
|
|
214
|
+
<>
|
|
215
|
+
<CommentBody bodyMd={first} />
|
|
216
|
+
<CommentBody bodyMd={SECOND_BODY} />
|
|
217
|
+
</>,
|
|
218
|
+
)
|
|
219
|
+
expect(container.children).toHaveLength(2)
|
|
220
|
+
const second = container.children[1]
|
|
221
|
+
const out = second?.outerHTML ?? ''
|
|
222
|
+
unmount()
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
describe('CommentBody — cross-comment isolation (AC#5)', () => {
|
|
227
|
+
const control = secondBodyHtml(BENIGN_FIRST)
|
|
228
|
+
|
|
229
|
+
it('the control second body actually rendered its structure', () => {
|
|
230
|
+
// Guards against the equality below being vacuous.
|
|
231
|
+
for (const needle of ['<strong', '<em', '<u ', '<del', '<a ', '<code', '<pre', 'Copy code', '<h1', '<li', '<blockquote', '<hr', 'Ada', 'last line']) {
|
|
232
|
+
expect(control).toContain(needle)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
for (const [name, first] of OPEN_FIRSTS) {
|
|
237
|
+
it(`is byte-identical after ${name}`, () => {
|
|
238
|
+
expect(secondBodyHtml(first)).toBe(control)
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('the open first body still renders its own text (strangely, by itself)', () => {
|
|
243
|
+
for (const [name, first, visible] of OPEN_FIRSTS) {
|
|
244
|
+
const { container, unmount } = renderBody(first)
|
|
245
|
+
const text = container.textContent ?? ''
|
|
246
|
+
for (const word of visible) expect(text, name).toContain(word)
|
|
247
|
+
unmount()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// AC#8 — the URL allowlist stays exact
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
describe('CommentBody — URL allowlist (AC#8)', () => {
|
|
257
|
+
const MENTIONS: ReadonlyArray<readonly [scheme: MentionTarget['type'], id: string]> = [
|
|
258
|
+
['user', 'usr_9f2'],
|
|
259
|
+
['task_item', 'tit_44a'],
|
|
260
|
+
['task_board', 'tbd_1'],
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
for (const [scheme, id] of MENTIONS) {
|
|
264
|
+
it(`passes ${scheme}: through as a mention chip`, () => {
|
|
265
|
+
const onSelectMention = vi.fn<(target: MentionTarget) => void>()
|
|
266
|
+
const { container, unmount } = renderBody(`@[Target](${scheme}:${id})`, onSelectMention)
|
|
267
|
+
// Not an anchor — the href never reaches the DOM as a link at all.
|
|
268
|
+
expect(container.querySelector('a')).toBeNull()
|
|
269
|
+
const chip = container.querySelector('button')
|
|
270
|
+
expect(chip).not.toBeNull()
|
|
271
|
+
expect(chip?.textContent).toBe('Target')
|
|
272
|
+
if (chip !== null) fireEvent.click(chip)
|
|
273
|
+
expect(onSelectMention).toHaveBeenCalledTimes(1)
|
|
274
|
+
expect(onSelectMention).toHaveBeenCalledWith({ type: scheme, id })
|
|
275
|
+
unmount()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it(`renders ${scheme}: as an inert chip when there is no handler`, () => {
|
|
279
|
+
const { container, unmount } = renderBody(`@[Target](${scheme}:${id})`)
|
|
280
|
+
expect(container.querySelector('a')).toBeNull()
|
|
281
|
+
expect(container.querySelector('button')).toBeNull()
|
|
282
|
+
expect(container.textContent).toBe('@Target')
|
|
283
|
+
unmount()
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it('javascript:, data: and vbscript: hrefs render with a null or empty href', () => {
|
|
288
|
+
for (const url of [
|
|
289
|
+
'javascript:alert(1)',
|
|
290
|
+
'JaVaScRiPt:alert(1)',
|
|
291
|
+
'javascript:alert(1)',
|
|
292
|
+
'data:text/html,x',
|
|
293
|
+
'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
|
|
294
|
+
'vbscript:msgbox',
|
|
295
|
+
'<javascript:alert(1)>',
|
|
296
|
+
]) {
|
|
297
|
+
const { container, unmount } = renderBody(`[click](${url})`)
|
|
298
|
+
const anchor = container.querySelector('a')
|
|
299
|
+
expect(anchor, url).not.toBeNull()
|
|
300
|
+
expect(anchor?.textContent, url).toBe('click')
|
|
301
|
+
// react-markdown's `defaultUrlTransform` returns '' for an unsafe
|
|
302
|
+
// protocol; React then emits the attribute empty. Either shape is inert
|
|
303
|
+
// and neither begins with the scheme — that is the outcome pinned here.
|
|
304
|
+
const href = anchor?.getAttribute('href')
|
|
305
|
+
expect(href === null || href === '', `${url} -> ${String(href)}`).toBe(true)
|
|
306
|
+
unmount()
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('does not widen the allowlist to look-alike schemes', () => {
|
|
311
|
+
for (const url of ['users:usr_1', 'user_x:1', 'task:tit_1', 'task_items:1', 'note:abc', 'user:', 'USER:usr_1']) {
|
|
312
|
+
const { container, unmount } = renderBody(`[x](${url})`)
|
|
313
|
+
// A near-miss is an ordinary link, not a chip...
|
|
314
|
+
expect(container.querySelector('button, span[class*="inline-flex"]'), url).toBeNull()
|
|
315
|
+
const anchor = container.querySelector('a')
|
|
316
|
+
expect(anchor, url).not.toBeNull()
|
|
317
|
+
// ...and its unknown scheme is dropped by the default transform.
|
|
318
|
+
const href = anchor?.getAttribute('href')
|
|
319
|
+
expect(href === null || href === '', `${url} -> ${String(href)}`).toBe(true)
|
|
320
|
+
unmount()
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('keeps ordinary safe hrefs exactly', () => {
|
|
325
|
+
for (const url of ['https://example.com/a?b=c:d', 'http://example.com', 'mailto:ada@example.com', '/relative/path', '#fragment', '?q=a:b']) {
|
|
326
|
+
const { container, unmount } = renderBody(`[x](${url})`)
|
|
327
|
+
expect(container.querySelector('a')?.getAttribute('href'), url).toBe(url)
|
|
328
|
+
unmount()
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* comment-tree — the shape contract, and the D4 correction.
|
|
4
|
+
*
|
|
5
|
+
* These are pure-function tests in the node environment: no DOM, no clock, no
|
|
6
|
+
* randomness. The two that matter most are the deleted-parent and absent-parent
|
|
7
|
+
* cases, because the reference collapsed them into one behaviour and that is
|
|
8
|
+
* the bug ADR-147 D4 was written to stop.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
buildCommentTree,
|
|
14
|
+
countCommentTree,
|
|
15
|
+
countDescendants,
|
|
16
|
+
engagementScore,
|
|
17
|
+
sortCommentTree,
|
|
18
|
+
} from '../comment-tree'
|
|
19
|
+
import type { CommentNode, ThreadComment } from '../types'
|
|
20
|
+
import { FIXTURE_NOW, THREAD_FIXTURE } from './fixtures/thread-fixture'
|
|
21
|
+
|
|
22
|
+
function find(nodes: readonly CommentNode[], id: string): CommentNode | undefined {
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
if (node.id === id) return node
|
|
25
|
+
const hit = find(node.replies, id)
|
|
26
|
+
if (hit !== undefined) return hit
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const comment = (over: Partial<ThreadComment> & { id: string }): ThreadComment => ({
|
|
32
|
+
bodyMd: 'body',
|
|
33
|
+
createdAt: FIXTURE_NOW,
|
|
34
|
+
...over,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('buildCommentTree', () => {
|
|
38
|
+
it('returns a flat list unchanged when nothing has a parent', () => {
|
|
39
|
+
const flat = [comment({ id: 'a' }), comment({ id: 'b' }), comment({ id: 'c' })]
|
|
40
|
+
const roots = buildCommentTree(flat)
|
|
41
|
+
expect(roots.map((n) => n.id)).toEqual(['a', 'b', 'c'])
|
|
42
|
+
expect(roots.every((n) => n.replies.length === 0)).toBe(true)
|
|
43
|
+
expect(roots.every((n) => n.orphanedParentId === null)).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('nests by parentId and records the TRUE depth', () => {
|
|
47
|
+
const roots = buildCommentTree(THREAD_FIXTURE)
|
|
48
|
+
const deep3 = find(roots, 'c-deep-3')
|
|
49
|
+
expect(deep3).toBeDefined()
|
|
50
|
+
// Depth is stored true and capped only at render time (D3).
|
|
51
|
+
expect(deep3?.depth).toBe(3)
|
|
52
|
+
expect(find(roots, 'c-deep-0')?.depth).toBe(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('D4 — a DELETED parent keeps its subtree', () => {
|
|
56
|
+
const roots = buildCommentTree(THREAD_FIXTURE)
|
|
57
|
+
const parent = roots.find((n) => n.id === 'c-deleted-parent')
|
|
58
|
+
expect(parent).toBeDefined()
|
|
59
|
+
expect(parent?.deletedAt).toBeTypeOf('number')
|
|
60
|
+
// The children are STILL under it, not promoted to top level.
|
|
61
|
+
expect(parent?.replies.map((n) => n.id)).toEqual([
|
|
62
|
+
'c-deleted-child-a',
|
|
63
|
+
'c-deleted-child-b',
|
|
64
|
+
])
|
|
65
|
+
expect(roots.some((n) => n.id === 'c-deleted-child-a')).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('D4 — an ABSENT parent surfaces the reply at top level AND flags it', () => {
|
|
69
|
+
const roots = buildCommentTree(THREAD_FIXTURE)
|
|
70
|
+
const orphan = roots.find((n) => n.id === 'c-orphan')
|
|
71
|
+
expect(orphan).toBeDefined()
|
|
72
|
+
// Never dropped...
|
|
73
|
+
expect(orphan?.bodyMd).toContain('pacing')
|
|
74
|
+
// ...and never silently promoted as if it were a new remark.
|
|
75
|
+
expect(orphan?.orphanedParentId).toBe('c-not-on-this-page')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does not recurse forever on a parent cycle', () => {
|
|
79
|
+
const cyclic = [
|
|
80
|
+
comment({ id: 'a', parentId: 'b' }),
|
|
81
|
+
comment({ id: 'b', parentId: 'a' }),
|
|
82
|
+
]
|
|
83
|
+
const roots = buildCommentTree(cyclic)
|
|
84
|
+
expect(countCommentTree(roots)).toBe(2)
|
|
85
|
+
expect(roots.every((n) => n.orphanedParentId !== null)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('counts every node at every depth', () => {
|
|
89
|
+
const roots = buildCommentTree(THREAD_FIXTURE)
|
|
90
|
+
expect(countCommentTree(roots)).toBe(THREAD_FIXTURE.length)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('countDescendants excludes the node itself', () => {
|
|
94
|
+
const roots = buildCommentTree(THREAD_FIXTURE)
|
|
95
|
+
const many = roots.find((n) => n.id === 'c-many')
|
|
96
|
+
expect(many).toBeDefined()
|
|
97
|
+
expect(many?.replies.length).toBe(8)
|
|
98
|
+
expect(many === undefined ? -1 : countDescendants(many)).toBe(8)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('engagementScore', () => {
|
|
103
|
+
it('prefers an explicit vote score', () => {
|
|
104
|
+
const [node] = buildCommentTree([comment({ id: 'a', score: 12 })])
|
|
105
|
+
expect(node === undefined ? -1 : engagementScore(node)).toBe(12)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('otherwise totals the reaction counts', () => {
|
|
109
|
+
const [node] = buildCommentTree([
|
|
110
|
+
comment({
|
|
111
|
+
id: 'a',
|
|
112
|
+
reactions: [
|
|
113
|
+
{ key: 'up', count: 3, viewerReacted: false },
|
|
114
|
+
{ key: 'heart', count: 2, viewerReacted: true },
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
])
|
|
118
|
+
expect(node === undefined ? -1 : engagementScore(node)).toBe(5)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('sortCommentTree', () => {
|
|
123
|
+
const roots = buildCommentTree(THREAD_FIXTURE)
|
|
124
|
+
|
|
125
|
+
it('top puts pinned first and undelivered last', () => {
|
|
126
|
+
const sorted = sortCommentTree(roots, 'top')
|
|
127
|
+
expect(sorted[0]?.id).toBe('c-reactions')
|
|
128
|
+
const ids = sorted.map((n) => n.id)
|
|
129
|
+
expect(ids.indexOf('c-pending')).toBeGreaterThan(ids.indexOf('c-one-word'))
|
|
130
|
+
expect(ids.indexOf('c-failed')).toBeGreaterThan(ids.indexOf('c-one-word'))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('newest puts the most recent root first', () => {
|
|
134
|
+
const sorted = sortCommentTree(roots, 'new')
|
|
135
|
+
expect(sorted[0]?.id).toBe('c-pending')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('keeps replies chronological whichever sort the roots use', () => {
|
|
139
|
+
for (const sort of ['top', 'new'] as const) {
|
|
140
|
+
const many = sortCommentTree(roots, sort).find((n) => n.id === 'c-many')
|
|
141
|
+
const times = (many?.replies ?? []).map((n) => n.createdAt)
|
|
142
|
+
expect(times).toEqual([...times].sort((a, b) => a - b))
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('is deterministic — the same input always sorts the same way', () => {
|
|
147
|
+
const a = sortCommentTree(roots, 'top').map((n) => n.id)
|
|
148
|
+
const b = sortCommentTree(buildCommentTree(THREAD_FIXTURE), 'top').map((n) => n.id)
|
|
149
|
+
expect(a).toEqual(b)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curated shortcode table (ADR-149 D3), and the source-byte discipline it
|
|
3
|
+
* has to keep (AC#9).
|
|
4
|
+
*
|
|
5
|
+
* Two things are being defended here. The first is behaviour: the search has
|
|
6
|
+
* to find what a person actually types, in the dialect they know. The second
|
|
7
|
+
* is a property of the FILE rather than the code — every glyph is written as a
|
|
8
|
+
* `\u{...}` escape, so the table can grow to a thousand emoji without a single
|
|
9
|
+
* raw high byte reaching a source file. A raw high byte makes a file grep as
|
|
10
|
+
* binary, which silently blinds the CLAUDE.md §8 gates; ADR-147 D8 learned
|
|
11
|
+
* that once and this is the assertion that keeps it learned.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
EMOJI_RESULT_LIMIT,
|
|
19
|
+
THREAD_EMOJI,
|
|
20
|
+
emojiByShortcode,
|
|
21
|
+
normalizeShortcode,
|
|
22
|
+
searchEmoji,
|
|
23
|
+
type EmojiEntry,
|
|
24
|
+
} from '../emoji'
|
|
25
|
+
|
|
26
|
+
/* ── D3 — the table itself ────────────────────────────────────────────────── */
|
|
27
|
+
|
|
28
|
+
describe('D3 — the table', () => {
|
|
29
|
+
it('is the size the ADR budgeted for: curated, not a dataset', () => {
|
|
30
|
+
expect(THREAD_EMOJI.length).toBeGreaterThanOrEqual(120)
|
|
31
|
+
expect(THREAD_EMOJI.length).toBeLessThanOrEqual(160)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('has no duplicate name, glyph or shortcode — a lookup can only mean one thing', () => {
|
|
35
|
+
const names = new Set<string>()
|
|
36
|
+
const glyphs = new Set<string>()
|
|
37
|
+
const codes = new Set<string>()
|
|
38
|
+
for (const entry of THREAD_EMOJI) {
|
|
39
|
+
expect(names.has(entry.name)).toBe(false)
|
|
40
|
+
names.add(entry.name)
|
|
41
|
+
expect(glyphs.has(entry.glyph)).toBe(false)
|
|
42
|
+
glyphs.add(entry.glyph)
|
|
43
|
+
for (const code of [entry.name, ...entry.aliases]) {
|
|
44
|
+
const key = normalizeShortcode(code)
|
|
45
|
+
expect(codes.has(key)).toBe(false)
|
|
46
|
+
codes.add(key)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('spells every shortcode in the character set a `:` query can actually carry', () => {
|
|
52
|
+
// The trigger's own match is `:[^\s:]*`, so a shortcode holding a space or
|
|
53
|
+
// a colon would be unreachable by typing — a dead row in the table.
|
|
54
|
+
for (const entry of THREAD_EMOJI) {
|
|
55
|
+
for (const code of [entry.name, ...entry.aliases]) {
|
|
56
|
+
expect(code).toMatch(/^[a-z0-9_+-]+$/)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('carries a real glyph on every row', () => {
|
|
62
|
+
for (const entry of THREAD_EMOJI) {
|
|
63
|
+
expect(entry.glyph.length).toBeGreaterThan(0)
|
|
64
|
+
expect([...entry.glyph].every((char) => char.codePointAt(0)! > 0x7f)).toBe(true)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/* ── D3 — the lookup ──────────────────────────────────────────────────────── */
|
|
70
|
+
|
|
71
|
+
describe('D3 — resolving a shortcode', () => {
|
|
72
|
+
it('AC#3: `:side-eye:` resolves to the rolling-eyes glyph — the owner example', () => {
|
|
73
|
+
expect(emojiByShortcode('side-eye')?.glyph).toBe('\u{1F644}')
|
|
74
|
+
expect(emojiByShortcode(':side-eye:')?.glyph).toBe('\u{1F644}')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('treats `-` and `_` as the same letter, so muscle memory works in any dialect', () => {
|
|
78
|
+
const rolling: EmojiEntry | undefined = emojiByShortcode('roll_eyes')
|
|
79
|
+
expect(rolling?.glyph).toBe('\u{1F644}')
|
|
80
|
+
expect(emojiByShortcode('side_eye')).toBe(rolling)
|
|
81
|
+
expect(emojiByShortcode('SIDE-EYE')).toBe(rolling)
|
|
82
|
+
expect(emojiByShortcode('face_with_rolling_eyes')).toBe(rolling)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('resolves the shortcodes people reach for most', () => {
|
|
86
|
+
expect(emojiByShortcode('tada')?.glyph).toBe('\u{1F389}')
|
|
87
|
+
expect(emojiByShortcode('rocket')?.glyph).toBe('\u{1F680}')
|
|
88
|
+
expect(emojiByShortcode('thumbsup')?.glyph).toBe('\u{1F44D}')
|
|
89
|
+
expect(emojiByShortcode('+1')?.glyph).toBe('\u{1F44D}')
|
|
90
|
+
expect(emojiByShortcode('eyes')?.glyph).toBe('\u{1F440}')
|
|
91
|
+
expect(emojiByShortcode('heart')?.glyph).toBe('\u{2764}\u{FE0F}')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns nothing for a shortcode the table does not hold', () => {
|
|
95
|
+
expect(emojiByShortcode('definitely_not_an_emoji')).toBeUndefined()
|
|
96
|
+
expect(emojiByShortcode('')).toBeUndefined()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/* ── D4 — the search ──────────────────────────────────────────────────────── */
|
|
101
|
+
|
|
102
|
+
describe('D4 — prefix search over names, aliases and keywords', () => {
|
|
103
|
+
it('finds an entry by the prefix of its name', () => {
|
|
104
|
+
expect(searchEmoji('tad')[0]?.glyph).toBe('\u{1F389}')
|
|
105
|
+
expect(searchEmoji('roc')[0]?.glyph).toBe('\u{1F680}')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("D3's worked example: `:party:` finds the party popper, whose name is `tada`", () => {
|
|
109
|
+
expect(searchEmoji('party')[0]?.name).toBe('tada')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('finds an entry by an alias, in the dialect that spells it that way', () => {
|
|
113
|
+
expect(searchEmoji('thumbsup')[0]?.glyph).toBe('\u{1F44D}')
|
|
114
|
+
expect(searchEmoji('shipped')[0]?.glyph).toBe('\u{1F389}')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('ranks an exact name above a longer name that merely starts the same way', () => {
|
|
118
|
+
const results = searchEmoji('star')
|
|
119
|
+
expect(results[0]?.name).toBe('star')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('returns nothing for an empty query — a bare colon is punctuation', () => {
|
|
123
|
+
expect(searchEmoji('')).toEqual([])
|
|
124
|
+
expect(searchEmoji(' ')).toEqual([])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('returns nothing for a query nothing matches', () => {
|
|
128
|
+
expect(searchEmoji('zzzznope')).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('bounds the result list, so the popup has a known maximum height', () => {
|
|
132
|
+
// `s` is a prefix of a great many keywords; the cap is what stops the
|
|
133
|
+
// popup from becoming a page.
|
|
134
|
+
expect(searchEmoji('s').length).toBeLessThanOrEqual(EMOJI_RESULT_LIMIT)
|
|
135
|
+
expect(searchEmoji('s', 3)).toHaveLength(3)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('is case-insensitive and tolerates the colons a person types around it', () => {
|
|
139
|
+
expect(searchEmoji('TAD')[0]?.glyph).toBe('\u{1F389}')
|
|
140
|
+
expect(searchEmoji(':tada:')[0]?.glyph).toBe('\u{1F389}')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
/* ── AC#9 — the source files stay text ────────────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read a sibling source file. `.href` rather than the URL object itself
|
|
148
|
+
* because the global `URL` under jsdom is whatwg-url's, which Node's
|
|
149
|
+
* `fileURLToPath` does not always accept as one of its own.
|
|
150
|
+
*/
|
|
151
|
+
function sourceOf(relative: string): string {
|
|
152
|
+
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe('AC#9 — every glyph is an escape, never a raw byte', () => {
|
|
156
|
+
/**
|
|
157
|
+
* The only non-ASCII this codebase's TypeScript is allowed to carry, and it
|
|
158
|
+
* is all PROSE inside comments: the section rule, the three dashes, the
|
|
159
|
+
* ellipsis, the two arrows and the command key. No pictograph, no variation
|
|
160
|
+
* selector, nothing above the BMP. Adding to this list should feel like a
|
|
161
|
+
* decision; adding a raw emoji to it is the thing the list exists to stop.
|
|
162
|
+
*/
|
|
163
|
+
const TYPOGRAPHY = new Set(
|
|
164
|
+
['\u{00A7}', '\u{2013}', '\u{2014}', '\u{2019}', '\u{2026}', '\u{2192}', '\u{21B5}', '\u{2318}', '\u{2500}'].map(
|
|
165
|
+
(char) => char.codePointAt(0),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const files = ['../emoji.ts', '../suggestion-list.tsx', './emoji.test.ts', './suggestion-list.test.tsx']
|
|
170
|
+
|
|
171
|
+
it.each(files)('%s holds no raw glyph', (relative) => {
|
|
172
|
+
const source = sourceOf(relative)
|
|
173
|
+
const offenders = [...source].filter((char) => {
|
|
174
|
+
const code = char.codePointAt(0) ?? 0
|
|
175
|
+
return code > 0x7f && !TYPOGRAPHY.has(code)
|
|
176
|
+
})
|
|
177
|
+
expect(offenders).toEqual([])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('proves the point on the table itself: every glyph is absent from its own source', () => {
|
|
181
|
+
const source = sourceOf('../emoji.ts')
|
|
182
|
+
for (const entry of THREAD_EMOJI) {
|
|
183
|
+
expect(source.includes(entry.glyph)).toBe(false)
|
|
184
|
+
expect(source.includes(`'${entry.name}'`)).toBe(true)
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
})
|