@gjsify/adwaita-web 0.64.12 → 0.64.13-next.283

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.
@@ -1,11 +1,14 @@
1
1
  // DOM-level tests for <gtk-label>.
2
2
  //
3
- // Three things matter more than the attribute round-trips. The text reaches the DOM as ONE
4
- // TEXT NODE whatever it holds — `use-markup` included, which is where an `innerHTML` port
5
- // would execute a string. `xalign` is read off where the text actually SITS in the label's
6
- // box, not off a style the element wrote. And the typography classes from `_labels.scss`
7
- // apply to the element, including inside another widget whose isolation floor would
8
- // otherwise repaint it (`_reset.scss`).
3
+ // Four things matter more than the attribute round-trips. A label WITHOUT `use-markup`
4
+ // reaches the DOM as one text node, and one WITH it is drawn as the styled runs core
5
+ // derived — built node by node, so no string the label carries is ever parsed as markup
6
+ // (the `<img>` row: markup pango rejects stays text, it does not become an element).
7
+ // `<a href>` is a real anchor whose click is cancelled in favour of `activate-link`.
8
+ // `xalign` is read off where the text actually SITS in the label's box, not off a style the
9
+ // element wrote. And the typography classes from `_labels.scss` apply to the element,
10
+ // including inside another widget whose isolation floor would otherwise repaint it
11
+ // (`_reset.scss`).
9
12
 
10
13
  import { describe, expect, it } from '@gjsify/unit';
11
14
 
@@ -16,16 +19,21 @@ import {
16
19
  LABEL_ELLIPSIZE_OVERFLOW_VECTORS,
17
20
  LABEL_ELLIPSIZE_VECTORS,
18
21
  LABEL_JUSTIFY_VECTORS,
22
+ LABEL_MARKUP_LINK_VECTORS,
23
+ LABEL_MARKUP_RUN_VECTORS,
19
24
  LABEL_WIDTH_CHARS_EXTENT_VECTORS,
20
25
  LABEL_WRAP_MODE_VECTORS,
21
26
  LABEL_XALIGN_VECTORS,
22
27
  LABEL_YALIGN_ALIGN_ITEMS_VECTORS,
23
28
  LABEL_YALIGN_VECTORS,
29
+ type LabelMarkupLinkExpectation,
30
+ type LabelMarkupRunExpectation,
24
31
  } from '@gjsify/adwaita-core/conformance';
25
32
 
26
33
  import type { GtkLabel } from './elements/gtk-label.js';
34
+ import { labelMarkupAnchorIndex, labelMarkupAnchors } from './label-markup.js';
27
35
 
28
- function mount(label: string, parent?: HTMLElement): { el: GtkLabel; host: HTMLElement } {
36
+ function mount(label: string, parent?: HTMLElement): { el: GtkLabel; host: HTMLElement; opened: string[] } {
29
37
  const host = document.createElement('div');
30
38
  host.style.width = '400px';
31
39
  document.body.appendChild(host);
@@ -33,7 +41,136 @@ function mount(label: string, parent?: HTMLElement): { el: GtkLabel; host: HTMLE
33
41
  el.label = label;
34
42
  (parent ?? host).appendChild(el);
35
43
  if (parent) host.appendChild(parent);
36
- return { el, host };
44
+ // `window.open` is the launcher call; a spec must not open a tab to prove a click.
45
+ const opened: string[] = [];
46
+ (el as unknown as { launch: (uri: string) => void }).launch = (uri: string) => opened.push(uri);
47
+ return { el, host, opened };
48
+ }
49
+
50
+ /** One click a user could make, cancelable so the spec can read back whether it was. */
51
+ function click(target: Element): MouseEvent {
52
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
53
+ target.dispatchEvent(event);
54
+ return event;
55
+ }
56
+
57
+ /** Collect the `detail` of every `event` dispatched on (or bubbling to) `el`. */
58
+ function record(el: HTMLElement, event: string): unknown[] {
59
+ const details: unknown[] = [];
60
+ el.addEventListener(event, (e) => details.push((e as CustomEvent).detail));
61
+ return details;
62
+ }
63
+
64
+ /**
65
+ * Every text node under `root`, in reading order — ONE PER DRAWN RUN, because a run is
66
+ * built as a chain of elements around a single text node (`label-markup.ts`, `buildRun`).
67
+ * So this list and the vector's `runs` line up position for position, and a renderer that
68
+ * failed to merge two adjacent equal-styled runs produces one node too many.
69
+ */
70
+ function textNodes(root: Node): Text[] {
71
+ const found: Text[] = [];
72
+ for (const child of root.childNodes) {
73
+ if (child.nodeType === Node.TEXT_NODE) found.push(child as Text);
74
+ else found.push(...textNodes(child));
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /** A run as the DOM holds it, in the shape {@link LabelMarkupRunExpectation} states it. */
80
+ interface DrawnRun {
81
+ text: string;
82
+ bold: boolean;
83
+ italic: boolean;
84
+ underline: boolean;
85
+ strikethrough: boolean;
86
+ monospace: boolean;
87
+ sizeSteps: number;
88
+ script: 'sub' | 'sup' | null;
89
+ link: number | null;
90
+ /** How many `<span>` attribute sets the run sits in — the COUNT, see below. */
91
+ spans: number;
92
+ }
93
+
94
+ /**
95
+ * `node`'s run, read back off its ancestors up to `root`.
96
+ *
97
+ * THE `<span>`S ARE COUNTED, NOT COMPARED. `tt`, `big` and `small` are each drawn as a
98
+ * `<span>` carrying the one CSS property `TAG_STYLES` gives them, so those three are
99
+ * recognised by that property and everything else that is a `<span>` came from a pango
100
+ * `<span>` element. Its ATTRIBUTES are not asserted here: `applySpanAttributes` drops
101
+ * every attribute outside its whitelist (`foreground` among them), so the mapping is
102
+ * `pango-markup.spec.ts`'s subject and what matters at this level is that each `<span>` in
103
+ * the run's stack became one element in the chain, in order.
104
+ */
105
+ function drawnRun(node: Text, root: Element): DrawnRun {
106
+ const run: DrawnRun = {
107
+ text: node.data,
108
+ bold: false,
109
+ italic: false,
110
+ underline: false,
111
+ strikethrough: false,
112
+ monospace: false,
113
+ sizeSteps: 0,
114
+ script: null,
115
+ link: null,
116
+ spans: 0,
117
+ };
118
+ for (let el = node.parentElement; el !== null && el !== root; el = el.parentElement) {
119
+ const tag = el.tagName.toLowerCase();
120
+ if (tag === 'b') run.bold = true;
121
+ else if (tag === 'i') run.italic = true;
122
+ else if (tag === 'u') run.underline = true;
123
+ else if (tag === 's') run.strikethrough = true;
124
+ else if (tag === 'sub' || tag === 'sup') run.script = tag;
125
+ else if (tag === 'a') run.link = labelMarkupAnchorIndex(el);
126
+ // THE TWO BOXES ARE THE ELEMENT'S OWN, not a run's: `.adw-label-text` is the
127
+ // wrap/ellipsize box (`_renderSpan`), `.adw-label-runs` the single-line one the
128
+ // runs need because the label is a flex container (`_runSpan`). Neither carries
129
+ // style a run asked for, so neither counts as a `<span>` from the markup.
130
+ else if (el.classList.contains('adw-label-text') || el.classList.contains('adw-label-runs')) continue;
131
+ else if (el.style.fontFamily === 'monospace') run.monospace = true;
132
+ else if (el.style.fontSize === 'larger') run.sizeSteps += 1;
133
+ else if (el.style.fontSize === 'smaller') run.sizeSteps -= 1;
134
+ else run.spans += 1;
135
+ }
136
+ return run;
137
+ }
138
+
139
+ /** The same shape from the vector, where every omitted field means "not set". */
140
+ function expectedRun(run: LabelMarkupRunExpectation): DrawnRun {
141
+ return {
142
+ text: run.text,
143
+ bold: run.bold ?? false,
144
+ italic: run.italic ?? false,
145
+ underline: run.underline ?? false,
146
+ strikethrough: run.strikethrough ?? false,
147
+ monospace: run.monospace ?? false,
148
+ sizeSteps: run.sizeSteps ?? 0,
149
+ script: run.script ?? null,
150
+ link: run.link ?? null,
151
+ spans: run.spans?.length ?? 0,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The text the DOM can give the anchor for `links[index]`: the link's range, minus the
157
+ * range of any link nested INSIDE it.
158
+ *
159
+ * THE ONE PLACE THE ANCHORS ARE NOT THE RANGES. GTK's ranges may overlap —
160
+ * `<a href="outer">out <a href="inner">in</a></a>` is two links and the outer's range
161
+ * covers the inner's text — and one DOM element cannot hold text another also holds. The
162
+ * inner anchor owns that text, as the inner link owns it in GTK too (`finish_text`
163
+ * attributes to the open element), and the outer's node keeps the rest of its range.
164
+ */
165
+ function anchorText(text: string, links: ReadonlyArray<LabelMarkupLinkExpectation>, index: number): string {
166
+ const link = links[index]!;
167
+ const inner = links.filter(
168
+ (other, at) => at !== index && other.end > other.start && other.start >= link.start && other.end <= link.end,
169
+ );
170
+ let kept = '';
171
+ for (let at = link.start; at < link.end; at += 1)
172
+ if (!inner.some((nested) => nested.start <= at && at < nested.end)) kept += text[at];
173
+ return kept;
37
174
  }
38
175
 
39
176
  /** Where the rendered TEXT sits, measured on the text node rather than the element. */
@@ -43,6 +180,13 @@ function textRect(el: HTMLElement): DOMRect {
43
180
  return range.getBoundingClientRect();
44
181
  }
45
182
 
183
+ /** Where one drawn run's GLYPHS sit — the text node's own box, not its element's. */
184
+ function runRect(node: Text): DOMRect {
185
+ const range = document.createRange();
186
+ range.selectNodeContents(node);
187
+ return range.getBoundingClientRect();
188
+ }
189
+
46
190
  export const GtkLabelTest = async () => {
47
191
  await describe('<gtk-label> against LABEL_DISPLAY_TEXT_VECTORS', async () => {
48
192
  for (const vector of LABEL_DISPLAY_TEXT_VECTORS) {
@@ -50,16 +194,292 @@ export const GtkLabelTest = async () => {
50
194
  const { el, host } = mount(vector.label);
51
195
  el.useMarkup = vector.useMarkup;
52
196
  el.useUnderline = vector.useUnderline;
53
- // What reached the SCREEN, and that it is one text node: no row may build
54
- // an element, whatever its markup says.
197
+ // What reached the SCREEN. `textContent` is the display text whichever
198
+ // path drew it — a plain text node, or the run elements concatenated —
199
+ // so this is the assertion that says the two agree.
200
+ expect(el.textContent).toBe(vector.text);
201
+ expect(el.getText()).toBe(vector.text);
202
+ // NO ELEMENT WAS PARSED FROM THE STRING, which is the XSS answer and a
203
+ // different claim from "no elements at all": a row whose markup PARSES is
204
+ // now drawn as runs (`<b>` is a `<b>`), so the check is that nothing the
205
+ // markup named as a foreign tag became a node. The `<img>` row is the one
206
+ // that matters — it does not parse, so it stays text.
207
+ expect(el.querySelectorAll('img, script, iframe').length).toBe(0);
208
+ host.remove();
209
+ });
210
+ }
211
+ });
212
+
213
+ await describe('<gtk-label> against LABEL_MARKUP_RUN_VECTORS — the runs as DRAWN nodes', async () => {
214
+ for (const vector of LABEL_MARKUP_RUN_VECTORS) {
215
+ await it(vector.rule, () => {
216
+ const { el, host } = mount(vector.markup);
217
+ el.useMarkup = true;
218
+
219
+ // The row with NO runs is markup pango rejects, and GTK then shows the
220
+ // raw string: one text node, the markup included, no element built.
221
+ if (vector.runs.length === 0) {
222
+ expect(el.textContent).toBe(vector.text);
223
+ expect(el.childElementCount).toBe(0);
224
+ host.remove();
225
+ return;
226
+ }
227
+
228
+ const drawn = textNodes(el).map((node) => drawnRun(node, el));
229
+ expect(drawn).toStrictEqual(vector.runs.map(expectedRun));
230
+ // The runs' text concatenated IS the display text — `gtk_label_get_text`
231
+ // and what the user sees cannot be two different strings.
55
232
  expect(el.textContent).toBe(vector.text);
56
233
  expect(el.getText()).toBe(vector.text);
57
- expect(el.childElementCount).toBe(0);
58
234
  host.remove();
59
235
  });
60
236
  }
61
237
  });
62
238
 
239
+ await describe('<gtk-label> draws its runs as TEXT, not as flex items', async () => {
240
+ await it('puts the runs in one box of their own, and takes it away again', () => {
241
+ const { el, host } = mount('<b>Bold</b> and <i>italic</i>');
242
+ el.useMarkup = true;
243
+ // ONE element child, holding every run. The label itself is a flex container
244
+ // (the `xalign` spacers, `_labels.scss`), so a run list directly under it is
245
+ // one FLEX ITEM PER RUN — three boxes shrinking independently, not a line of
246
+ // text. The box is the item; the runs inside it are inline.
247
+ expect(el.childElementCount).toBe(1);
248
+ expect(el.firstElementChild?.classList.contains('adw-label-runs')).toBe(true);
249
+ expect(textNodes(el).length).toBe(3);
250
+
251
+ // And it is GONE when there is no markup to draw: a plain label is back to the
252
+ // single text node the XSS note pins, no leftover box.
253
+ el.useMarkup = false;
254
+ expect(el.childElementCount).toBe(0);
255
+ expect(el.textContent).toBe('<b>Bold</b> and <i>italic</i>');
256
+ host.remove();
257
+ });
258
+
259
+ await it('hands the box over to the wrap box, never nests the two', () => {
260
+ const { el, host } = mount('<b>Bold</b> and <i>italic</i>');
261
+ el.useMarkup = true;
262
+ el.wrap = true;
263
+ // `.adw-label-text` is `display: block`, so the runs in it are already inline
264
+ // boxes — a second box inside it would be one box too many, and `_remeasure`
265
+ // would then be pinning the wrong element's width.
266
+ expect(el.childElementCount).toBe(1);
267
+ expect(el.firstElementChild?.classList.contains('adw-label-text')).toBe(true);
268
+ expect(el.querySelectorAll('.adw-label-runs').length).toBe(0);
269
+ expect(el.textContent).toBe('Bold and italic');
270
+
271
+ el.wrap = false;
272
+ expect(el.childElementCount).toBe(1);
273
+ expect(el.firstElementChild?.classList.contains('adw-label-runs')).toBe(true);
274
+ expect(el.querySelectorAll('.adw-label-text').length).toBe(0);
275
+ host.remove();
276
+ });
277
+
278
+ await it('raises sup above sub — the proof the runs are text and not items', () => {
279
+ const { el, host } = mount('x<sub>2</sub> and y<sup>3</sup>');
280
+ el.useMarkup = true;
281
+ const [, sub, , sup] = textNodes(el);
282
+ // THE ASSERTION THAT CATCHES THE FLEX ITEM. `vertical-align` does not apply to
283
+ // a flex item: as items these two would each be laid out by the container's
284
+ // `align-items: center`, landing at the SAME height, and a formula would read
285
+ // `x2 and y3` flat. As inline text `<sup>` is raised and `<sub>` lowered, so
286
+ // the one sits strictly above the other.
287
+ expect(runRect(sup!).top).toBeLessThan(runRect(sub!).top);
288
+ // Both still on the label's own one line: neither escaped the line box.
289
+ const whole = textRect(el);
290
+ expect(runRect(sub!).bottom).toBeLessThanOrEqual(Math.ceil(whole.bottom));
291
+ expect(runRect(sup!).top).toBeGreaterThanOrEqual(Math.floor(whole.top));
292
+ host.remove();
293
+ });
294
+
295
+ await it('still offsets the whole line by xalign', () => {
296
+ const { el, host } = mount('<b>Hi</b> there');
297
+ el.useMarkup = true;
298
+ host.style.width = '400px';
299
+ // The box sizes to its text (`flex-basis: auto` over `display: block`), which
300
+ // is what leaves the two spacer pseudo-elements free space to split — the
301
+ // whole of `xalign`. A box stretched to the container would have none, and the
302
+ // text would sit at the left whatever `xalign` said.
303
+ el.xalign = 0;
304
+ const left = textRect(el);
305
+ el.xalign = 1;
306
+ const right = textRect(el);
307
+ expect(Math.round(left.left)).toBeLessThan(Math.round(right.left));
308
+ expect(Math.round(right.width)).toBe(Math.round(left.width));
309
+ host.remove();
310
+ });
311
+ });
312
+
313
+ await describe('<gtk-label> against LABEL_MARKUP_LINK_VECTORS — the anchors', async () => {
314
+ for (const vector of LABEL_MARKUP_LINK_VECTORS) {
315
+ await it(vector.rule, () => {
316
+ const { el, host } = mount(vector.markup);
317
+ el.useMarkup = true;
318
+
319
+ // `null` is markup that does not parse: no links, and so neither of the
320
+ // two things `gtk_label_set_markup` puts on the widget for having them.
321
+ const links = vector.links ?? [];
322
+ const anchors = labelMarkupAnchors(el);
323
+ expect(anchors.length).toBe(links.length);
324
+ expect(el.classList.contains('link')).toBe(vector.cssClass);
325
+ expect(el.hasAttribute('has-tooltip')).toBe(vector.hasTooltip);
326
+
327
+ for (const [index, link] of links.entries()) {
328
+ const anchor = anchors[index]!;
329
+ // The anchor is a REAL link: `href` is set, which is what makes it one
330
+ // to the keyboard, to the accessibility tree and to "copy link
331
+ // address". `getAttribute`, not `.href`, because the property
332
+ // resolves a relative URI against the document.
333
+ expect(anchor.getAttribute('href')).toBe(link.href);
334
+ expect(labelMarkupAnchorIndex(anchor)).toBe(index);
335
+ expect(anchor.classList.contains('link')).toBe(true);
336
+ // GTK's `link` class and `<a class>`'s value sit on the same node.
337
+ if (link.cssClass !== null) expect(anchor.classList.contains(link.cssClass)).toBe(true);
338
+ // An EMPTY `title` attribute is a tooltip in some browsers, so a link
339
+ // without one must not carry the attribute at all.
340
+ expect(anchor.hasAttribute('title')).toBe(link.title !== null);
341
+ if (link.title !== null) expect(anchor.title).toBe(link.title);
342
+ // THE OFFSETS REACHED THE DOM: the characters the row's range cuts out
343
+ // of the display text are the characters under this anchor.
344
+ expect(anchor.textContent).toBe(anchorText(el.textContent ?? '', links, index));
345
+ }
346
+ host.remove();
347
+ });
348
+ }
349
+ });
350
+
351
+ await describe('<gtk-label> activate-link', async () => {
352
+ const markup = 'Read the <a href="https://gtk.org">docs</a> first';
353
+
354
+ await it('cancels the browser navigation and launches the uri instead', () => {
355
+ const { el, host, opened } = mount(markup);
356
+ el.useMarkup = true;
357
+ const details = record(el, 'activate-link');
358
+ const anchor = labelMarkupAnchors(el)[0]!;
359
+
360
+ // `preventDefault`ed EVERY time: the anchor has a real `href`, so a browser
361
+ // would navigate this window, and GTK never does — `gtk_label_activate_link`
362
+ // hands the URI to the desktop and leaves the window standing.
363
+ expect(click(anchor).defaultPrevented).toBe(true);
364
+ expect(opened).toStrictEqual(['https://gtk.org']);
365
+ expect(details).toStrictEqual([{ uri: 'https://gtk.org' }]);
366
+ expect(anchor.classList.contains('visited')).toBe(true);
367
+ host.remove();
368
+ });
369
+
370
+ await it('a handler that handles it stops the launch — and still visits the link', () => {
371
+ const { el, host, opened } = mount(markup);
372
+ el.useMarkup = true;
373
+ // `preventDefault` is a handler returning TRUE under the boolean accumulator,
374
+ // so the class handler never runs and nothing is launched.
375
+ el.addEventListener('activate-link', (event) => event.preventDefault());
376
+ const anchor = labelMarkupAnchors(el)[0]!;
377
+ click(anchor);
378
+ expect(opened).toStrictEqual([]);
379
+ // VISITED ALL THE SAME, and this is where the label and `<gtk-link-button>`
380
+ // part ways. `emit_activate_link` (gtklabel.c:2053) reads the ACCUMULATED
381
+ // result — `if (handled && !link->visited)` — so a handler that followed the
382
+ // link its own way has followed it. `GtkLinkButton` instead sets `visited`
383
+ // inside its own default handler (gtklinkbutton.c:521), which a cancelling
384
+ // handler skips. Two widgets, two places, and only the C says which is which.
385
+ expect(anchor.classList.contains('visited')).toBe(true);
386
+ host.remove();
387
+ });
388
+
389
+ await it('visits every link to the same uri, and leaves the others alone', () => {
390
+ const { el, host, opened } = mount('<a href="a">one</a> <a href="b">two</a> <a href="a">three</a>');
391
+ el.useMarkup = true;
392
+ const anchors = labelMarkupAnchors(el);
393
+ click(anchors[0]!);
394
+ // BY URI, not by index (gtklabel.c:3635-3647): two links to the same place are
395
+ // one visited state, so following either marks both.
396
+ expect(anchors[0]!.classList.contains('visited')).toBe(true);
397
+ expect(anchors[2]!.classList.contains('visited')).toBe(true);
398
+ expect(anchors[1]!.classList.contains('visited')).toBe(false);
399
+ expect(opened).toStrictEqual(['a']);
400
+ host.remove();
401
+ });
402
+
403
+ await it('carries visited across a re-render, by uri', () => {
404
+ const { el, host } = mount(markup);
405
+ el.useMarkup = true;
406
+ click(labelMarkupAnchors(el)[0]!);
407
+ // Rewriting the text AROUND a link has not un-followed it.
408
+ el.label = 'Now read the <a href="https://gtk.org">manual</a>';
409
+ expect(labelMarkupAnchors(el)[0]!.classList.contains('visited')).toBe(true);
410
+ host.remove();
411
+ });
412
+
413
+ await it('ignores a click that is not on a link', () => {
414
+ const { el, host, opened } = mount(markup);
415
+ el.useMarkup = true;
416
+ const details = record(el, 'activate-link');
417
+ expect(click(el).defaultPrevented).toBe(false);
418
+ expect(opened).toStrictEqual([]);
419
+ expect(details).toStrictEqual([]);
420
+ host.remove();
421
+ });
422
+
423
+ await it('keeps the run elements across a render that did not touch the text', () => {
424
+ const { el, host } = mount(markup);
425
+ el.useMarkup = true;
426
+ const anchor = labelMarkupAnchors(el)[0]!;
427
+ // `_render` runs on EVERY observed attribute, and rebuilding the runs for one
428
+ // that never touched the text would drop a selection and the focus inside a
429
+ // link — so the nodes have to be the SAME nodes.
430
+ el.xalign = 1;
431
+ expect(labelMarkupAnchors(el)[0]).toBe(anchor);
432
+ host.remove();
433
+ });
434
+
435
+ await it('declares activate-link, so connect() takes it', () => {
436
+ const { el, host } = mount(markup);
437
+ el.useMarkup = true;
438
+ const connectable = el as unknown as { connect(signal: string, handler: () => void): number };
439
+ expect(() => connectable.connect('activate-link', () => {})).not.toThrow();
440
+ host.remove();
441
+ });
442
+ });
443
+
444
+ await describe('<gtk-label> gives its anchors the link treatment', async () => {
445
+ const markup = 'Read the <a href="https://gtk.org">docs</a> first';
446
+
447
+ await it('underlines the anchor and recolours it, where the text around it is plain', () => {
448
+ const { el, host } = mount(markup);
449
+ el.useMarkup = true;
450
+ const anchor = labelMarkupAnchors(el)[0]!;
451
+ // `_link.scss`'s `link`-node rule, upstream's `_links.scss:1-5`. The label is
452
+ // NOT a link even though it carries the `.link` style class (gtklabel.c:3896),
453
+ // so the treatment has to stop at the anchor.
454
+ expect(getComputedStyle(anchor).textDecorationLine).toBe('underline');
455
+ expect(getComputedStyle(el).textDecorationLine).toBe('none');
456
+ expect(getComputedStyle(anchor).color === getComputedStyle(el).color).toBe(false);
457
+ host.remove();
458
+ });
459
+
460
+ await it('keeps the label text weight inside the link', () => {
461
+ const { el, host } = mount('<b>bold <a href="https://gtk.org">link</a></b>');
462
+ el.useMarkup = true;
463
+ const anchor = labelMarkupAnchors(el)[0]!;
464
+ // `font-weight: inherit` (`_links.scss:4`): a link inside bold markup stays
465
+ // bold, where a UA's own `<a>` styling would not care.
466
+ expect(getComputedStyle(anchor).fontWeight).toBe(getComputedStyle(anchor.parentElement!).fontWeight);
467
+ host.remove();
468
+ });
469
+
470
+ await it('draws a followed link in a different colour', () => {
471
+ const { el, host } = mount(markup);
472
+ el.useMarkup = true;
473
+ const fresh = getComputedStyle(labelMarkupAnchors(el)[0]!).color;
474
+ click(labelMarkupAnchors(el)[0]!);
475
+ // `.visited`, the class `_paintVisited` writes — NOT the `:visited`
476
+ // pseudo-class, which is the user's browsing history and would colour a link
477
+ // in a label that was never clicked.
478
+ expect(getComputedStyle(labelMarkupAnchors(el)[0]!).color === fresh).toBe(false);
479
+ host.remove();
480
+ });
481
+ });
482
+
63
483
  await describe('<gtk-label> against LABEL_XALIGN_VECTORS and LABEL_JUSTIFY_VECTORS', async () => {
64
484
  for (const vector of LABEL_XALIGN_VECTORS) {
65
485
  await it(`xalign: ${vector.rule}`, () => {
@@ -206,6 +206,14 @@ export const GtkSwitchTest = async () => {
206
206
  parts(sw).slider.click();
207
207
  expect(row.expanded).toBe(false);
208
208
 
209
+ // The click turned the row's expansion OFF, and a disabled row refuses to
210
+ // disclose (libadwaita's `enable-expansion` gate); an enabled one discloses.
211
+ expect(row.enableExpansion).toBe(false);
212
+ (row.querySelector('.adw-expander-row-header') as HTMLElement).click();
213
+ expect(row.expanded).toBe(false);
214
+
215
+ row.enableExpansion = true;
216
+ row.expanded = false;
209
217
  (row.querySelector('.adw-expander-row-header') as HTMLElement).click();
210
218
  expect(row.expanded).toBe(true);
211
219
  host.remove();