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,421 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-file semantic contracts for curated design data."""
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
15
|
+
DATA_DIR = SCRIPTS_DIR.parent / "data"
|
|
16
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
17
|
+
|
|
18
|
+
from core import AVAILABLE_STACKS, STACK_CONFIG # noqa: E402
|
|
19
|
+
from design_system import DesignSystemGenerator # noqa: E402
|
|
20
|
+
from reasoning_contract import apply_decision_rules, parse_decision_rules # noqa: E402
|
|
21
|
+
import validate_data # noqa: E402
|
|
22
|
+
from validate_data import _check_reasoning_contract # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_rows(name):
|
|
26
|
+
with (DATA_DIR / name).open(encoding="utf-8", newline="") as handle:
|
|
27
|
+
return list(csv.DictReader(handle))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split_values(value, delimiter):
|
|
31
|
+
return [part.strip() for part in value.split(delimiter) if part.strip()]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def style_identities(row):
|
|
35
|
+
return [
|
|
36
|
+
row["Style ID"], row["Style Category"],
|
|
37
|
+
*split_values(row["Aliases"], "|"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestStyleIdentityContract(unittest.TestCase):
|
|
42
|
+
def setUp(self):
|
|
43
|
+
self.styles = read_rows("styles.csv")
|
|
44
|
+
|
|
45
|
+
def test_ids_aliases_status_and_parents_are_unambiguous(self):
|
|
46
|
+
ids = {row["Style ID"] for row in self.styles}
|
|
47
|
+
self.assertEqual(len(ids), len(self.styles))
|
|
48
|
+
aliases = {}
|
|
49
|
+
for row in self.styles:
|
|
50
|
+
style_id = row["Style ID"]
|
|
51
|
+
self.assertRegex(style_id, r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
52
|
+
self.assertIn(row["Status"], {"active", "supplemental", "deprecated"})
|
|
53
|
+
parent = row["Parent Style ID"]
|
|
54
|
+
if parent:
|
|
55
|
+
self.assertIn(parent, ids)
|
|
56
|
+
self.assertNotEqual(parent, style_id)
|
|
57
|
+
if row["Status"] == "supplemental":
|
|
58
|
+
self.assertTrue(parent)
|
|
59
|
+
if row["Status"] == "deprecated":
|
|
60
|
+
has_redirect = bool(row["Replacement Domain"] and row["Replacement ID"])
|
|
61
|
+
self.assertNotEqual(bool(parent), has_redirect)
|
|
62
|
+
for alias in split_values(row["Aliases"], "|"):
|
|
63
|
+
self.assertNotIn(alias.casefold(), aliases)
|
|
64
|
+
aliases[alias.casefold()] = style_id
|
|
65
|
+
|
|
66
|
+
def test_every_product_and_reasoning_style_reference_resolves(self):
|
|
67
|
+
lookup = {}
|
|
68
|
+
for row in self.styles:
|
|
69
|
+
lookup.update(
|
|
70
|
+
(identity.casefold(), row["Style ID"])
|
|
71
|
+
for identity in style_identities(row)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
references = []
|
|
75
|
+
for row in read_rows("products.csv"):
|
|
76
|
+
references.extend(split_values(row["Primary Style Recommendation"], "+"))
|
|
77
|
+
references.extend(split_values(row["Secondary Styles"], ","))
|
|
78
|
+
for row in read_rows("ui-reasoning.csv"):
|
|
79
|
+
references.extend(split_values(row["Style_Priority"], "+"))
|
|
80
|
+
unresolved = sorted({
|
|
81
|
+
reference for reference in references
|
|
82
|
+
if reference.casefold() not in lookup
|
|
83
|
+
})
|
|
84
|
+
self.assertEqual([], unresolved)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestReasoningContract(unittest.TestCase):
|
|
88
|
+
def test_known_product_sets_match_exactly(self):
|
|
89
|
+
product_rows = read_rows("products.csv")
|
|
90
|
+
color_rows = read_rows("colors.csv")
|
|
91
|
+
reasoning_rows = read_rows("ui-reasoning.csv")
|
|
92
|
+
self.assertEqual([192, 192, 192], [
|
|
93
|
+
len(product_rows), len(color_rows), len(reasoning_rows)])
|
|
94
|
+
products = {row["Product Type"] for row in product_rows}
|
|
95
|
+
colors = {row["Product Type"] for row in color_rows}
|
|
96
|
+
reasoning = {row["UI_Category"] for row in reasoning_rows}
|
|
97
|
+
self.assertEqual(products, colors)
|
|
98
|
+
self.assertEqual(products, reasoning)
|
|
99
|
+
self.assertEqual(len(products), 192)
|
|
100
|
+
|
|
101
|
+
def test_decision_rules_use_closed_array_grammar(self):
|
|
102
|
+
for row in read_rows("ui-reasoning.csv"):
|
|
103
|
+
with self.subTest(category=row["UI_Category"]):
|
|
104
|
+
parsed = parse_decision_rules(row["Decision_Rules"])
|
|
105
|
+
self.assertTrue(all(isinstance(actions, list) for actions in parsed.values()))
|
|
106
|
+
|
|
107
|
+
def test_duplicate_unknown_keys_and_unknown_actions_fail_closed(self):
|
|
108
|
+
invalid = (
|
|
109
|
+
'{"must_have":["constraint:first"],"must_have":["constraint:second"]}',
|
|
110
|
+
'{"if_not_supported":["constraint:test"]}',
|
|
111
|
+
'{"must_have":["execute:arbitrary"]}',
|
|
112
|
+
'{"must_have":[["constraint:nested"]]}',
|
|
113
|
+
'{"must_have":[{"constraint":"nested"}]}',
|
|
114
|
+
)
|
|
115
|
+
for raw in invalid:
|
|
116
|
+
with self.subTest(raw=raw), self.assertRaises(ValueError):
|
|
117
|
+
parse_decision_rules(raw)
|
|
118
|
+
|
|
119
|
+
def test_must_have_and_explicit_signals_are_applied_and_reported(self):
|
|
120
|
+
rules = parse_decision_rules(
|
|
121
|
+
'{"must_have":["constraint:keyboard-navigation"],'
|
|
122
|
+
'"if_mobile":["constraint:optimize-touch-targets"]}')
|
|
123
|
+
desktop = apply_decision_rules(rules, "accessible government portal")
|
|
124
|
+
mobile = apply_decision_rules(rules, "accessible mobile government portal")
|
|
125
|
+
self.assertEqual(desktop["constraints"], ["keyboard-navigation"])
|
|
126
|
+
self.assertEqual(
|
|
127
|
+
mobile["constraints"], ["keyboard-navigation", "optimize-touch-targets"])
|
|
128
|
+
self.assertEqual(
|
|
129
|
+
[item["condition"] for item in mobile["activated"]],
|
|
130
|
+
["must_have", "if_mobile"],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def test_generator_matches_reasoning_exactly_and_defaults_only_for_unknown(self):
|
|
134
|
+
generator = DesignSystemGenerator()
|
|
135
|
+
categories = [row["Product Type"] for row in read_rows("products.csv")]
|
|
136
|
+
for category in categories:
|
|
137
|
+
with self.subTest(category=category):
|
|
138
|
+
self.assertEqual(generator._find_reasoning_rule(category)["UI_Category"], category)
|
|
139
|
+
self.assertEqual(generator._find_reasoning_rule("Government"), {})
|
|
140
|
+
self.assertTrue(generator._apply_reasoning("External Unknown", "unknown")["is_default"])
|
|
141
|
+
|
|
142
|
+
def test_reasoning_patterns_reference_landing_identities(self):
|
|
143
|
+
patterns = set()
|
|
144
|
+
for row in read_rows("landing.csv"):
|
|
145
|
+
patterns.add(row["Pattern Name"])
|
|
146
|
+
patterns.update(alias for alias in row["Aliases"].split("|") if alias)
|
|
147
|
+
reasoning = read_rows("ui-reasoning.csv")
|
|
148
|
+
self.assertEqual(192, len(reasoning))
|
|
149
|
+
for row in reasoning:
|
|
150
|
+
with self.subTest(category=row["UI_Category"]):
|
|
151
|
+
self.assertIn(row["Recommended_Pattern"], patterns)
|
|
152
|
+
|
|
153
|
+
def test_every_known_product_generates_a_traceable_landing_pattern(self):
|
|
154
|
+
generator = DesignSystemGenerator()
|
|
155
|
+
patterns = {row["Pattern Name"] for row in read_rows("landing.csv")}
|
|
156
|
+
for category in (row["Product Type"] for row in read_rows("products.csv")):
|
|
157
|
+
with self.subTest(category=category):
|
|
158
|
+
result = generator.generate(category)
|
|
159
|
+
self.assertIn(result["source_identities"]["landing"], patterns)
|
|
160
|
+
|
|
161
|
+
def test_representative_new_products_generate_traceable_sources(self):
|
|
162
|
+
generator = DesignSystemGenerator()
|
|
163
|
+
styles = {row["Style ID"] for row in read_rows("styles.csv")}
|
|
164
|
+
colors = {row["Product Type"] for row in read_rows("colors.csv")}
|
|
165
|
+
typography = {row["Font Pairing Name"] for row in read_rows("typography.csv")}
|
|
166
|
+
patterns = {row["Pattern Name"] for row in read_rows("landing.csv")}
|
|
167
|
+
cases = {
|
|
168
|
+
"government grant portal accessible trustworthy": "Grant / Funding Portal",
|
|
169
|
+
"API developer portal documentation": "API Developer Portal",
|
|
170
|
+
"academic journal scholarly publishing accessible": "Academic Journal / Scholarly Publishing",
|
|
171
|
+
"patient portal mobile secure": "Patient Portal / Health Records",
|
|
172
|
+
"status page outage monitoring": "Status Page / Incident Management",
|
|
173
|
+
}
|
|
174
|
+
for query, category in cases.items():
|
|
175
|
+
with self.subTest(query=query):
|
|
176
|
+
result = generator.generate(query)
|
|
177
|
+
sources = result["source_identities"]
|
|
178
|
+
self.assertEqual(category, result["category"])
|
|
179
|
+
self.assertFalse(result["reasoning_default"])
|
|
180
|
+
self.assertEqual(category, sources["product"])
|
|
181
|
+
self.assertEqual(category, sources["reasoning"])
|
|
182
|
+
self.assertIn(sources["style"], styles)
|
|
183
|
+
self.assertIn(sources["color"], colors)
|
|
184
|
+
self.assertIn(sources["typography"], typography)
|
|
185
|
+
self.assertIn(sources["landing"], patterns)
|
|
186
|
+
|
|
187
|
+
def test_constraints_reach_domain_queries(self):
|
|
188
|
+
generator = DesignSystemGenerator()
|
|
189
|
+
calls = []
|
|
190
|
+
|
|
191
|
+
def capture(query, domain, max_results):
|
|
192
|
+
calls.append((domain, query))
|
|
193
|
+
return {"domain": domain, "count": 0, "results": []}
|
|
194
|
+
|
|
195
|
+
reasoning = {
|
|
196
|
+
"pattern": "Unmapped Pattern",
|
|
197
|
+
"color_mood": "Trustworthy",
|
|
198
|
+
"typography_mood": "Readable",
|
|
199
|
+
"constraints": ["keyboard-navigation", "touch-targets"],
|
|
200
|
+
}
|
|
201
|
+
with patch("design_system.search", side_effect=capture):
|
|
202
|
+
generator._multi_domain_search(
|
|
203
|
+
"public portal", "Government Portal", reasoning, ["Minimalism"])
|
|
204
|
+
queried = {domain: query for domain, query in calls}
|
|
205
|
+
for domain in ("style", "color", "typography", "landing"):
|
|
206
|
+
with self.subTest(domain=domain):
|
|
207
|
+
self.assertIn("keyboard navigation", queried[domain])
|
|
208
|
+
|
|
209
|
+
def test_canonical_style_priority_is_not_limited_to_bm25_top_three(self):
|
|
210
|
+
generator = DesignSystemGenerator()
|
|
211
|
+
unrelated = [
|
|
212
|
+
generator._resolve_style("Kinetic Brutalism (Mobile)"),
|
|
213
|
+
generator._resolve_style("Glassmorphism"),
|
|
214
|
+
]
|
|
215
|
+
selected = generator._select_best_match(unrelated, ["Brutalism"])
|
|
216
|
+
self.assertEqual("brutalism", selected["Style ID"])
|
|
217
|
+
|
|
218
|
+
def test_duplicate_semantic_reasoning_labels_fail_validation(self):
|
|
219
|
+
product = {"Product Type": "Duplicate"}
|
|
220
|
+
color = {"Product Type": "Duplicate"}
|
|
221
|
+
reasoning = {
|
|
222
|
+
"UI_Category": "Duplicate", "Decision_Rules": "{}", "Confidence": ""
|
|
223
|
+
}
|
|
224
|
+
problems = []
|
|
225
|
+
_check_reasoning_contract(
|
|
226
|
+
[product, dict(product)], [color, dict(color)],
|
|
227
|
+
[reasoning, dict(reasoning)], set(), set(), problems,
|
|
228
|
+
)
|
|
229
|
+
self.assertTrue(any("duplicate" in problem.lower() for problem in problems))
|
|
230
|
+
|
|
231
|
+
def test_every_exact_product_label_resolves_to_itself(self):
|
|
232
|
+
generator = DesignSystemGenerator()
|
|
233
|
+
for row in read_rows("products.csv"):
|
|
234
|
+
category = row["Product Type"]
|
|
235
|
+
with self.subTest(category=category):
|
|
236
|
+
result = generator.generate(category)
|
|
237
|
+
reasoning = generator._apply_reasoning(category, category)
|
|
238
|
+
expected = [
|
|
239
|
+
generator._resolve_style(priority).get("Style ID")
|
|
240
|
+
for priority in reasoning["style_priority"]
|
|
241
|
+
]
|
|
242
|
+
expected = [style_id for style_id in expected if style_id]
|
|
243
|
+
self.assertEqual(category, result["category"])
|
|
244
|
+
self.assertFalse(result["reasoning_default"])
|
|
245
|
+
self.assertTrue(expected)
|
|
246
|
+
self.assertEqual(expected[0], result["style"]["id"])
|
|
247
|
+
|
|
248
|
+
def test_style_aliases_have_one_exact_owner(self):
|
|
249
|
+
generator = DesignSystemGenerator()
|
|
250
|
+
self.assertEqual(
|
|
251
|
+
generator._resolve_style("Minimalism")["Style ID"],
|
|
252
|
+
"minimalism-and-swiss-style",
|
|
253
|
+
)
|
|
254
|
+
self.assertEqual(generator._resolve_style("Clean Science"), {})
|
|
255
|
+
self.assertEqual(
|
|
256
|
+
generator._resolve_style("Holographic/HUD")["Style ID"],
|
|
257
|
+
"hud-sci-fi-fui",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class TestLandingAndStackContract(unittest.TestCase):
|
|
262
|
+
def test_landing_sections_use_one_delimiter(self):
|
|
263
|
+
for row in read_rows("landing.csv"):
|
|
264
|
+
with self.subTest(pattern=row["Pattern Name"]):
|
|
265
|
+
sections = row["Section Order"].split(" > ")
|
|
266
|
+
self.assertGreaterEqual(len(sections), 2)
|
|
267
|
+
self.assertTrue(all(section.strip() for section in sections))
|
|
268
|
+
self.assertFalse(any(re.match(r"^\d+\.\s", section) for section in sections))
|
|
269
|
+
|
|
270
|
+
def test_stack_schema_is_additive_and_uniform(self):
|
|
271
|
+
for stack in AVAILABLE_STACKS:
|
|
272
|
+
path = DATA_DIR / STACK_CONFIG[stack]["file"]
|
|
273
|
+
with path.open(encoding="utf-8", newline="") as handle:
|
|
274
|
+
reader = csv.DictReader(handle)
|
|
275
|
+
self.assertTrue({"Applies To", "Status", "Verified At"} <= set(reader.fieldnames or []))
|
|
276
|
+
for row in reader:
|
|
277
|
+
self.assertIn(row["Status"], {"active", "supplemental", "deprecated", "unverified"})
|
|
278
|
+
|
|
279
|
+
def test_provenance_sidecar_has_stable_shape(self):
|
|
280
|
+
payload = json.loads((DATA_DIR / "data-provenance.json").read_text(encoding="utf-8"))
|
|
281
|
+
self.assertEqual(payload["schemaVersion"], 1)
|
|
282
|
+
self.assertIsInstance(payload["records"], list)
|
|
283
|
+
for record in payload["records"]:
|
|
284
|
+
self.assertTrue({"entityKind", "entityId", "sourceFile", "status", "verifiedAt", "sources"} <= set(record))
|
|
285
|
+
self.assertIsInstance(record["sources"], list)
|
|
286
|
+
source_types = {source.get("type") for source in record["sources"]}
|
|
287
|
+
if source_types <= {"derived"}:
|
|
288
|
+
self.assertEqual("needs-review", record["sla"])
|
|
289
|
+
|
|
290
|
+
def test_provenance_rejects_bad_shapes_enums_and_hosts_without_crashing(self):
|
|
291
|
+
canonical = json.loads(
|
|
292
|
+
(DATA_DIR / "data-provenance.json").read_text(encoding="utf-8")
|
|
293
|
+
)
|
|
294
|
+
cases = [[], None, "invalid"]
|
|
295
|
+
malformed_record = copy.deepcopy(canonical)
|
|
296
|
+
malformed_record["records"].append(None)
|
|
297
|
+
cases.append(malformed_record)
|
|
298
|
+
malformed_source = copy.deepcopy(canonical)
|
|
299
|
+
malformed_source["records"][0]["sources"] = [None]
|
|
300
|
+
cases.append(malformed_source)
|
|
301
|
+
unapproved_source = copy.deepcopy(canonical)
|
|
302
|
+
official = next(
|
|
303
|
+
record for record in unapproved_source["records"]
|
|
304
|
+
if any(source.get("type") == "official" for source in record["sources"])
|
|
305
|
+
)
|
|
306
|
+
official["sources"] = [
|
|
307
|
+
{"type": "official", "ref": "https://evil.example/fake"}
|
|
308
|
+
]
|
|
309
|
+
cases.append(unapproved_source)
|
|
310
|
+
invalid_enums = copy.deepcopy(canonical)
|
|
311
|
+
invalid_enums["records"][0].update(
|
|
312
|
+
status="invented", sla="whenever", confidence=float("nan")
|
|
313
|
+
)
|
|
314
|
+
cases.append(invalid_enums)
|
|
315
|
+
|
|
316
|
+
reasoning, styles = read_rows("ui-reasoning.csv"), read_rows("styles.csv")
|
|
317
|
+
for index, payload in enumerate(cases):
|
|
318
|
+
with self.subTest(case=index), tempfile.TemporaryDirectory() as directory:
|
|
319
|
+
root = Path(directory)
|
|
320
|
+
(root / "data-provenance.json").write_text(
|
|
321
|
+
json.dumps(payload), encoding="utf-8"
|
|
322
|
+
)
|
|
323
|
+
problems = []
|
|
324
|
+
with patch.object(validate_data, "DATA_DIR", root):
|
|
325
|
+
validate_data._check_provenance(reasoning, styles, problems)
|
|
326
|
+
self.assertTrue(problems)
|
|
327
|
+
|
|
328
|
+
def test_dataset_provenance_scope_binds_real_rows_and_fields(self):
|
|
329
|
+
problems = []
|
|
330
|
+
valid = validate_data._valid_dataset_source_key(
|
|
331
|
+
"colors.csv", {"Scope": "No 1-192; Notes field"},
|
|
332
|
+
("dataset-contract", "valid"), problems,
|
|
333
|
+
)
|
|
334
|
+
self.assertTrue(valid)
|
|
335
|
+
self.assertEqual([], problems)
|
|
336
|
+
stack_problems = []
|
|
337
|
+
self.assertTrue(validate_data._valid_dataset_source_key(
|
|
338
|
+
"stacks/html-tailwind.csv", {"Scope": "No 57-59; Guideline field"},
|
|
339
|
+
("dataset-contract", "valid-stack"), stack_problems,
|
|
340
|
+
))
|
|
341
|
+
self.assertEqual([], stack_problems)
|
|
342
|
+
for source_file, source_key in (
|
|
343
|
+
("unknown.csv", {"Scope": "No 1; Notes field"}),
|
|
344
|
+
("colors.csv", {"Scope": "No 999; Notes field"}),
|
|
345
|
+
("colors.csv", {"Scope": "No 1; Invented Field"}),
|
|
346
|
+
):
|
|
347
|
+
with self.subTest(source_file=source_file, source_key=source_key):
|
|
348
|
+
problems = []
|
|
349
|
+
self.assertFalse(validate_data._valid_dataset_source_key(
|
|
350
|
+
source_file, source_key, ("dataset-contract", "bad"), problems
|
|
351
|
+
))
|
|
352
|
+
self.assertTrue(problems)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class TestGeneratedCatalogContract(unittest.TestCase):
|
|
356
|
+
def load_json(self, name):
|
|
357
|
+
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
|
|
358
|
+
|
|
359
|
+
def test_canonical_catalogs_and_provenance_are_release_ready(self):
|
|
360
|
+
problems = validate_data.validate()
|
|
361
|
+
self.assertEqual([], [problem for problem in problems if "catalog" in problem])
|
|
362
|
+
|
|
363
|
+
def test_font_license_and_typography_drift_fail_closed(self):
|
|
364
|
+
fonts = read_rows("google-fonts.csv")
|
|
365
|
+
licenses = self.load_json("google-font-licenses.json")
|
|
366
|
+
licenses["families"][0]["license"] = "UNKNOWN"
|
|
367
|
+
problems = []
|
|
368
|
+
validate_data._check_font_catalog(
|
|
369
|
+
fonts, licenses, read_rows("typography.csv"), problems
|
|
370
|
+
)
|
|
371
|
+
self.assertTrue(any("invalid active family" in problem for problem in problems))
|
|
372
|
+
|
|
373
|
+
missing_font = copy.deepcopy(read_rows("typography.csv"))
|
|
374
|
+
missing_font[0]["Google Fonts URL"] = (
|
|
375
|
+
"https://fonts.googleapis.com/css2?family=Invented+Sans:wght@400"
|
|
376
|
+
)
|
|
377
|
+
problems = []
|
|
378
|
+
validate_data._check_font_catalog(
|
|
379
|
+
fonts, self.load_json("google-font-licenses.json"), missing_font, problems
|
|
380
|
+
)
|
|
381
|
+
self.assertTrue(any("absent from approved catalog" in problem for problem in problems))
|
|
382
|
+
|
|
383
|
+
def test_font_source_revision_and_exclusion_policy_fail_closed(self):
|
|
384
|
+
fonts = read_rows("google-fonts.csv")
|
|
385
|
+
typography = read_rows("typography.csv")
|
|
386
|
+
licenses = self.load_json("google-font-licenses.json")
|
|
387
|
+
licenses["excludedFamilies"][0]["source"] = "https://github.com/google/fonts"
|
|
388
|
+
problems = []
|
|
389
|
+
validate_data._check_font_catalog(fonts, licenses, typography, problems)
|
|
390
|
+
self.assertFalse(any("invalid exclusion" in problem for problem in problems))
|
|
391
|
+
|
|
392
|
+
licenses["excludedFamilies"][0]["source"] = "https://github.com/other/fonts"
|
|
393
|
+
licenses["source"]["revision"] = "main"
|
|
394
|
+
problems = []
|
|
395
|
+
validate_data._check_font_catalog(fonts, licenses, typography, problems)
|
|
396
|
+
self.assertTrue(any("invalid exclusion" in problem for problem in problems))
|
|
397
|
+
self.assertTrue(any("invalid source revision" in problem for problem in problems))
|
|
398
|
+
|
|
399
|
+
def test_curated_icon_and_summary_drift_fail_closed(self):
|
|
400
|
+
manifest = self.load_json("phosphor-icons-upstream.json")
|
|
401
|
+
manifest["icons"][0]["clientImport"] = (
|
|
402
|
+
'import { Wrong } from "@phosphor-icons/react"'
|
|
403
|
+
)
|
|
404
|
+
problems = []
|
|
405
|
+
validate_data._check_phosphor_catalog(read_rows("icons.csv"), manifest, problems)
|
|
406
|
+
self.assertTrue(any("invalid identity or imports" in problem for problem in problems))
|
|
407
|
+
|
|
408
|
+
summary = self.load_json("catalog-summary.json")
|
|
409
|
+
summary["counts"]["googleFonts"] -= 1
|
|
410
|
+
problems = []
|
|
411
|
+
validate_data._check_catalog_summary(
|
|
412
|
+
summary,
|
|
413
|
+
self.load_json("google-font-licenses.json"),
|
|
414
|
+
self.load_json("phosphor-icons-upstream.json"),
|
|
415
|
+
problems,
|
|
416
|
+
)
|
|
417
|
+
self.assertTrue(any("stale count for googleFonts" in problem for problem in problems))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
if __name__ == "__main__":
|
|
421
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Regression tests for color-mode coherence in design_system.py (issue #428).
|
|
5
|
+
|
|
6
|
+
Style, palette and anti-patterns used to be resolved independently, so a
|
|
7
|
+
dark-primary style could be returned alongside a light palette and a
|
|
8
|
+
"Dark mode by default" anti-pattern.
|
|
9
|
+
|
|
10
|
+
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
|
|
11
|
+
with zero external dependencies.
|
|
12
|
+
|
|
13
|
+
Run with:
|
|
14
|
+
python -m unittest discover -s scripts/tests -v
|
|
15
|
+
or directly:
|
|
16
|
+
python scripts/tests/test_design_system_mode.py
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import unittest
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
25
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
26
|
+
|
|
27
|
+
from design_system import ( # noqa: E402
|
|
28
|
+
_filter_anti_patterns_for_mode,
|
|
29
|
+
_contrast_ratio,
|
|
30
|
+
_mode_support_labels,
|
|
31
|
+
_palette_is_dark,
|
|
32
|
+
_query_wants_dark,
|
|
33
|
+
_relative_luminance,
|
|
34
|
+
_resolve_color_mode,
|
|
35
|
+
_select_palette_for_mode,
|
|
36
|
+
_style_is_dark_primary,
|
|
37
|
+
BOX_WIDTH,
|
|
38
|
+
DesignSystemGenerator,
|
|
39
|
+
format_ascii_box,
|
|
40
|
+
format_markdown,
|
|
41
|
+
format_master_md,
|
|
42
|
+
) # noqa: I001 - private helpers first, public class last
|
|
43
|
+
|
|
44
|
+
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
|
|
45
|
+
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
|
|
46
|
+
|
|
47
|
+
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
|
|
48
|
+
DARK_PRIMARY_STYLE = {
|
|
49
|
+
"Style Category": "Modern Dark (Cinema Mobile)",
|
|
50
|
+
"Light Mode ✓": "✓ Light mode only as exception",
|
|
51
|
+
"Dark Mode ✓": "✓ Dark Mode Primary",
|
|
52
|
+
}
|
|
53
|
+
DUAL_MODE_STYLE = {
|
|
54
|
+
"Style Category": "Minimalism",
|
|
55
|
+
"Light Mode ✓": "✓ Full",
|
|
56
|
+
"Dark Mode ✓": "✓ Full",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestLuminance(unittest.TestCase):
|
|
61
|
+
def test_parses_six_and_three_digit_hex(self):
|
|
62
|
+
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
|
|
63
|
+
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
|
|
64
|
+
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
|
|
65
|
+
|
|
66
|
+
def test_returns_none_for_unparseable(self):
|
|
67
|
+
for value in ("", "nope", "#12", "#GGGGGG", None):
|
|
68
|
+
self.assertIsNone(_relative_luminance(value))
|
|
69
|
+
|
|
70
|
+
def test_classifies_backgrounds_from_the_shipped_data(self):
|
|
71
|
+
# Lightest dark background and darkest light background in colors.csv.
|
|
72
|
+
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
|
|
73
|
+
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
|
|
74
|
+
|
|
75
|
+
def test_missing_background_is_not_dark(self):
|
|
76
|
+
self.assertFalse(_palette_is_dark({}))
|
|
77
|
+
self.assertFalse(_palette_is_dark(None))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestModeResolution(unittest.TestCase):
|
|
81
|
+
def test_dark_primary_style_detected(self):
|
|
82
|
+
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
|
|
83
|
+
|
|
84
|
+
def test_dual_mode_style_is_not_dark_primary(self):
|
|
85
|
+
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
|
|
86
|
+
self.assertFalse(_style_is_dark_primary({}))
|
|
87
|
+
|
|
88
|
+
def test_query_keywords(self):
|
|
89
|
+
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
|
|
90
|
+
self.assertTrue(_query_wants_dark("gaming app OLED"))
|
|
91
|
+
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
|
|
92
|
+
self.assertFalse(_query_wants_dark(""))
|
|
93
|
+
|
|
94
|
+
def test_either_signal_resolves_dark(self):
|
|
95
|
+
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
|
|
96
|
+
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
|
|
97
|
+
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestPaletteSelection(unittest.TestCase):
|
|
101
|
+
def test_dark_mode_skips_light_palettes(self):
|
|
102
|
+
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
|
|
103
|
+
self.assertEqual(chosen["Background"], "#0F172A")
|
|
104
|
+
|
|
105
|
+
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
|
|
106
|
+
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
|
|
107
|
+
self.assertEqual(chosen["Background"], "#F8FAFC")
|
|
108
|
+
|
|
109
|
+
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
|
|
110
|
+
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
|
|
111
|
+
self.assertEqual(chosen["Background"], "#0F172A")
|
|
112
|
+
|
|
113
|
+
def test_empty_results(self):
|
|
114
|
+
self.assertEqual(_select_palette_for_mode([], "dark"), {})
|
|
115
|
+
|
|
116
|
+
def test_category_identity_wins_over_unrelated_dark_palette(self):
|
|
117
|
+
chosen = _select_palette_for_mode(
|
|
118
|
+
[LIGHT_PALETTE, DARK_PALETTE], "dark", "SaaS")
|
|
119
|
+
self.assertEqual("SaaS", chosen["Product Type"])
|
|
120
|
+
self.assertEqual("derived-dark", chosen["_mode_derivation"])
|
|
121
|
+
self.assertTrue(_palette_is_dark(chosen))
|
|
122
|
+
self.assertGreaterEqual(
|
|
123
|
+
_contrast_ratio(chosen["Ring"], chosen["Background"]), 3.0)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestAntiPatternGating(unittest.TestCase):
|
|
127
|
+
def test_dark_clause_dropped_others_kept(self):
|
|
128
|
+
result = _filter_anti_patterns_for_mode(
|
|
129
|
+
"Excessive animation + Dark mode by default", "dark")
|
|
130
|
+
self.assertEqual(result, "Excessive animation")
|
|
131
|
+
|
|
132
|
+
def test_light_mode_is_a_no_op(self):
|
|
133
|
+
original = "Excessive animation + Dark mode by default"
|
|
134
|
+
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
|
|
135
|
+
|
|
136
|
+
def test_unrelated_anti_patterns_survive_dark_mode(self):
|
|
137
|
+
original = "Complex jargon + Tiny tap targets"
|
|
138
|
+
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
|
|
139
|
+
|
|
140
|
+
def test_empty_input(self):
|
|
141
|
+
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestEndToEndCoherence(unittest.TestCase):
|
|
145
|
+
"""The exact reproduction from issue #428."""
|
|
146
|
+
|
|
147
|
+
QUERY = "SaaS invoicing fintech B2B professional dark mode"
|
|
148
|
+
|
|
149
|
+
def test_dark_query_gets_a_dark_background(self):
|
|
150
|
+
ds = DesignSystemGenerator().generate(self.QUERY)
|
|
151
|
+
background = ds["colors"]["background"]
|
|
152
|
+
self.assertTrue(
|
|
153
|
+
_palette_is_dark({"Background": background}),
|
|
154
|
+
"dark-mode query returned a light background: {}".format(background),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_generator_exports_every_semantic_foreground_pair(self):
|
|
158
|
+
colors = DesignSystemGenerator().generate("SaaS dashboard")["colors"]
|
|
159
|
+
pairs = (
|
|
160
|
+
("on_primary", "primary"),
|
|
161
|
+
("on_secondary", "secondary"),
|
|
162
|
+
("on_accent", "accent"),
|
|
163
|
+
("foreground", "background"),
|
|
164
|
+
("card_foreground", "card"),
|
|
165
|
+
("muted_foreground", "muted"),
|
|
166
|
+
("on_destructive", "destructive"),
|
|
167
|
+
)
|
|
168
|
+
for foreground, background in pairs:
|
|
169
|
+
with self.subTest(pair=foreground):
|
|
170
|
+
self.assertTrue(colors[foreground])
|
|
171
|
+
self.assertTrue(colors[background])
|
|
172
|
+
self.assertGreaterEqual(
|
|
173
|
+
_contrast_ratio(colors[foreground], colors[background]), 4.5
|
|
174
|
+
)
|
|
175
|
+
self.assertEqual(colors["on_cta"], colors["on_accent"])
|
|
176
|
+
|
|
177
|
+
def test_dark_query_foreground_is_lighter_than_background(self):
|
|
178
|
+
ds = DesignSystemGenerator().generate(self.QUERY)
|
|
179
|
+
background = _relative_luminance(ds["colors"]["background"])
|
|
180
|
+
foreground = _relative_luminance(ds["colors"]["foreground"])
|
|
181
|
+
self.assertIsNotNone(background)
|
|
182
|
+
self.assertIsNotNone(foreground)
|
|
183
|
+
self.assertGreater(foreground, background)
|
|
184
|
+
|
|
185
|
+
def test_dark_query_does_not_advise_against_dark_mode(self):
|
|
186
|
+
ds = DesignSystemGenerator().generate(self.QUERY)
|
|
187
|
+
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
|
|
188
|
+
|
|
189
|
+
def test_light_query_keeps_a_light_background(self):
|
|
190
|
+
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
|
|
191
|
+
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestLightResultOutputCoherence(unittest.TestCase):
|
|
195
|
+
"""A light result that avoids dark mode must not also call dark "supported"."""
|
|
196
|
+
|
|
197
|
+
QUERY = "beauty spa wellness"
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def setUpClass(cls):
|
|
201
|
+
cls.ds = DesignSystemGenerator().generate(cls.QUERY, "Serenity Spa")
|
|
202
|
+
|
|
203
|
+
def test_dark_support_is_qualified_when_the_product_avoids_dark_mode(self):
|
|
204
|
+
self.assertIn("dark mode", self.ds["anti_patterns"].lower())
|
|
205
|
+
light, dark = _mode_support_labels(self.ds["style"], self.ds["anti_patterns"])
|
|
206
|
+
self.assertIn("avoid for this product", dark)
|
|
207
|
+
self.assertIn("avoid for this product", format_ascii_box(self.ds))
|
|
208
|
+
self.assertIn("avoid for this product", format_markdown(self.ds))
|
|
209
|
+
|
|
210
|
+
def test_labels_unchanged_when_dark_mode_is_not_avoided(self):
|
|
211
|
+
style = {"light_mode": "supported", "dark_mode": "supported"}
|
|
212
|
+
self.assertEqual(_mode_support_labels(style, "Harsh animations"),
|
|
213
|
+
("supported", "supported"))
|
|
214
|
+
|
|
215
|
+
def test_ascii_box_lines_never_overflow_the_border(self):
|
|
216
|
+
for query in (self.QUERY, "SaaS invoicing fintech B2B professional dark mode"):
|
|
217
|
+
with self.subTest(query=query):
|
|
218
|
+
ds = DesignSystemGenerator().generate(query, motion=7)
|
|
219
|
+
widths = {len(re.sub(r"\033\[[0-9;]*m", "", line))
|
|
220
|
+
for line in format_ascii_box(ds).splitlines()}
|
|
221
|
+
self.assertEqual(widths, {BOX_WIDTH + 1})
|
|
222
|
+
|
|
223
|
+
def test_master_button_text_meets_contrast_on_its_background(self):
|
|
224
|
+
colors = self.ds["colors"]
|
|
225
|
+
master = format_master_md(self.ds)
|
|
226
|
+
primary = re.search(r"\.btn-primary \{\n background: (#\w+);\n color: (#\w+);", master)
|
|
227
|
+
secondary = re.search(r"\.btn-secondary \{\n background: transparent;\n color: (#\w+);", master)
|
|
228
|
+
self.assertGreaterEqual(_contrast_ratio(primary.group(2), primary.group(1)), 4.5)
|
|
229
|
+
self.assertGreaterEqual(_contrast_ratio(secondary.group(1), colors["background"]), 4.5)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
unittest.main(verbosity=2)
|