spec-to-ui 0.1.0 → 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/LICENSE +2 -0
- package/README.ko.md +129 -0
- package/README.md +96 -29
- package/RELEASE.md +26 -0
- package/THIRD-PARTY.md +14 -0
- package/bin/cli.mjs +70 -91
- package/bundle-manifest.json +140 -0
- package/package.json +40 -8
- package/plugin.json +16 -0
- package/skills/design-taste-frontend/LICENSE +21 -0
- package/skills/design-taste-frontend/README.md +81 -0
- package/skills/design-taste-frontend/SKILL.md +546 -0
- package/skills/design-taste-frontend/assets/app.css +216 -0
- package/skills/design-taste-frontend/assets/base.css +105 -0
- package/skills/design-taste-frontend/assets/base.js +37 -0
- package/skills/design-taste-frontend/assets/charts.js +619 -0
- package/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/skills/design-taste-frontend/assets/scenes/stage.js +134 -0
- package/skills/design-taste-frontend/evals/evals.json +76 -0
- package/skills/design-taste-frontend/library/app.json +1327 -0
- package/skills/design-taste-frontend/library/directions.json +1078 -0
- package/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/skills/design-taste-frontend/library/motions.json +92 -0
- package/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/skills/design-taste-frontend/library/photos.json +7240 -0
- package/skills/design-taste-frontend/library/sections.json +4429 -0
- package/skills/design-taste-frontend/library/sites.json +8350 -0
- package/skills/design-taste-frontend/library/type.json +2024 -0
- package/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/skills/design-taste-frontend/references/colour.md +141 -0
- package/skills/design-taste-frontend/references/copy.md +177 -0
- package/skills/design-taste-frontend/references/critique.md +182 -0
- package/skills/design-taste-frontend/references/imagery.md +791 -0
- package/skills/design-taste-frontend/references/layout.md +292 -0
- package/skills/design-taste-frontend/references/motion.md +980 -0
- package/skills/design-taste-frontend/references/research.md +356 -0
- package/skills/design-taste-frontend/references/scenes.md +174 -0
- package/skills/design-taste-frontend/references/tells.md +115 -0
- package/skills/design-taste-frontend/references/type.md +170 -0
- package/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/skills/design-taste-frontend/scripts/inspect.mjs +308 -0
- package/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/skills/design-taste-frontend/scripts/lib/inspection-files.mjs +9 -0
- package/skills/design-taste-frontend/scripts/lib/probe.mjs +929 -0
- package/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/skills/spec-to-ui/SKILL.md +61 -0
- package/{skill → skills/spec-to-ui}/references/composition-and-validation.md +6 -0
- package/{skill → skills/spec-to-ui}/references/drafts-and-feedback.md +10 -0
- package/skills/spec-to-ui/references/requirements-supplement.md +21 -0
- package/{skill → skills/spec-to-ui}/references/review-and-handoff.md +9 -0
- package/skills/spec-to-ui/references/skill-composition.md +21 -0
- package/{skill → skills/spec-to-ui}/references/structure-and-decisions.md +4 -0
- package/skills/specframe/NOTICE.md +7 -0
- package/skills/specframe/SKILL.md +58 -0
- package/skills/specframe/agents/openai.yaml +3 -0
- package/skills/specframe/references/operating-contract.md +137 -0
- package/skills/specframe/references/visual.md +3 -0
- package/skills/specframe/references/web.md +15 -0
- package/skills/specframe/scripts/state.py +182 -0
- package/skills/ui-ux-pro-max/LICENSE +21 -0
- package/skills/ui-ux-pro-max/SKILL.md +218 -0
- package/skills/ui-ux-pro-max/data/app-interface.csv +33 -0
- package/skills/ui-ux-pro-max/data/catalog-summary.json +78 -0
- package/skills/ui-ux-pro-max/data/charts.csv +26 -0
- package/skills/ui-ux-pro-max/data/colors.csv +193 -0
- package/skills/ui-ux-pro-max/data/data-provenance.json +1348 -0
- package/skills/ui-ux-pro-max/data/google-font-licenses.json +20185 -0
- package/skills/ui-ux-pro-max/data/google-fonts.csv +1935 -0
- package/skills/ui-ux-pro-max/data/icons.csv +106 -0
- package/skills/ui-ux-pro-max/data/landing.csv +35 -0
- package/skills/ui-ux-pro-max/data/motion.csv +18 -0
- package/skills/ui-ux-pro-max/data/phosphor-icons-upstream.json +32582 -0
- package/skills/ui-ux-pro-max/data/products.csv +193 -0
- package/skills/ui-ux-pro-max/data/react-performance.csv +45 -0
- package/skills/ui-ux-pro-max/data/stacks/angular.csv +51 -0
- package/skills/ui-ux-pro-max/data/stacks/astro.csv +54 -0
- package/skills/ui-ux-pro-max/data/stacks/avalonia.csv +57 -0
- package/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +60 -0
- package/skills/ui-ux-pro-max/data/stacks/javafx.csv +76 -0
- package/skills/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
- package/skills/ui-ux-pro-max/data/stacks/laravel.csv +51 -0
- package/skills/ui-ux-pro-max/data/stacks/nextjs.csv +62 -0
- package/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +84 -0
- package/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +68 -0
- package/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/skills/ui-ux-pro-max/data/stacks/react.csv +67 -0
- package/skills/ui-ux-pro-max/data/stacks/shadcn.csv +69 -0
- package/skills/ui-ux-pro-max/data/stacks/svelte.csv +56 -0
- package/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/skills/ui-ux-pro-max/data/stacks/threejs.csv +54 -0
- package/skills/ui-ux-pro-max/data/stacks/uno.csv +60 -0
- package/skills/ui-ux-pro-max/data/stacks/uwp.csv +56 -0
- package/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/skills/ui-ux-pro-max/data/stacks/winui.csv +60 -0
- package/skills/ui-ux-pro-max/data/stacks/wpf.csv +57 -0
- package/skills/ui-ux-pro-max/data/styles.csv +89 -0
- package/skills/ui-ux-pro-max/data/typography.csv +75 -0
- package/skills/ui-ux-pro-max/data/ui-reasoning.csv +193 -0
- package/skills/ui-ux-pro-max/data/ux-guidelines.csv +120 -0
- package/skills/ui-ux-pro-max/references/pro-rules.md +117 -0
- package/skills/ui-ux-pro-max/references/quick-reference.md +256 -0
- package/skills/ui-ux-pro-max/scripts/core.py +993 -0
- package/skills/ui-ux-pro-max/scripts/design_system.py +1668 -0
- package/skills/ui-ux-pro-max/scripts/reasoning_contract.py +123 -0
- package/skills/ui-ux-pro-max/scripts/search.py +184 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-api.json +36 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-catalog.json +33 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-existing.csv +3 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-metadata.json +17 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-overrides.json +7 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/icons-curated.csv +4 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-core.json +23 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-package.json +13 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-react-exports.json +4 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-react-package.json +5 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-baseline.json +3366 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-cases.json +111 -0
- package/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-thresholds.json +100 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_catalog_refresh.py +354 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py +78 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_core.py +343 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_core_data_quality.py +198 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_data_contracts.py +421 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_design_system_mode.py +233 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_design_system_stack.py +61 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_native_desktop_stack_freshness.py +179 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_relevance_evaluator.py +182 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_skill_script_paths.py +82 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_style_taxonomy.py +175 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_text_layout_resilience.py +120 -0
- package/skills/ui-ux-pro-max/scripts/tests/test_web_stack_freshness.py +191 -0
- package/skills/ui-ux-pro-max/scripts/validate_data.py +1095 -0
- package/sources.json +58 -0
- package/skill/SKILL.md +0 -68
- package/skill-manifest.json +0 -10
- /package/{skill → skills/spec-to-ui}/LICENSE +0 -0
- /package/{skill → skills/spec-to-ui}/agents/openai.yaml +0 -0
- /package/{skill → skills/spec-to-ui}/references/reference-selection.md +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Stdlib-only regression tests for core.py / design_system.py (unittest, not
|
|
5
|
+
pytest -- this project ships with zero external dependencies and the tests
|
|
6
|
+
shouldn't add one).
|
|
7
|
+
|
|
8
|
+
Run with:
|
|
9
|
+
python -m unittest discover -s scripts/tests -v
|
|
10
|
+
or directly:
|
|
11
|
+
python scripts/tests/test_core.py
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
from unittest.mock import patch
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
24
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
25
|
+
|
|
26
|
+
import core
|
|
27
|
+
from core import BM25, detect_domain, search, search_stack, CSV_CONFIG, AVAILABLE_STACKS
|
|
28
|
+
from design_system import DesignSystemGenerator, generate_design_system
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestTokenizer(unittest.TestCase):
|
|
32
|
+
def test_short_domain_terms_are_kept(self):
|
|
33
|
+
bm25 = BM25()
|
|
34
|
+
tokens = bm25.tokenize("UI and UX design with 3D and AI")
|
|
35
|
+
self.assertIn("ui", tokens)
|
|
36
|
+
self.assertIn("3d", tokens)
|
|
37
|
+
self.assertIn("ai", tokens)
|
|
38
|
+
|
|
39
|
+
def test_stopwords_removed(self):
|
|
40
|
+
bm25 = BM25()
|
|
41
|
+
tokens = bm25.tokenize("this is for the team to do")
|
|
42
|
+
for stopword in ("is", "for", "the", "to", "do"):
|
|
43
|
+
self.assertNotIn(stopword, tokens)
|
|
44
|
+
|
|
45
|
+
def test_synonym_normalization(self):
|
|
46
|
+
bm25 = BM25()
|
|
47
|
+
self.assertEqual(bm25.tokenize("e-commerce store"), bm25.tokenize("ecommerce store"))
|
|
48
|
+
self.assertEqual(bm25.tokenize("dark-mode toggle"), bm25.tokenize("dark toggle"))
|
|
49
|
+
|
|
50
|
+
def test_boundary_safe_nav_normalization_preserves_existing_words(self):
|
|
51
|
+
bm25 = BM25()
|
|
52
|
+
tokens = bm25.tokenize("nav navigation navbar")
|
|
53
|
+
self.assertIn("navigation", tokens)
|
|
54
|
+
self.assertIn("navbar", tokens)
|
|
55
|
+
self.assertNotIn("navigationigation", tokens)
|
|
56
|
+
self.assertNotIn("navigationbar", tokens)
|
|
57
|
+
|
|
58
|
+
def test_punctuation_and_uk_variants_normalize_to_canonical_tokens(self):
|
|
59
|
+
bm25 = BM25()
|
|
60
|
+
tokens = bm25.tokenize("colour, organisation; behaviour customisation")
|
|
61
|
+
for expected in ("color", "organization", "behavior", "customization"):
|
|
62
|
+
self.assertIn(expected, tokens)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestBm25CoreBehavior(unittest.TestCase):
|
|
66
|
+
def test_empty_documents_produce_no_scores_or_vocab(self):
|
|
67
|
+
bm25 = BM25()
|
|
68
|
+
bm25.fit([])
|
|
69
|
+
self.assertEqual(bm25.score("anything"), [])
|
|
70
|
+
self.assertEqual(bm25.vocabulary(), [])
|
|
71
|
+
|
|
72
|
+
def test_bm25_cache_rebuilds_after_file_mtime_changes(self):
|
|
73
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
74
|
+
path = Path(tmp) / "search.csv"
|
|
75
|
+
path.write_text("Name,Keywords\nAlpha,alpha token\n", encoding="utf-8")
|
|
76
|
+
results_a, bm25_a = core._search_csv(path, ["Name", "Keywords"], ["Name"], "alpha", 1)
|
|
77
|
+
self.assertEqual(results_a[0]["Name"], "Alpha")
|
|
78
|
+
|
|
79
|
+
path.write_text("Name,Keywords\nBeta,beta token\n", encoding="utf-8")
|
|
80
|
+
stat = path.stat()
|
|
81
|
+
os.utime(path, ns=(stat.st_atime_ns + 1_000_000_000, stat.st_mtime_ns + 1_000_000_000))
|
|
82
|
+
|
|
83
|
+
results_b, bm25_b = core._search_csv(path, ["Name", "Keywords"], ["Name"], "beta", 1)
|
|
84
|
+
self.assertEqual(results_b[0]["Name"], "Beta")
|
|
85
|
+
self.assertIsNot(bm25_a, bm25_b)
|
|
86
|
+
|
|
87
|
+
def test_search_uses_one_verified_rows_and_index_snapshot(self):
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
path = Path(tmp) / "search.csv"
|
|
90
|
+
path.write_text("Name,Keywords\nAlpha,alpha token\n", encoding="utf-8")
|
|
91
|
+
original_get_bm25 = core._get_bm25
|
|
92
|
+
replaced = False
|
|
93
|
+
|
|
94
|
+
def replace_after_read(filepath, search_cols, data, signature=None,
|
|
95
|
+
cache_variant=""):
|
|
96
|
+
nonlocal replaced
|
|
97
|
+
if not replaced:
|
|
98
|
+
path.write_text("Name,Keywords\nBeta,beta token\n", encoding="utf-8")
|
|
99
|
+
replaced = True
|
|
100
|
+
return original_get_bm25(
|
|
101
|
+
filepath, search_cols, data, signature, cache_variant)
|
|
102
|
+
|
|
103
|
+
with patch.object(core, "_get_bm25", side_effect=replace_after_read):
|
|
104
|
+
results, _, _ = core._search_csv_detailed(
|
|
105
|
+
path, ["Name", "Keywords"], ["Name"], "alpha", 1)
|
|
106
|
+
self.assertEqual(results[0]["Name"], "Alpha")
|
|
107
|
+
|
|
108
|
+
results, _, _ = core._search_csv_detailed(
|
|
109
|
+
path, ["Name", "Keywords"], ["Name"], "beta", 1)
|
|
110
|
+
self.assertEqual(results[0]["Name"], "Beta")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestSearchDomains(unittest.TestCase):
|
|
114
|
+
def test_read_failure_is_not_reported_as_a_search_result(self):
|
|
115
|
+
failure = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")
|
|
116
|
+
with patch("core._load_csv_snapshot", side_effect=failure):
|
|
117
|
+
domain = search("palette", domain="color", max_results=1)
|
|
118
|
+
stack = search_stack("component", "react", max_results=1)
|
|
119
|
+
for result in (domain, stack):
|
|
120
|
+
self.assertEqual(0, result["count"])
|
|
121
|
+
self.assertEqual([], result["results"])
|
|
122
|
+
self.assertRegex(result["error"], r"^Unable to read search data:")
|
|
123
|
+
self.assertNotIn("invalid", result["error"])
|
|
124
|
+
|
|
125
|
+
def test_ui_is_searchable_in_style_domain(self):
|
|
126
|
+
result = search("ui minimalism", domain="style", max_results=1)
|
|
127
|
+
self.assertGreater(result["count"], 0, "literal 'ui' token must be searchable, not filtered by tokenizer")
|
|
128
|
+
|
|
129
|
+
def test_accessibility_query_hits_ux(self):
|
|
130
|
+
result = search("accessibility contrast wcag keyboard", domain="ux", max_results=3)
|
|
131
|
+
self.assertGreater(result["count"], 0)
|
|
132
|
+
|
|
133
|
+
def test_zero_result_query_reports_suggestions_not_error(self):
|
|
134
|
+
result = search("zzqqxx totally made up gibberish", domain="ux", max_results=2)
|
|
135
|
+
self.assertEqual(result["count"], 0)
|
|
136
|
+
self.assertIn("suggestions", result)
|
|
137
|
+
self.assertNotIn("error", result)
|
|
138
|
+
|
|
139
|
+
def test_hard_negative_query_abstains_across_registered_domains_and_stacks(self):
|
|
140
|
+
query = "sourdough starter crumb fermentation"
|
|
141
|
+
for domain in CSV_CONFIG:
|
|
142
|
+
with self.subTest(kind="domain", name=domain):
|
|
143
|
+
self.assertEqual(search(query, domain=domain, max_results=1)["count"], 0)
|
|
144
|
+
for stack in AVAILABLE_STACKS:
|
|
145
|
+
with self.subTest(kind="stack", name=stack):
|
|
146
|
+
self.assertEqual(search_stack(query, stack, max_results=1)["count"], 0)
|
|
147
|
+
|
|
148
|
+
def test_typo_suggestions_are_deterministic_and_retryable(self):
|
|
149
|
+
first = search("testimonal", domain="landing", max_results=3)
|
|
150
|
+
second = search("testimonal", domain="landing", max_results=3)
|
|
151
|
+
self.assertEqual(first["count"], 0)
|
|
152
|
+
self.assertEqual(first.get("suggestions"), second.get("suggestions"))
|
|
153
|
+
self.assertTrue(first["suggestions"], "typo path should return at least one deterministic suggestion")
|
|
154
|
+
|
|
155
|
+
retry = search(first["suggestions"][0], domain="landing", max_results=3)
|
|
156
|
+
self.assertGreater(retry["count"], 0)
|
|
157
|
+
|
|
158
|
+
def test_suggestions_never_repeat_the_input_or_offer_a_dead_first_retry(self):
|
|
159
|
+
pricing = search("pricing", domain="landing", max_results=3)
|
|
160
|
+
self.assertNotIn("pricing", pricing.get("suggestions", []))
|
|
161
|
+
|
|
162
|
+
minimal = search("minimal", domain="style", max_results=3)
|
|
163
|
+
self.assertEqual(1, minimal["count"])
|
|
164
|
+
self.assertEqual(
|
|
165
|
+
"minimalism-and-swiss-style", minimal["results"][0]["Style ID"]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def test_unknown_programmatic_domain_keeps_legacy_style_fallback(self):
|
|
169
|
+
result = search("minimalism", domain="unknown", max_results=1)
|
|
170
|
+
self.assertEqual(result["domain"], "unknown")
|
|
171
|
+
self.assertEqual(result["file"], CSV_CONFIG["style"]["file"])
|
|
172
|
+
self.assertGreater(result["count"], 0)
|
|
173
|
+
|
|
174
|
+
def test_unsupported_icon_library_abstains_instead_of_returning_other_library(self):
|
|
175
|
+
result = search("lucide icon", diagnostics=True)
|
|
176
|
+
self.assertEqual(result["domain"], "icons")
|
|
177
|
+
self.assertEqual(result["count"], 0)
|
|
178
|
+
self.assertEqual(result["diagnostics"]["reason"], "unsupported-library")
|
|
179
|
+
|
|
180
|
+
def test_every_configured_domain_file_exists_and_is_searchable(self):
|
|
181
|
+
for domain, config in CSV_CONFIG.items():
|
|
182
|
+
with self.subTest(domain=domain):
|
|
183
|
+
result = search("design", domain=domain, max_results=1)
|
|
184
|
+
self.assertNotIn("error", result, f"domain '{domain}' failed: {result.get('error')}")
|
|
185
|
+
|
|
186
|
+
def test_chart_output_keeps_legacy_grade_during_risk_migration(self):
|
|
187
|
+
result = search("time series chart", domain="chart", max_results=1)
|
|
188
|
+
self.assertEqual(result["count"], 1)
|
|
189
|
+
self.assertEqual(
|
|
190
|
+
result["results"][0]["Accessibility Grade"],
|
|
191
|
+
"deprecated: use Accessibility Risk",
|
|
192
|
+
)
|
|
193
|
+
self.assertIn("Accessibility Risk", result["results"][0])
|
|
194
|
+
|
|
195
|
+
def test_every_stack_file_exists_and_is_searchable(self):
|
|
196
|
+
for stack in AVAILABLE_STACKS:
|
|
197
|
+
with self.subTest(stack=stack):
|
|
198
|
+
result = search_stack("performance", stack, max_results=1)
|
|
199
|
+
self.assertNotIn("error", result, f"stack '{stack}' failed: {result.get('error')}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestDomainDetection(unittest.TestCase):
|
|
203
|
+
def test_style_keywords_route_to_style(self):
|
|
204
|
+
self.assertEqual(detect_domain("glassmorphism dark ui"), "style")
|
|
205
|
+
|
|
206
|
+
def test_accessibility_keywords_route_to_ux(self):
|
|
207
|
+
self.assertEqual(detect_domain("accessibility contrast wcag"), "ux")
|
|
208
|
+
|
|
209
|
+
def test_ambiguous_query_returns_runner_up(self):
|
|
210
|
+
domain, _ = detect_domain("font pairing elegant crypto", return_scores=True)
|
|
211
|
+
self.assertIsNotNone(domain)
|
|
212
|
+
|
|
213
|
+
def test_empty_query_falls_back_to_style(self):
|
|
214
|
+
self.assertEqual(detect_domain("...!!!???"), "style")
|
|
215
|
+
|
|
216
|
+
def test_router_prioritizes_color_intent_over_generic_product_terms(self):
|
|
217
|
+
self.assertEqual(detect_domain("semantic color tokens palette"), "color")
|
|
218
|
+
|
|
219
|
+
def test_router_prioritizes_icons_when_icon_library_and_icon_intent_present(self):
|
|
220
|
+
self.assertEqual(detect_domain("lucide search icon outline"), "icons")
|
|
221
|
+
|
|
222
|
+
def test_router_prioritizes_typography_for_font_pairing_queries(self):
|
|
223
|
+
self.assertEqual(detect_domain("font pairing elegant serif body font"), "typography")
|
|
224
|
+
|
|
225
|
+
def test_router_prioritizes_chart_queries_over_generic_product_keywords(self):
|
|
226
|
+
self.assertEqual(detect_domain("time series chart forecast"), "chart")
|
|
227
|
+
|
|
228
|
+
def test_hash_only_routes_color_for_a_valid_hex_literal(self):
|
|
229
|
+
self.assertNotEqual(detect_domain("C# WPF desktop app"), "color")
|
|
230
|
+
self.assertEqual(detect_domain("use #ff00aa as the accent"), "color")
|
|
231
|
+
|
|
232
|
+
def test_product_router_keeps_high_signal_service_aliases(self):
|
|
233
|
+
self.assertEqual(detect_domain("beauty spa"), "product")
|
|
234
|
+
self.assertEqual(detect_domain("salon booking"), "product")
|
|
235
|
+
|
|
236
|
+
def test_native_drag_intent_beats_generic_react_token(self):
|
|
237
|
+
self.assertEqual(detect_domain("drag reorder react native"), "web")
|
|
238
|
+
|
|
239
|
+
def test_every_router_term_is_searchable_or_has_a_corpus_rewrite(self):
|
|
240
|
+
for domain, keywords in core._domain_keywords().items():
|
|
241
|
+
config = CSV_CONFIG[domain]
|
|
242
|
+
path = core.DATA_DIR / config["file"]
|
|
243
|
+
index = core._get_bm25(path, config["search_cols"], core._load_csv(path))
|
|
244
|
+
vocabulary = set(index.vocabulary())
|
|
245
|
+
for keyword in keywords:
|
|
246
|
+
with self.subTest(domain=domain, keyword=keyword):
|
|
247
|
+
searchable = bool(set(index.tokenize(keyword)) & vocabulary)
|
|
248
|
+
explicitly_routing_only = keyword in core._DOMAIN_QUERY_REWRITES.get(domain, {})
|
|
249
|
+
self.assertTrue(searchable or explicitly_routing_only)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TestPersistence(unittest.TestCase):
|
|
253
|
+
def test_concurrent_non_force_persist_has_one_writer(self):
|
|
254
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
255
|
+
search_script = SCRIPTS_DIR / "search.py"
|
|
256
|
+
processes = [subprocess.Popen(
|
|
257
|
+
[sys.executable, str(search_script), f"saas dashboard {index}",
|
|
258
|
+
"--design-system", "--persist", "--project-name", "Race Probe",
|
|
259
|
+
"--output-dir", tmp, "--json"],
|
|
260
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
261
|
+
) for index in range(8)]
|
|
262
|
+
statuses = []
|
|
263
|
+
for process in processes:
|
|
264
|
+
stdout, stderr = process.communicate(timeout=30)
|
|
265
|
+
self.assertEqual(process.returncode, 0, stderr)
|
|
266
|
+
statuses.append(json.loads(stdout)["persistence"]["status"])
|
|
267
|
+
|
|
268
|
+
self.assertEqual(statuses.count("success"), 1)
|
|
269
|
+
self.assertEqual(statuses.count("skipped_exists"), 7)
|
|
270
|
+
|
|
271
|
+
def test_persist_then_skip_then_force(self):
|
|
272
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
273
|
+
result = generate_design_system("saas dashboard", "Test Project", persist=True, output_dir=tmp)
|
|
274
|
+
self.assertEqual(result["persistence"]["status"], "success")
|
|
275
|
+
master = Path(result["persistence"]["master_file"])
|
|
276
|
+
self.assertTrue(master.exists())
|
|
277
|
+
original_content = master.read_text(encoding="utf-8")
|
|
278
|
+
|
|
279
|
+
# Second persist without force must not overwrite.
|
|
280
|
+
result2 = generate_design_system("saas dashboard", "Test Project", persist=True, output_dir=tmp)
|
|
281
|
+
self.assertEqual(result2["persistence"]["status"], "skipped_exists")
|
|
282
|
+
self.assertEqual(master.read_text(encoding="utf-8"), original_content)
|
|
283
|
+
|
|
284
|
+
# A new page override may be added without rewriting the existing Master.
|
|
285
|
+
page_result = generate_design_system(
|
|
286
|
+
"checkout form", "Test Project", persist=True, page="Checkout", output_dir=tmp
|
|
287
|
+
)
|
|
288
|
+
self.assertEqual(page_result["persistence"]["status"], "success")
|
|
289
|
+
self.assertEqual(master.read_text(encoding="utf-8"), original_content)
|
|
290
|
+
page_file = Path(tmp) / "design-system" / "test-project" / "pages" / "checkout.md"
|
|
291
|
+
self.assertEqual(page_result["persistence"]["created_files"], [str(page_file)])
|
|
292
|
+
self.assertTrue(page_file.exists())
|
|
293
|
+
|
|
294
|
+
# Existing page overrides are protected by the same default no-overwrite rule.
|
|
295
|
+
page_content = page_file.read_text(encoding="utf-8")
|
|
296
|
+
page_result2 = generate_design_system(
|
|
297
|
+
"different checkout", "Test Project", persist=True, page="Checkout", output_dir=tmp
|
|
298
|
+
)
|
|
299
|
+
self.assertEqual(page_result2["persistence"]["status"], "skipped_exists")
|
|
300
|
+
self.assertEqual(page_file.read_text(encoding="utf-8"), page_content)
|
|
301
|
+
|
|
302
|
+
# With force=True it must overwrite.
|
|
303
|
+
result3 = generate_design_system("ecommerce luxury", "Test Project", persist=True, output_dir=tmp, force=True)
|
|
304
|
+
self.assertEqual(result3["persistence"]["status"], "success")
|
|
305
|
+
|
|
306
|
+
def test_persist_writes_only_under_output_dir(self):
|
|
307
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
308
|
+
generate_design_system("saas dashboard", "Scoped Project", persist=True, output_dir=tmp)
|
|
309
|
+
expected = Path(tmp) / "design-system" / "scoped-project" / "MASTER.md"
|
|
310
|
+
self.assertTrue(expected.exists())
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class TestReasoningMatch(unittest.TestCase):
|
|
314
|
+
def test_known_category_matches_exactly(self):
|
|
315
|
+
gen = DesignSystemGenerator()
|
|
316
|
+
rule = gen._find_reasoning_rule("SaaS (General)")
|
|
317
|
+
self.assertTrue(rule, "exact-match category lookup should not fall through to fuzzy matching")
|
|
318
|
+
|
|
319
|
+
def test_unknown_category_falls_back_gracefully(self):
|
|
320
|
+
gen = DesignSystemGenerator()
|
|
321
|
+
rule = gen._find_reasoning_rule("Totally Unknown Category XYZ")
|
|
322
|
+
# Should not raise; may return {} which _apply_reasoning handles with defaults.
|
|
323
|
+
self.assertIsInstance(rule, dict)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class TestDiagnosticsContracts(unittest.TestCase):
|
|
327
|
+
def test_diagnostics_opt_in_is_additive_for_domain_search(self):
|
|
328
|
+
baseline = search("minimalism", domain="style", max_results=1)
|
|
329
|
+
diagnosed = search("minimalism", domain="style", max_results=1, diagnostics=True)
|
|
330
|
+
self.assertEqual(set(baseline.keys()), set(diagnosed.keys()) - {"diagnostics"})
|
|
331
|
+
self.assertIn("diagnostics", diagnosed)
|
|
332
|
+
self.assertIn("top_score", diagnosed["diagnostics"])
|
|
333
|
+
self.assertIn("query_rewrites", diagnosed["diagnostics"])
|
|
334
|
+
|
|
335
|
+
def test_diagnostics_opt_in_is_additive_for_stack_search(self):
|
|
336
|
+
baseline = search_stack("performance", "react", max_results=1)
|
|
337
|
+
diagnosed = search_stack("performance", "react", max_results=1, diagnostics=True)
|
|
338
|
+
self.assertEqual(set(baseline.keys()), set(diagnosed.keys()) - {"diagnostics"})
|
|
339
|
+
self.assertIn("diagnostics", diagnosed)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
if __name__ == "__main__":
|
|
343
|
+
unittest.main()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Semantic quality contracts for the core UI/UX datasets."""
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
10
|
+
DATA_DIR = SCRIPTS_DIR.parent / "data"
|
|
11
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
12
|
+
|
|
13
|
+
from core import search # noqa: E402
|
|
14
|
+
from validate_data import ( # noqa: E402
|
|
15
|
+
CHART_NON_COLOR_GUIDANCE,
|
|
16
|
+
CHART_RISKS,
|
|
17
|
+
CHART_TEXT_FALLBACK,
|
|
18
|
+
COLOR_CONTRAST_PAIRS,
|
|
19
|
+
CSS_IMPORT,
|
|
20
|
+
ICON_CONTEXTS,
|
|
21
|
+
ICON_ROLES,
|
|
22
|
+
ICON_USAGE_REQUIREMENTS,
|
|
23
|
+
WCAG_GRADE,
|
|
24
|
+
_check_chart_contract,
|
|
25
|
+
_check_icon_contract,
|
|
26
|
+
_check_typography_contract,
|
|
27
|
+
_configured_font_names,
|
|
28
|
+
_font_families,
|
|
29
|
+
_font_names,
|
|
30
|
+
contrast_ratio,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_rows(name):
|
|
35
|
+
with (DATA_DIR / name).open(encoding="utf-8", newline="") as handle:
|
|
36
|
+
return list(csv.DictReader(handle))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestSemanticColors(unittest.TestCase):
|
|
40
|
+
def test_declared_text_and_ui_pairs_meet_role_thresholds(self):
|
|
41
|
+
for row in read_rows("colors.csv"):
|
|
42
|
+
for foreground, (background, role, minimum) in COLOR_CONTRAST_PAIRS.items():
|
|
43
|
+
with self.subTest(
|
|
44
|
+
product=row["Product Type"], pair=foreground, role=role):
|
|
45
|
+
self.assertGreaterEqual(
|
|
46
|
+
contrast_ratio(row[foreground], row[background]), minimum)
|
|
47
|
+
|
|
48
|
+
def test_destructive_tokens_are_not_success_green(self):
|
|
49
|
+
for row in read_rows("colors.csv"):
|
|
50
|
+
value = row["Destructive"].lstrip("#")
|
|
51
|
+
red, green, blue = (int(value[index:index + 2], 16)
|
|
52
|
+
for index in (0, 2, 4))
|
|
53
|
+
with self.subTest(product=row["Product Type"]):
|
|
54
|
+
self.assertFalse(green > red * 1.1 and green > blue * 1.1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestAccessibilityGuidance(unittest.TestCase):
|
|
58
|
+
def test_wcag_22_topics_have_explicit_rows_and_are_retrievable(self):
|
|
59
|
+
rows = read_rows("ux-guidelines.csv")
|
|
60
|
+
issues = {row["Issue"] for row in rows}
|
|
61
|
+
expected_platforms = {
|
|
62
|
+
"Focus Not Obscured (Minimum)": "Web",
|
|
63
|
+
"Focus Not Obscured (Enhanced)": "Web",
|
|
64
|
+
"Focus Appearance": "Web",
|
|
65
|
+
"Dragging Movements": "All",
|
|
66
|
+
"Target Size (Minimum)": "Web",
|
|
67
|
+
"Consistent Help": "All",
|
|
68
|
+
"Redundant Entry": "All",
|
|
69
|
+
"Accessible Authentication (Minimum)": "All",
|
|
70
|
+
"Auto-Rotating Content Controls": "All",
|
|
71
|
+
}
|
|
72
|
+
self.assertTrue(expected_platforms.keys() <= issues)
|
|
73
|
+
for issue, expected_platform in expected_platforms.items():
|
|
74
|
+
with self.subTest(issue=issue):
|
|
75
|
+
row = next(row for row in rows if row["Issue"] == issue)
|
|
76
|
+
self.assertEqual(row["Platform"], expected_platform)
|
|
77
|
+
self.assertIn(row["Severity"], {"Medium", "High", "Critical"})
|
|
78
|
+
self.assertNotEqual(row["Do"], row["Description"])
|
|
79
|
+
result = search(issue, domain="ux", max_results=3)
|
|
80
|
+
self.assertTrue(any(row.get("Issue") == issue for row in result["results"]))
|
|
81
|
+
|
|
82
|
+
def test_native_and_web_target_sizes_remain_distinct(self):
|
|
83
|
+
native = next(row for row in read_rows("app-interface.csv")
|
|
84
|
+
if row["Issue"] == "Touch Target Size")
|
|
85
|
+
web = next(row for row in read_rows("ux-guidelines.csv")
|
|
86
|
+
if row["Issue"] == "Target Size (Minimum)")
|
|
87
|
+
native_text = " ".join(native.values())
|
|
88
|
+
web_text = " ".join(web.values())
|
|
89
|
+
self.assertIn("44pt", native_text)
|
|
90
|
+
self.assertIn("48dp", native_text)
|
|
91
|
+
self.assertIn("24 CSS px", web_text)
|
|
92
|
+
|
|
93
|
+
def test_motion_recipes_offer_reduced_motion_or_user_control(self):
|
|
94
|
+
for row in read_rows("motion.csv"):
|
|
95
|
+
text = " ".join(row.values()).casefold()
|
|
96
|
+
with self.subTest(row=row["No"], category=row["Category"]):
|
|
97
|
+
self.assertTrue(
|
|
98
|
+
"reduced-motion" in text or "user-controlled" in text,
|
|
99
|
+
"every motion recipe needs an explicit opt-out",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestChartsTypographyAndIcons(unittest.TestCase):
|
|
104
|
+
def test_mutated_accessibility_and_import_contracts_fail(self):
|
|
105
|
+
mutations = []
|
|
106
|
+
chart = dict(read_rows("charts.csv")[0])
|
|
107
|
+
chart["A11y Fallback"] = "A visible table is available."
|
|
108
|
+
mutations.append((_check_chart_contract, chart, "keyboard"))
|
|
109
|
+
typography = dict(read_rows("typography.csv")[0])
|
|
110
|
+
typography["CSS Import"] = (
|
|
111
|
+
"@import url('https://fonts.googleapis.com/css2?family=Comic+Sans');"
|
|
112
|
+
)
|
|
113
|
+
mutations.append((_check_typography_contract, typography, "differ"))
|
|
114
|
+
missing_weight = dict(read_rows("typography.csv")[0])
|
|
115
|
+
missing_weight["Notes"] += " Recommended weight 900."
|
|
116
|
+
mutations.append((_check_typography_contract, missing_weight, "weights"))
|
|
117
|
+
icon = dict(read_rows("icons.csv")[0])
|
|
118
|
+
icon["Import Code"] = "import { IconName } from '@phosphor-icons/react'"
|
|
119
|
+
mutations.append((_check_icon_contract, icon, "import"))
|
|
120
|
+
for checker, row, expected in mutations:
|
|
121
|
+
with self.subTest(checker=checker.__name__):
|
|
122
|
+
problems = []
|
|
123
|
+
checker([row], problems)
|
|
124
|
+
self.assertTrue(any(expected in problem for problem in problems))
|
|
125
|
+
|
|
126
|
+
def test_chart_risk_is_not_a_conformance_grade(self):
|
|
127
|
+
for row in read_rows("charts.csv"):
|
|
128
|
+
with self.subTest(data_type=row["Data Type"]):
|
|
129
|
+
self.assertIn(row["Accessibility Risk"], CHART_RISKS)
|
|
130
|
+
self.assertEqual(
|
|
131
|
+
row["Accessibility Grade"],
|
|
132
|
+
"deprecated: use Accessibility Risk",
|
|
133
|
+
)
|
|
134
|
+
text = " ".join((row["Accessibility Notes"], row["A11y Fallback"]))
|
|
135
|
+
self.assertIsNone(WCAG_GRADE.search(text))
|
|
136
|
+
self.assertIsNotNone(CHART_TEXT_FALLBACK.search(text.casefold()))
|
|
137
|
+
self.assertIsNotNone(
|
|
138
|
+
CHART_NON_COLOR_GUIDANCE.search(text.casefold())
|
|
139
|
+
)
|
|
140
|
+
self.assertIn("keyboard", text.casefold())
|
|
141
|
+
|
|
142
|
+
def test_named_fonts_match_google_import_css_import_and_tailwind_config(self):
|
|
143
|
+
for row in read_rows("typography.csv"):
|
|
144
|
+
url_families = _font_families(row["Google Fonts URL"])
|
|
145
|
+
families = _font_names(url_families)
|
|
146
|
+
configured = _configured_font_names(row["Tailwind Config"])
|
|
147
|
+
named = {row["Heading Font"], row["Body Font"]}
|
|
148
|
+
with self.subTest(pairing=row["Font Pairing Name"]):
|
|
149
|
+
self.assertTrue(named <= families)
|
|
150
|
+
self.assertTrue(named <= configured)
|
|
151
|
+
match = CSS_IMPORT.fullmatch(row["CSS Import"])
|
|
152
|
+
self.assertIsNotNone(match)
|
|
153
|
+
self.assertEqual(
|
|
154
|
+
sorted(url_families),
|
|
155
|
+
sorted(_font_families(match.group(2))),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def test_icon_semantics_are_explicit_and_imports_are_concrete(self):
|
|
159
|
+
for row in read_rows("icons.csv"):
|
|
160
|
+
with self.subTest(icon=row["Icon Name"]):
|
|
161
|
+
self.assertIn(row["Semantic Role"], ICON_ROLES)
|
|
162
|
+
self.assertEqual(set(row["Allowed Contexts"].split("|")), ICON_CONTEXTS)
|
|
163
|
+
self.assertNotRegex(row["Usage"], r"[\u3400-\u9fff]")
|
|
164
|
+
self.assertNotIn("IconName", row["Import Code"])
|
|
165
|
+
for requirement in ICON_USAGE_REQUIREMENTS:
|
|
166
|
+
self.assertRegex(row["Usage"].casefold(), requirement)
|
|
167
|
+
|
|
168
|
+
def test_natural_accessibility_queries_are_retrievable(self):
|
|
169
|
+
cases = {
|
|
170
|
+
"chart": ("keyboard accessible chart", "keyboard"),
|
|
171
|
+
"landing": ("accessible drag interaction", "keyboard controls"),
|
|
172
|
+
"icons": ("decorative icon aria hidden", "aria-hidden"),
|
|
173
|
+
"gsap": ("stop animation offscreen", "visibility"),
|
|
174
|
+
"ux": ("error summary validation", "error summary"),
|
|
175
|
+
}
|
|
176
|
+
for domain, (query, expected) in cases.items():
|
|
177
|
+
with self.subTest(domain=domain, query=query):
|
|
178
|
+
result = search(query, domain=domain, max_results=3)
|
|
179
|
+
self.assertGreater(result["count"], 0)
|
|
180
|
+
self.assertIn(
|
|
181
|
+
expected.casefold(),
|
|
182
|
+
" ".join(str(value) for value in result["results"][0].values()).casefold(),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestCurrentReactGuidance(unittest.TestCase):
|
|
187
|
+
def test_effect_event_is_scoped_and_community_use_latest_is_removed(self):
|
|
188
|
+
rows = read_rows("react-performance.csv")
|
|
189
|
+
effect_event = next(row for row in rows if row["Issue"] == "Effect Events")
|
|
190
|
+
text = " ".join(effect_event.values()).casefold()
|
|
191
|
+
self.assertIn("inside effects", text)
|
|
192
|
+
self.assertIn("dependencies", text)
|
|
193
|
+
self.assertFalse(any("uselatest" in " ".join(row.values()).casefold()
|
|
194
|
+
for row in rows))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
unittest.main()
|