tosijs-floorplan 0.3.0 → 0.5.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/README.md +86 -11
- package/dist/.metadata_never_index +0 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +97 -33
- package/package.json +3 -1
- package/src/index.ts +302 -71
- package/src/schematic.test.ts +384 -5
package/src/schematic.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe } from 'bun:test'
|
|
2
|
-
import { schematicSVG, schematic } from './index'
|
|
2
|
+
import { schematicSVG, schematic, isInteractive, targetSizeFinding } from './index'
|
|
3
3
|
import { SchematicDescription } from './index'
|
|
4
4
|
|
|
5
5
|
const description: SchematicDescription = {
|
|
@@ -556,6 +556,164 @@ describe('decorate — the plugin seam', () => {
|
|
|
556
556
|
})
|
|
557
557
|
})
|
|
558
558
|
|
|
559
|
+
describe('the 1.11.0 adoption feedback (0.5.0 — #7/#8/#9/#10/#12/#15)', () => {
|
|
560
|
+
const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
|
|
561
|
+
|
|
562
|
+
test('evidence beats container role: a list-bound <select> that IS the control is an affordance (#7)', () => {
|
|
563
|
+
const select = {
|
|
564
|
+
tag: 'select',
|
|
565
|
+
list: { path: 'app.options', idPath: 'id' },
|
|
566
|
+
value: 'b ⟷ app.choice',
|
|
567
|
+
bounds: { x: 10, y: 10, width: 20, height: 20 },
|
|
568
|
+
}
|
|
569
|
+
expect(isInteractive(select)).toBe(true)
|
|
570
|
+
expect(targetSizeFinding(select)).toBe('20×20 — below 24×24 (WCAG 2.5.8)')
|
|
571
|
+
const { svg } = schematic({ wiring: [select] })
|
|
572
|
+
expect(svg).not.toContain('stroke-dasharray') // control, not ground
|
|
573
|
+
// …while a plain list container without evidence of its own stays ground
|
|
574
|
+
expect(isInteractive({ tag: 'ul', list: { path: 'a.items' } })).toBe(false)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test('supersession cannot be bought with a kind that merely mentions target (#8)', () => {
|
|
578
|
+
const tiny = { tag: 'button', on: { click: 'ƒ' }, bounds: { x: 0, y: 0, width: 10, height: 10 } }
|
|
579
|
+
const finding = '10×10 — below 24×24 (WCAG 2.5.8)'
|
|
580
|
+
// claims to BE a target-size finding: supersedes (when honoured)
|
|
581
|
+
for (const kind of ['target', 'target-size', 'smallTarget', 'TARGET_SIZE']) {
|
|
582
|
+
expect(targetSizeFinding({ ...tiny, flags: [{ kind, label: 'x' }] }, 24, { honorProducerFlags: true })).toBeNull()
|
|
583
|
+
}
|
|
584
|
+
// merely mentions the word: does not
|
|
585
|
+
for (const kind of ['target-ok', 'TARGETS ARE FINE', 'retargeting']) {
|
|
586
|
+
expect(targetSizeFinding({ ...tiny, flags: [{ kind, label: 'x' }] }, 24, { honorProducerFlags: true })).toBe(finding)
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
test('hidden is not small: zero-size records are not undersized (#9)', () => {
|
|
591
|
+
expect(targetSizeFinding({ tag: 'button', on: { click: 'ƒ' }, bounds: { x: 0, y: 0, width: 0, height: 0 } })).toBeNull()
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
test('capability evidence suppresses the blind-map note: a read-only dashboard is not a blind map (#10)', () => {
|
|
595
|
+
const dashboard = schematic({
|
|
596
|
+
wiring: [
|
|
597
|
+
{ tag: 'span', text: '21°C ⟵ dash.temp', bounds: at(10, 10) },
|
|
598
|
+
{ tag: 'span', text: '40% ⟵ dash.humidity', bounds: at(10, 40) },
|
|
599
|
+
],
|
|
600
|
+
})
|
|
601
|
+
// display bindings prove the producer can see wiring — nothing here is
|
|
602
|
+
// actionable, and that IS established
|
|
603
|
+
expect(dashboard.note).toBeUndefined()
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
test('a flags entry without kind neither throws nor colors outside the lines (#12)', () => {
|
|
607
|
+
const record = {
|
|
608
|
+
tag: 'button',
|
|
609
|
+
on: { click: 'ƒ' },
|
|
610
|
+
label: 'Go',
|
|
611
|
+
flags: [{ label: 'x' }] as any,
|
|
612
|
+
bounds: { x: 0, y: 0, width: 10, height: 10 },
|
|
613
|
+
}
|
|
614
|
+
expect(targetSizeFinding(record, 24, { honorProducerFlags: true })).toBe('10×10 — below 24×24 (WCAG 2.5.8)')
|
|
615
|
+
const { svg } = schematic({ wiring: [{ ...record, bounds: at(10, 10) }] })
|
|
616
|
+
expect(svg).toContain('data-flag=""')
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('redaction is FAIL-CLOSED: facts a producer bug left in a secret record never reach drawing or legend (review G1)', () => {
|
|
620
|
+
const { svg, legend } = schematic({
|
|
621
|
+
wiring: [
|
|
622
|
+
{
|
|
623
|
+
tag: 'a',
|
|
624
|
+
secret: true,
|
|
625
|
+
// the failure this guards: marked secret, but the withholding
|
|
626
|
+
// upstream didn't happen — the renderer must not republish
|
|
627
|
+
href: 'https://app.example/magic?token=SECRET123',
|
|
628
|
+
label: 'reset for ada@example.com',
|
|
629
|
+
text: 'leaky text',
|
|
630
|
+
value: 'SECRET123',
|
|
631
|
+
on: { click: 'ƒ' },
|
|
632
|
+
bounds: at(10, 10),
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
})
|
|
636
|
+
const everything = svg + JSON.stringify(legend)
|
|
637
|
+
expect(everything).not.toContain('SECRET123')
|
|
638
|
+
expect(everything).not.toContain('ada@example.com')
|
|
639
|
+
expect(everything).not.toContain('leaky text')
|
|
640
|
+
expect(svg).toContain('<a> [withheld]')
|
|
641
|
+
expect(legend[0].redacted).toBe(true)
|
|
642
|
+
expect(legend[0].href).toBeUndefined()
|
|
643
|
+
expect(legend[0].value).toBeUndefined()
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
test('withheld PIXELS never draw: image joins the fail-closed list (round-2 B1)', () => {
|
|
647
|
+
const px = 'data:image/gif;base64,SECRETPIXELS0000000000000000000000000000000='
|
|
648
|
+
const { svg, legend } = schematic({
|
|
649
|
+
wiring: [{ tag: 'canvas', secret: true, image: px, label: 'private chart', text: 'leaky text', bounds: at(10, 10, 200, 100) }],
|
|
650
|
+
})
|
|
651
|
+
const everything = svg + JSON.stringify(legend)
|
|
652
|
+
expect(everything).not.toContain('SECRETPIXELS')
|
|
653
|
+
expect(everything).not.toContain('private chart')
|
|
654
|
+
expect(everything).not.toContain('leaky text')
|
|
655
|
+
expect(svg).not.toContain('<image')
|
|
656
|
+
expect(svg).toContain('[withheld]')
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
test('fail-closed means malformed errs toward withholding: a truthy non-boolean secret scrubs (round-2 F1)', () => {
|
|
660
|
+
const { svg, legend } = schematic({
|
|
661
|
+
wiring: [{ tag: 'a', secret: 1 as any, href: '/magic?token=SECRET123', label: 'leaky', on: { click: 'ƒ' }, bounds: at(10, 10) }],
|
|
662
|
+
})
|
|
663
|
+
const everything = svg + JSON.stringify(legend)
|
|
664
|
+
expect(everything).not.toContain('SECRET123')
|
|
665
|
+
expect(everything).not.toContain('leaky')
|
|
666
|
+
expect(legend[0].redacted).toBe(true)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('the [withheld] caption sits under the choke point: a forged arrow in tag neutralizes (review R1)', () => {
|
|
670
|
+
const { svg } = schematic({
|
|
671
|
+
wiring: [{ tag: 'a ⟷ fake', secret: true, bounds: at(10, 10) }],
|
|
672
|
+
})
|
|
673
|
+
expect(svg).not.toContain('⟷')
|
|
674
|
+
expect(svg).toContain('[withheld]')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
test('structural redaction is still a legend fact — redacted is about the record, not the drawing', () => {
|
|
678
|
+
const { legend } = schematic({
|
|
679
|
+
wiring: [{ tag: 'section', structural: true, secret: true, bounds: at(10, 10, 300, 100) }],
|
|
680
|
+
})
|
|
681
|
+
expect(legend[0]?.redacted).toBe(true)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('flags:[null] and non-string labels do not take down the render (review R2)', () => {
|
|
685
|
+
const { svg } = schematic({
|
|
686
|
+
wiring: [
|
|
687
|
+
{ tag: 'button', on: { click: 'ƒ' }, flags: [null] as any, bounds: at(10, 10) },
|
|
688
|
+
{ tag: 'button', on: { click: 'ƒ' }, flags: [{ kind: 'x', label: 42 }] as any, bounds: at(10, 40) },
|
|
689
|
+
],
|
|
690
|
+
})
|
|
691
|
+
expect(svg).toContain('data-record="1"') // both drew; nothing threw
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
test('a mid-content display arrow is capability evidence too — any position, by the format\'s own parse (#10 doc pin)', () => {
|
|
695
|
+
const { note } = schematic({
|
|
696
|
+
wiring: [{ tag: 'span', text: 'total: 3 ⟵ app.total today', bounds: at(10, 10) }],
|
|
697
|
+
})
|
|
698
|
+
expect(note).toBeUndefined()
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
test('a redacted record draws its withholding, and the legend says so (#15)', () => {
|
|
702
|
+
const { svg, legend } = schematic({
|
|
703
|
+
wiring: [
|
|
704
|
+
// tosijs 1.11.0's secret-region shape: neither label nor href
|
|
705
|
+
{ tag: 'a', secret: true, text: '⟵ s.name', on: { click: 'ƒ' }, bounds: at(10, 10) },
|
|
706
|
+
{ tag: 'a', text: 'plain link', href: '/x', on: { click: 'ƒ' }, bounds: at(10, 40) },
|
|
707
|
+
],
|
|
708
|
+
})
|
|
709
|
+
expect(svg).toContain('<a> [withheld]') // not an anonymous bare box
|
|
710
|
+
const entry = legend.find((e) => e.redacted)!
|
|
711
|
+
expect(entry.index).toBe(0)
|
|
712
|
+
expect(entry.href).toBeUndefined() // withheld, and the legend SAYS withheld
|
|
713
|
+
expect(legend.find((e) => e.index === 1)!.redacted).toBeUndefined()
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
|
|
559
717
|
describe('producer compatibility — interface-typed maps assign without casts', () => {
|
|
560
718
|
test("a describe()-shaped interface flows straight in", () => {
|
|
561
719
|
// mirrors tosijs's AgentDescription: an INTERFACE (no implicit index
|
|
@@ -690,18 +848,21 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
|
|
|
690
848
|
expect(legend[0].value).toBe('3') // provenance stripped
|
|
691
849
|
})
|
|
692
850
|
|
|
693
|
-
test('inline exception: a text-sized link is not flagged undersized;
|
|
851
|
+
test('inline exception: a text-sized link is not flagged undersized; icon links are — even labelled ones (#2)', () => {
|
|
694
852
|
const { legend } = schematic({
|
|
695
853
|
wiring: [
|
|
696
|
-
// a link in prose: sized by its text
|
|
854
|
+
// a link in prose: sized by its text (wider than tall) — exempt
|
|
697
855
|
{ tag: 'a', text: 'terms', href: '/terms', on: { click: 'ƒ' }, bounds: at(10, 10, 34, 16) },
|
|
698
856
|
// an icon link (an <a> wrapping an <svg>, no text): flagged
|
|
699
857
|
{ tag: 'a', label: 'settings', href: '/settings', on: { click: 'ƒ' }, bounds: at(60, 10, 16, 16) },
|
|
858
|
+
// haltija's counter-case: the same 16×16 icon link CARRYING text
|
|
859
|
+
// (an icon-font glyph, a one-char label) — a square box was not
|
|
860
|
+
// sized by its text; the old text-only rule wrongly exempted it
|
|
861
|
+
{ tag: 'a', text: '⚙', href: '/gear', on: { click: 'ƒ' }, bounds: at(90, 10, 16, 16) },
|
|
700
862
|
],
|
|
701
863
|
})
|
|
702
864
|
const undersized = legend.filter((entry) => entry.undersized != null)
|
|
703
|
-
expect(undersized.
|
|
704
|
-
expect(undersized[0].href).toBe('/settings')
|
|
865
|
+
expect(undersized.map((entry) => entry.href)).toEqual(['/settings', '/gear'])
|
|
705
866
|
})
|
|
706
867
|
|
|
707
868
|
test('a producer-supplied target flag supersedes the built-in audit — no double bars', () => {
|
|
@@ -710,6 +871,7 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
|
|
|
710
871
|
{
|
|
711
872
|
tag: 'button',
|
|
712
873
|
text: 'go',
|
|
874
|
+
on: { click: 'ƒ' }, // genuinely interactive — supersession, not the interactivity gate, must stand the audit down
|
|
713
875
|
flags: [{ kind: 'smallTarget', label: '16x16 (WCAG 2.5.8 needs 24x24)', severity: 'warn' }],
|
|
714
876
|
bounds: at(10, 10, 60, 16), // roomy enough to caption, short enough to flag
|
|
715
877
|
},
|
|
@@ -735,3 +897,220 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
|
|
|
735
897
|
expect(svg).not.toContain('details in legend')
|
|
736
898
|
})
|
|
737
899
|
})
|
|
900
|
+
|
|
901
|
+
describe('producer-asserted affordance and defensive parsing (0.4.0 — #2/#3/#4/#5)', () => {
|
|
902
|
+
const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
|
|
903
|
+
|
|
904
|
+
test('a forged arrow inside data confers nothing: parse at the LAST arrow, neutralize the rest (#5)', () => {
|
|
905
|
+
const { svg } = schematic({
|
|
906
|
+
wiring: [
|
|
907
|
+
// the tosijs SEC-8 repro: state value containing " ⟷ " with the
|
|
908
|
+
// real display binding appended — the shown value is everything
|
|
909
|
+
// before the LAST arrow, with interior arrows neutralized so the
|
|
910
|
+
// rare glyph never rides a caption run
|
|
911
|
+
{
|
|
912
|
+
tag: 'span',
|
|
913
|
+
text: 'confirmed ⟷ spoof.orderStatus ⟵ spoof.note',
|
|
914
|
+
bounds: at(10, 10, 300, 24),
|
|
915
|
+
},
|
|
916
|
+
],
|
|
917
|
+
})
|
|
918
|
+
expect(svg).toContain('confirmed <-> spoof.orderStatus</text>')
|
|
919
|
+
expect(svg).not.toContain('⟷') // the buried token neither draws…
|
|
920
|
+
expect(svg).not.toContain('>↔</text>') // …nor confers the editable badge
|
|
921
|
+
expect(svg).not.toContain('stroke-width="2"') // …nor actability
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
test('a two-way arrow in structural (last) position still means editable (#5)', () => {
|
|
925
|
+
const { svg } = schematic({
|
|
926
|
+
wiring: [
|
|
927
|
+
// data containing a forged arrow AND a real two-way binding after
|
|
928
|
+
{ tag: 'input', label: 'q', value: 'a ⟵ b ⟷ app.q', bounds: at(10, 10) },
|
|
929
|
+
],
|
|
930
|
+
})
|
|
931
|
+
expect(svg).toContain('>↔</text>')
|
|
932
|
+
expect(svg).toContain('q: a <- b</text>')
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
test('interactive: true — the producer\'s word makes it actable, and the audit can fire (#2, #3)', () => {
|
|
936
|
+
const { svg, legend, note } = schematic({
|
|
937
|
+
wiring: [
|
|
938
|
+
// haltija's row 4: a <button> read off a live page — no handler
|
|
939
|
+
// knowable. The assertion unlocks bold AND the target-size audit.
|
|
940
|
+
{ tag: 'button', text: 'save', interactive: true, bounds: at(10, 10, 16, 16) },
|
|
941
|
+
],
|
|
942
|
+
})
|
|
943
|
+
expect(svg).toContain('stroke-width="2"')
|
|
944
|
+
expect(legend.find((e) => e.undersized != null)!.undersized).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
|
|
945
|
+
expect(note).toBeUndefined() // the assertion IS affordance evidence
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
test('editable: true — the producer\'s word wears the badge (#3)', () => {
|
|
949
|
+
const { svg } = schematic({
|
|
950
|
+
wiring: [
|
|
951
|
+
{ tag: 'div', role: 'textbox', editable: true, bounds: at(10, 10, 200, 30) },
|
|
952
|
+
],
|
|
953
|
+
})
|
|
954
|
+
expect(svg).toContain('>↔</text>')
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
test('a destination is an affordance: href alone draws bold, and an icon link alone gets audited (#3, #4)', () => {
|
|
958
|
+
const { svg, legend } = schematic({
|
|
959
|
+
wiring: [
|
|
960
|
+
// a plain link, no handler introspectable — it still navigates
|
|
961
|
+
{ tag: 'a', text: 'read the docs', href: '/docs', bounds: at(10, 10, 120, 20) },
|
|
962
|
+
// a nameless 16×16 icon link with no handler: the case tosijs's
|
|
963
|
+
// audit reported and 0.3.0 drew nothing for (#4's second row)
|
|
964
|
+
{ tag: 'a', href: '/next', bounds: at(150, 10, 16, 16) },
|
|
965
|
+
],
|
|
966
|
+
})
|
|
967
|
+
expect(svg).toContain('stroke-width="2"')
|
|
968
|
+
expect(legend.find((e) => e.href === '/next')!.undersized).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
test('no affordance evidence anywhere: the result says so instead of silently claiming "nothing actionable" (#3)', () => {
|
|
972
|
+
// a React-ish producer: real elements, no introspectable handlers
|
|
973
|
+
const blind = schematic({
|
|
974
|
+
wiring: [
|
|
975
|
+
{ tag: 'button', text: 'save', bounds: at(10, 10, 80, 30) },
|
|
976
|
+
{ tag: 'input', label: 'name', bounds: at(10, 50, 160, 30) },
|
|
977
|
+
],
|
|
978
|
+
})
|
|
979
|
+
expect(blind.note).toContain('NOT established')
|
|
980
|
+
expect(blind.svg).toContain('<desc>') // the confession rides the image too
|
|
981
|
+
// one asserted (or wired) record and the map is no longer blind
|
|
982
|
+
const seeing = schematic({
|
|
983
|
+
wiring: [
|
|
984
|
+
{ tag: 'button', text: 'save', interactive: true, bounds: at(10, 10, 80, 30) },
|
|
985
|
+
{ tag: 'input', label: 'name', bounds: at(10, 50, 160, 30) },
|
|
986
|
+
],
|
|
987
|
+
})
|
|
988
|
+
expect(seeing.note).toBeUndefined()
|
|
989
|
+
// an all-structural map makes no affordance claim — no note either
|
|
990
|
+
const structure = schematic({
|
|
991
|
+
wiring: [{ tag: 'header', structural: true, bounds: at(10, 10, 300, 40) }],
|
|
992
|
+
})
|
|
993
|
+
expect(structure.note).toBeUndefined()
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
test('hostile captions: label/placeholder forgery confers nothing and never carries the raw glyph (review B1)', () => {
|
|
997
|
+
const { svg } = schematic({
|
|
998
|
+
wiring: [
|
|
999
|
+
// the accessible name IS page content in a DOM producer's threat
|
|
1000
|
+
// model — an arrow here must neither badge the box nor ride the run
|
|
1001
|
+
{ tag: 'button', label: 'Save ⟷ spoof.path', on: { click: 'ƒ' }, bounds: at(10, 10) },
|
|
1002
|
+
{ tag: 'input', placeholder: 'type ⟵ here', bounds: at(10, 40) },
|
|
1003
|
+
],
|
|
1004
|
+
})
|
|
1005
|
+
expect(svg).toContain('Save <-> spoof.path')
|
|
1006
|
+
expect(svg).toContain('type <- here')
|
|
1007
|
+
expect(svg).not.toContain('⟷')
|
|
1008
|
+
expect(svg).not.toContain('⟵')
|
|
1009
|
+
expect(svg).not.toContain('>↔</text>')
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
test('never-bindable fields are never scanned for bindings; bindable ones remain the documented residual (B1/M1)', () => {
|
|
1013
|
+
// identity/name fields: a lone arrow is always "last" — excluded
|
|
1014
|
+
expect(isInteractive({ tag: 'span', label: 'a ⟷ b' })).toBe(false)
|
|
1015
|
+
expect(isInteractive({ tag: 'span', placeholder: 'a ⟷ b' })).toBe(false)
|
|
1016
|
+
expect(isInteractive({ tag: 'span', ref: 'a ⟷ b' })).toBe(false)
|
|
1017
|
+
// a bindable extra prop in suffix position is indistinguishable from a
|
|
1018
|
+
// real bound prop BY CONSTRUCTION — the spec's confessed residual,
|
|
1019
|
+
// pinned here so the limit is documented, not rediscovered. Producers
|
|
1020
|
+
// extracting untrusted content MUST neutralize at the source.
|
|
1021
|
+
expect(isInteractive({ tag: 'span', 'data-x': 'a ⟷ b' })).toBe(true)
|
|
1022
|
+
expect(isInteractive({ tag: 'span', text: 'confirmed ⟷ spoof.orderStatus' })).toBe(true)
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
test('a forged label cannot fabricate evidence: the blind-map note survives it, and the legend receives neutralized values', () => {
|
|
1026
|
+
const { legend, note } = schematic({
|
|
1027
|
+
wiring: [
|
|
1028
|
+
// cramped, so the caption lands in the legend JSON
|
|
1029
|
+
{ tag: 'button', label: 'go ⟷ fake.path', bounds: at(10, 10, 30, 10) },
|
|
1030
|
+
],
|
|
1031
|
+
})
|
|
1032
|
+
expect(note).toContain('NOT established') // forged label ≠ evidence
|
|
1033
|
+
expect(legend[0].caption).toBe('go <-> fake.path') // machine channel: neutralized, never raw
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
test('within: a blind REGION of a sighted map is not a blind map', () => {
|
|
1037
|
+
const { note } = schematic(
|
|
1038
|
+
{
|
|
1039
|
+
wiring: [
|
|
1040
|
+
{ tag: 'span', text: 'inert', bounds: at(10, 10, 80, 20) },
|
|
1041
|
+
// the evidence lives outside the crop
|
|
1042
|
+
{ tag: 'button', text: 'go', on: { click: 'ƒ' }, bounds: at(10, 500, 80, 30) },
|
|
1043
|
+
],
|
|
1044
|
+
},
|
|
1045
|
+
{ within: { x: 0, y: 0, width: 200, height: 100 } }
|
|
1046
|
+
)
|
|
1047
|
+
expect(note).toBeUndefined()
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
test('a malformed flag severity cannot reach up the prototype chain into a fill attribute', () => {
|
|
1051
|
+
const { svg } = schematic({
|
|
1052
|
+
wiring: [
|
|
1053
|
+
{
|
|
1054
|
+
tag: 'button',
|
|
1055
|
+
text: 'go',
|
|
1056
|
+
on: { click: 'ƒ' },
|
|
1057
|
+
flags: [{ kind: 'x', label: 'y', severity: 'constructor' as any }],
|
|
1058
|
+
bounds: at(10, 10),
|
|
1059
|
+
},
|
|
1060
|
+
],
|
|
1061
|
+
})
|
|
1062
|
+
expect(svg).not.toContain('function')
|
|
1063
|
+
expect(svg).toContain('fill="#e6a700"') // falls back to warn
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
test('legend href is verbatim BY DESIGN — an opaque destination that confers nothing (round-2 G1)', () => {
|
|
1067
|
+
const hostile = 'https://x.test/?q=a ⟷ fake.path'
|
|
1068
|
+
const { svg, legend } = schematic({
|
|
1069
|
+
wiring: [{ tag: 'a', href: hostile, on: { click: 'ƒ' }, bounds: at(10, 10) }],
|
|
1070
|
+
})
|
|
1071
|
+
// a URL's bytes ARE the destination — the legend must not rewrite them
|
|
1072
|
+
expect(legend[0].href).toBe(hostile)
|
|
1073
|
+
// …but the DRAWN side (caption falls back to href here) never carries
|
|
1074
|
+
// the raw glyph, and the arrow inside the URL confers nothing
|
|
1075
|
+
expect(svg).not.toContain('⟷')
|
|
1076
|
+
expect(svg).not.toContain('>↔</text>')
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
test('a drawn flag label neutralizes; the legend copy of flags is verbatim (spec)', () => {
|
|
1080
|
+
const flags = [{ kind: 'contrast', label: '2:1 ⟷ x', severity: 'error' as const }]
|
|
1081
|
+
const { svg } = schematic({
|
|
1082
|
+
wiring: [{ tag: 'button', text: 'go', on: { click: 'ƒ' }, flags, bounds: at(10, 10) }],
|
|
1083
|
+
})
|
|
1084
|
+
expect(svg).toContain('2:1 <-> x') // the run is display text — neutralized
|
|
1085
|
+
expect(svg).not.toContain('⟷')
|
|
1086
|
+
const cramped = schematic({
|
|
1087
|
+
wiring: [{ tag: 'button', text: 'go', flags, interactive: true, bounds: at(10, 10, 30, 10) }],
|
|
1088
|
+
})
|
|
1089
|
+
expect(cramped.legend[0].flags![0].label).toBe('2:1 ⟷ x') // machine channel: as the producer computed it
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
test('the predicates are exported — one implementation for renderer and audits (#4)', () => {
|
|
1093
|
+
// "can I act here?": every kind of evidence, and ground never
|
|
1094
|
+
expect(isInteractive({ tag: 'button', on: { click: 'app.go' } })).toBe(true)
|
|
1095
|
+
expect(isInteractive({ tag: 'a', href: '/x' })).toBe(true)
|
|
1096
|
+
expect(isInteractive({ tag: 'button', interactive: true })).toBe(true)
|
|
1097
|
+
expect(isInteractive({ tag: 'div', editable: true })).toBe(true)
|
|
1098
|
+
expect(isInteractive({ tag: 'div', contentEditable: true })).toBe(true)
|
|
1099
|
+
expect(isInteractive({ tag: 'input', value: '3 ⟷ a.qty' })).toBe(true)
|
|
1100
|
+
expect(isInteractive({ tag: 'button' })).toBe(false)
|
|
1101
|
+
expect(isInteractive({ tag: 'button', on: { click: 'ƒ' }, interactive: false })).toBe(true) // false cannot veto evidence — only true is signal
|
|
1102
|
+
expect(isInteractive({ tag: 'span', text: 'x ⟷ y ⟵ a.b' })).toBe(false) // forged
|
|
1103
|
+
expect(isInteractive({ tag: 'ul', list: { path: 'a.items' } })).toBe(false) // ground
|
|
1104
|
+
expect(isInteractive({ tag: 'header', structural: true, on: { click: 'ƒ' } })).toBe(false)
|
|
1105
|
+
// the target-size rule, directly
|
|
1106
|
+
const icon = { tag: 'a', href: '/x', bounds: { x: 0, y: 0, width: 16, height: 16 } }
|
|
1107
|
+
expect(targetSizeFinding(icon)).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
|
|
1108
|
+
expect(targetSizeFinding(icon, 0)).toBeNull() // 0 disables
|
|
1109
|
+
expect(targetSizeFinding({ ...icon, text: 'terms', bounds: { x: 0, y: 0, width: 34, height: 16 } })).toBeNull() // text-sized
|
|
1110
|
+
// supersession is opt-in as of 0.5.0 (#8): the bare rule reports
|
|
1111
|
+
// geometry truth; only the renderer (no double bars) passes true
|
|
1112
|
+
expect(targetSizeFinding({ ...icon, flags: [{ kind: 'target', label: '16x16' }] })).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
|
|
1113
|
+
expect(targetSizeFinding({ ...icon, flags: [{ kind: 'target', label: '16x16' }] }, 24, { honorProducerFlags: true })).toBeNull()
|
|
1114
|
+
expect(targetSizeFinding({ tag: 'input', type: 'checkbox', value: 'x ⟷ a.on', bounds: { x: 0, y: 0, width: 13, height: 13 } })).toBeNull() // toggles exempt
|
|
1115
|
+
})
|
|
1116
|
+
})
|