@trishchuk/coolors-mcp 1.0.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.
Files changed (197) hide show
  1. package/.claude/settings.local.json +39 -0
  2. package/.env +2 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +71 -0
  5. package/.github/pull_request_template.md +97 -0
  6. package/.github/workflows/ci.yml +127 -0
  7. package/.github/workflows/deploy-docs.yml +56 -0
  8. package/.github/workflows/release.yml +99 -0
  9. package/.mcp.json +12 -0
  10. package/.prettierignore +1 -0
  11. package/CLAUDE.md +201 -0
  12. package/DOCUMENTATION.md +274 -0
  13. package/GEMINI.md +54 -0
  14. package/LICENSE +21 -0
  15. package/README.md +401 -0
  16. package/demo/content_based_color.png +0 -0
  17. package/demo/music-player.html +621 -0
  18. package/demo/podcast-player.html +903 -0
  19. package/dist/bin/coolors-mcp.d.ts +1 -0
  20. package/dist/bin/coolors-mcp.js +154 -0
  21. package/dist/bin/coolors-mcp.js.map +1 -0
  22. package/dist/bin/server.d.ts +1 -0
  23. package/dist/bin/server.js +3292 -0
  24. package/dist/bin/server.js.map +1 -0
  25. package/dist/chunk-IQ7NN26V.js +114 -0
  26. package/dist/chunk-IQ7NN26V.js.map +1 -0
  27. package/dist/chunk-P3ARRKLS.js +1214 -0
  28. package/dist/chunk-P3ARRKLS.js.map +1 -0
  29. package/dist/color/index.d.ts +716 -0
  30. package/dist/color/index.js +153 -0
  31. package/dist/color/index.js.map +1 -0
  32. package/dist/coolors-mcp.d.ts +136 -0
  33. package/dist/coolors-mcp.js +7 -0
  34. package/dist/coolors-mcp.js.map +1 -0
  35. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +93 -0
  36. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +7 -0
  37. package/docs/.vitepress/cache/deps/_metadata.json +127 -0
  38. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +9 -0
  39. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +7 -0
  40. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +12683 -0
  41. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +7 -0
  42. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +9719 -0
  43. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +7 -0
  44. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +4710 -0
  45. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +7 -0
  46. package/docs/.vitepress/cache/deps/cytoscape.js +30278 -0
  47. package/docs/.vitepress/cache/deps/cytoscape.js.map +7 -0
  48. package/docs/.vitepress/cache/deps/dayjs.js +285 -0
  49. package/docs/.vitepress/cache/deps/dayjs.js.map +7 -0
  50. package/docs/.vitepress/cache/deps/debug.js +468 -0
  51. package/docs/.vitepress/cache/deps/debug.js.map +7 -0
  52. package/docs/.vitepress/cache/deps/package.json +3 -0
  53. package/docs/.vitepress/cache/deps/prismjs.js +1466 -0
  54. package/docs/.vitepress/cache/deps/prismjs.js.map +7 -0
  55. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +228 -0
  56. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +7 -0
  57. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +142 -0
  58. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +7 -0
  59. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +27 -0
  60. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +7 -0
  61. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +65 -0
  62. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +7 -0
  63. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +53 -0
  64. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +7 -0
  65. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +73 -0
  66. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +7 -0
  67. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4507 -0
  68. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
  69. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +584 -0
  70. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
  71. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +1146 -0
  72. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +7 -0
  73. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +1667 -0
  74. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +7 -0
  75. package/docs/.vitepress/cache/deps/vitepress___minisearch.js +1814 -0
  76. package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +7 -0
  77. package/docs/.vitepress/cache/deps/vue.js +344 -0
  78. package/docs/.vitepress/cache/deps/vue.js.map +7 -0
  79. package/docs/.vitepress/components/ClientGrid.vue +125 -0
  80. package/docs/.vitepress/components/CodeBlock.vue +231 -0
  81. package/docs/.vitepress/components/ConfigModal.vue +477 -0
  82. package/docs/.vitepress/components/DiagramModal.vue +528 -0
  83. package/docs/.vitepress/components/TroubleshootingModal.vue +472 -0
  84. package/docs/.vitepress/config.js +162 -0
  85. package/docs/.vitepress/theme/FundingLayout.vue +251 -0
  86. package/docs/.vitepress/theme/Layout.vue +134 -0
  87. package/docs/.vitepress/theme/components/AdBanner.vue +317 -0
  88. package/docs/.vitepress/theme/components/AdPlaceholder.vue +78 -0
  89. package/docs/.vitepress/theme/components/FundingEffects.vue +322 -0
  90. package/docs/.vitepress/theme/components/FundingHero.vue +345 -0
  91. package/docs/.vitepress/theme/components/SupportSection.vue +511 -0
  92. package/docs/.vitepress/theme/custom-app.css +339 -0
  93. package/docs/.vitepress/theme/custom.css +699 -0
  94. package/docs/.vitepress/theme/index.js +25 -0
  95. package/docs/README.md +198 -0
  96. package/docs/concepts/accessibility.md +473 -0
  97. package/docs/concepts/color-spaces.md +222 -0
  98. package/docs/concepts/distance-metrics.md +384 -0
  99. package/docs/concepts/hct.md +261 -0
  100. package/docs/concepts/image-analysis.md +396 -0
  101. package/docs/concepts/material-design.md +306 -0
  102. package/docs/concepts/theme-matching.md +399 -0
  103. package/docs/examples/basic-colors.md +490 -0
  104. package/docs/examples/creating-themes.md +898 -0
  105. package/docs/examples/css-refactoring.md +824 -0
  106. package/docs/examples/image-extraction.md +882 -0
  107. package/docs/getting-started.md +366 -0
  108. package/docs/index.md +190 -0
  109. package/docs/installation.md +157 -0
  110. package/docs/tools/README.md +234 -0
  111. package/docs/tools/accessibility.md +614 -0
  112. package/docs/tools/color-operations.md +374 -0
  113. package/docs/tools/image-extraction.md +624 -0
  114. package/docs/tools/material-design.md +347 -0
  115. package/docs/tools/theme-matching.md +552 -0
  116. package/eslint.config.ts +14 -0
  117. package/examples/theme-matching.md +113 -0
  118. package/jsr.json +7 -0
  119. package/mcp-config.json +8 -0
  120. package/note.md +35 -0
  121. package/package.json +122 -0
  122. package/research_results.md +53 -0
  123. package/src/bin/coolors-mcp.ts +194 -0
  124. package/src/bin/server.ts +61 -0
  125. package/src/color/__tests__/conversions-argb.test.ts +198 -0
  126. package/src/color/__tests__/extract-colors.test.ts +360 -0
  127. package/src/color/__tests__/image-utils.test.ts +242 -0
  128. package/src/color/__tests__/reference-colors.test.ts +278 -0
  129. package/src/color/__tests__/round-trip.test.ts +197 -0
  130. package/src/color/conversions.test.ts +402 -0
  131. package/src/color/conversions.ts +393 -0
  132. package/src/color/dislike/__tests__/dislike-analyzer.test.ts +248 -0
  133. package/src/color/dislike/dislike-analyzer.ts +114 -0
  134. package/src/color/extract-colors.ts +228 -0
  135. package/src/color/hct/__tests__/hct-class.test.ts +232 -0
  136. package/src/color/hct/harmonization.ts +204 -0
  137. package/src/color/hct/hct-class.ts +109 -0
  138. package/src/color/hct/hct-solver.ts +168 -0
  139. package/src/color/hct/index.ts +39 -0
  140. package/src/color/hct/tonal-palette.ts +211 -0
  141. package/src/color/hct/types.ts +88 -0
  142. package/src/color/image-utils.ts +79 -0
  143. package/src/color/index.ts +87 -0
  144. package/src/color/material-theme.ts +157 -0
  145. package/src/color/metrics.test.ts +276 -0
  146. package/src/color/metrics.ts +281 -0
  147. package/src/color/quantize/__tests__/quantizer_celebi.test.ts +195 -0
  148. package/src/color/quantize/lab_point_provider.ts +55 -0
  149. package/src/color/quantize/point_provider.ts +27 -0
  150. package/src/color/quantize/quantizer_celebi.ts +51 -0
  151. package/src/color/quantize/quantizer_celebi_test.ts +71 -0
  152. package/src/color/quantize/quantizer_map.ts +47 -0
  153. package/src/color/quantize/quantizer_wsmeans.ts +232 -0
  154. package/src/color/quantize/quantizer_wu.ts +472 -0
  155. package/src/color/score/__tests__/score.test.ts +224 -0
  156. package/src/color/score/score.ts +175 -0
  157. package/src/color/types.ts +151 -0
  158. package/src/color/utils/color_utils.ts +292 -0
  159. package/src/color/utils/math_utils.ts +145 -0
  160. package/src/color/utils.test.ts +403 -0
  161. package/src/color/utils.ts +315 -0
  162. package/src/constants.ts +5 -0
  163. package/src/coolors-mcp.ts +37 -0
  164. package/src/examples/addition.ts +333 -0
  165. package/src/examples/color-demo.ts +125 -0
  166. package/src/examples/custom-logger.ts +201 -0
  167. package/src/examples/oauth-server.ts +113 -0
  168. package/src/examples/session-context.ts +269 -0
  169. package/src/session.ts +116 -0
  170. package/src/theme/__tests__/matcher.test.ts +180 -0
  171. package/src/theme/__tests__/parser.test.ts +148 -0
  172. package/src/theme/__tests__/refactor.test.ts +224 -0
  173. package/src/theme/index.ts +34 -0
  174. package/src/theme/matcher.ts +395 -0
  175. package/src/theme/parser.ts +392 -0
  176. package/src/theme/refactor.ts +360 -0
  177. package/src/theme/types.ts +152 -0
  178. package/src/tools/__tests__/gradient-generator.test.ts +206 -0
  179. package/src/tools/__tests__/palette-with-locks.test.ts +109 -0
  180. package/src/tools/color-conversion.tool.ts +54 -0
  181. package/src/tools/color-distance.tool.ts +41 -0
  182. package/src/tools/colors.ts +31 -0
  183. package/src/tools/contrast-checker.tool.ts +37 -0
  184. package/src/tools/dislike-analyzer.tool.ts +247 -0
  185. package/src/tools/gradient-generator.tool.ts +250 -0
  186. package/src/tools/image-extraction.tools.ts +289 -0
  187. package/src/tools/index.ts +39 -0
  188. package/src/tools/material-theme.tools.ts +250 -0
  189. package/src/tools/palette-generator.tool.ts +135 -0
  190. package/src/tools/palette-with-locks.tool.ts +221 -0
  191. package/src/tools/registry.ts +142 -0
  192. package/src/tools/simple-tools.ts +37 -0
  193. package/src/tools/theme-matching.tools.ts +334 -0
  194. package/src/types.ts +182 -0
  195. package/src/utils.ts +22 -0
  196. package/tsconfig.json +8 -0
  197. package/vitest.config.js +15 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Tests for color extraction from images
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { extractColors, extractThemePalette } from "../extract-colors.js";
8
+ import * as utils from "../utils/color_utils.js";
9
+
10
+ // Mock the quantizer and scorer for predictable tests
11
+ vi.mock("../quantize/quantizer_celebi", () => ({
12
+ QuantizerCelebi: {
13
+ quantize: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ vi.mock("../score/score", () => ({
18
+ Score: {
19
+ score: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ describe("Color Extraction", () => {
24
+ describe("extractColors", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it("should extract colors with default options", async () => {
30
+ const { QuantizerCelebi } = await import(
31
+ "../quantize/quantizer_celebi.js"
32
+ );
33
+ const { Score } = await import("../score/score.js");
34
+
35
+ // Mock quantizer to return test colors
36
+ const mockQuantized = new Map([
37
+ [utils.argbFromRgb(0, 0, 255), 60], // Blue
38
+ [utils.argbFromRgb(0, 255, 0), 80], // Green
39
+ [utils.argbFromRgb(255, 0, 0), 100], // Red
40
+ ]);
41
+
42
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
43
+ vi.mocked(Score.score).mockReturnValue([
44
+ utils.argbFromRgb(255, 0, 0),
45
+ utils.argbFromRgb(0, 255, 0),
46
+ ]);
47
+
48
+ const imageData = {
49
+ data: new Uint8ClampedArray([
50
+ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
51
+ ]),
52
+ height: 1,
53
+ width: 3,
54
+ };
55
+
56
+ const colors = extractColors(imageData);
57
+
58
+ expect(colors).toHaveLength(2);
59
+ expect(colors[0].hex).toBe("#ff0000");
60
+ expect(colors[0].rgb).toEqual({ b: 0, g: 0, r: 255 });
61
+ expect(colors[0].population).toBe(100);
62
+ expect(colors[0].percentage).toBeCloseTo(41.67, 1);
63
+ });
64
+
65
+ it("should extract colors with low quality setting", async () => {
66
+ const { QuantizerCelebi } = await import("../quantize/quantizer_celebi");
67
+
68
+ const mockQuantized = new Map([[utils.argbFromRgb(128, 128, 128), 50]]);
69
+
70
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
71
+
72
+ const imageData = {
73
+ data: new Uint8ClampedArray(100 * 100 * 4), // Large image
74
+ height: 100,
75
+ width: 100,
76
+ };
77
+
78
+ extractColors(imageData, { quality: "low" });
79
+
80
+ // Check that quantizer was called with correct max colors for low quality
81
+ expect(QuantizerCelebi.quantize).toHaveBeenCalledWith(
82
+ expect.any(Array),
83
+ 64, // Low quality uses 64 colors
84
+ );
85
+ });
86
+
87
+ it("should extract colors with high quality setting", async () => {
88
+ const { QuantizerCelebi } = await import("../quantize/quantizer_celebi");
89
+
90
+ const mockQuantized = new Map([[utils.argbFromRgb(128, 128, 128), 50]]);
91
+
92
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
93
+
94
+ const imageData = {
95
+ data: new Uint8ClampedArray(100 * 100 * 4),
96
+ height: 100,
97
+ width: 100,
98
+ };
99
+
100
+ extractColors(imageData, { quality: "high" });
101
+
102
+ expect(QuantizerCelebi.quantize).toHaveBeenCalledWith(
103
+ expect.any(Array),
104
+ 256, // High quality uses 256 colors
105
+ );
106
+ });
107
+
108
+ it("should extract colors without scoring", async () => {
109
+ const { QuantizerCelebi } = await import(
110
+ "../quantize/quantizer_celebi.js"
111
+ );
112
+ const { Score } = await import("../score/score.js");
113
+
114
+ const mockQuantized = new Map([
115
+ [utils.argbFromRgb(0, 0, 255), 60],
116
+ [utils.argbFromRgb(0, 255, 0), 80],
117
+ [utils.argbFromRgb(255, 0, 0), 100],
118
+ ]);
119
+
120
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
121
+
122
+ const imageData = {
123
+ data: new Uint8ClampedArray([255, 0, 0, 255]),
124
+ height: 1,
125
+ width: 1,
126
+ };
127
+
128
+ const colors = extractColors(imageData, {
129
+ maxColors: 2,
130
+ scoringEnabled: false,
131
+ });
132
+
133
+ // Should not call Score.score
134
+ expect(Score.score).not.toHaveBeenCalled();
135
+
136
+ // Should return top 2 by population
137
+ expect(colors).toHaveLength(2);
138
+ expect(colors[0].hex).toBe("#ff0000");
139
+ expect(colors[1].hex).toBe("#00ff00");
140
+ });
141
+
142
+ it("should handle empty image data", async () => {
143
+ const { QuantizerCelebi } = await import(
144
+ "../quantize/quantizer_celebi.js"
145
+ );
146
+ const { Score } = await import("../score/score.js");
147
+
148
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(new Map());
149
+ vi.mocked(Score.score).mockReturnValue([]);
150
+
151
+ const imageData = {
152
+ data: new Uint8ClampedArray([]),
153
+ height: 0,
154
+ width: 0,
155
+ };
156
+
157
+ const colors = extractColors(imageData);
158
+ expect(colors).toHaveLength(0);
159
+ });
160
+
161
+ it("should calculate correct percentages", async () => {
162
+ const { QuantizerCelebi } = await import(
163
+ "../quantize/quantizer_celebi.js"
164
+ );
165
+ const { Score } = await import("../score/score.js");
166
+
167
+ const mockQuantized = new Map([
168
+ [utils.argbFromRgb(0, 255, 0), 25],
169
+ [utils.argbFromRgb(255, 0, 0), 75],
170
+ ]);
171
+
172
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
173
+ vi.mocked(Score.score).mockReturnValue([
174
+ utils.argbFromRgb(255, 0, 0),
175
+ utils.argbFromRgb(0, 255, 0),
176
+ ]);
177
+
178
+ const imageData = {
179
+ data: new Uint8ClampedArray([255, 0, 0, 255]),
180
+ height: 1,
181
+ width: 1,
182
+ };
183
+
184
+ const colors = extractColors(imageData);
185
+
186
+ expect(colors[0].percentage).toBe(75);
187
+ expect(colors[1].percentage).toBe(25);
188
+ });
189
+ });
190
+
191
+ describe("extractThemePalette", () => {
192
+ beforeEach(() => {
193
+ vi.clearAllMocks();
194
+ });
195
+
196
+ it("should extract theme palette with primary color", async () => {
197
+ const { QuantizerCelebi } = await import(
198
+ "../quantize/quantizer_celebi.js"
199
+ );
200
+ const { Score } = await import("../score/score.js");
201
+
202
+ const mockQuantized = new Map([
203
+ [utils.argbFromRgb(103, 80, 164), 100], // Primary purple
204
+ ]);
205
+
206
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
207
+ vi.mocked(Score.score).mockReturnValue([utils.argbFromRgb(103, 80, 164)]);
208
+
209
+ const imageData = {
210
+ data: new Uint8ClampedArray([103, 80, 164, 255]),
211
+ height: 1,
212
+ width: 1,
213
+ };
214
+
215
+ const palette = extractThemePalette(imageData);
216
+
217
+ expect(palette.primary).toBeDefined();
218
+ expect(palette.primary.hex).toBe("#6750a4");
219
+ });
220
+
221
+ it("should extract secondary with different hue", async () => {
222
+ const { QuantizerCelebi } = await import(
223
+ "../quantize/quantizer_celebi.js"
224
+ );
225
+ const { Score } = await import("../score/score.js");
226
+
227
+ const mockQuantized = new Map([
228
+ [utils.argbFromRgb(103, 80, 164), 100], // Purple
229
+ [utils.argbFromRgb(110, 80, 164), 60], // Similar purple
230
+ [utils.argbFromRgb(255, 0, 0), 80], // Red (very different hue)
231
+ ]);
232
+
233
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
234
+ vi.mocked(Score.score).mockReturnValue([
235
+ utils.argbFromRgb(103, 80, 164),
236
+ utils.argbFromRgb(255, 0, 0),
237
+ utils.argbFromRgb(110, 80, 164),
238
+ ]);
239
+
240
+ const imageData = {
241
+ data: new Uint8ClampedArray([103, 80, 164, 255]),
242
+ height: 1,
243
+ width: 1,
244
+ };
245
+
246
+ const palette = extractThemePalette(imageData);
247
+
248
+ expect(palette.primary).toBeDefined();
249
+ expect(palette.secondary).toBeDefined();
250
+ expect(palette.secondary?.hex).toBe("#ff0000"); // Red should be secondary
251
+ });
252
+
253
+ it("should find neutral color with low chroma", async () => {
254
+ const { QuantizerCelebi } = await import(
255
+ "../quantize/quantizer_celebi.js"
256
+ );
257
+ const { Score } = await import("../score/score.js");
258
+
259
+ const mockQuantized = new Map([
260
+ [utils.argbFromRgb(103, 80, 164), 100], // Primary
261
+ [utils.argbFromRgb(128, 128, 128), 50], // Gray (low chroma)
262
+ ]);
263
+
264
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
265
+ vi.mocked(Score.score).mockReturnValue([
266
+ utils.argbFromRgb(103, 80, 164),
267
+ utils.argbFromRgb(128, 128, 128),
268
+ ]);
269
+
270
+ const imageData = {
271
+ data: new Uint8ClampedArray([103, 80, 164, 255]),
272
+ height: 1,
273
+ width: 1,
274
+ };
275
+
276
+ const palette = extractThemePalette(imageData);
277
+
278
+ expect(palette.neutral).toBeDefined();
279
+ expect(palette.neutral?.hct.c).toBeLessThan(20); // Low chroma
280
+ });
281
+
282
+ it("should find error color in red hue range", async () => {
283
+ const { QuantizerCelebi } = await import(
284
+ "../quantize/quantizer_celebi.js"
285
+ );
286
+ const { Score } = await import("../score/score.js");
287
+
288
+ const primaryArgb = utils.argbFromRgb(103, 80, 164);
289
+ const redArgb = utils.argbFromRgb(255, 20, 20);
290
+
291
+ const mockQuantized = new Map([
292
+ [primaryArgb, 100], // Primary
293
+ [redArgb, 50], // Red for error
294
+ ]);
295
+
296
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
297
+ vi.mocked(Score.score).mockReturnValue([primaryArgb, redArgb]);
298
+
299
+ const imageData = {
300
+ data: new Uint8ClampedArray([103, 80, 164, 255]),
301
+ height: 1,
302
+ width: 1,
303
+ };
304
+
305
+ const palette = extractThemePalette(imageData);
306
+
307
+ expect(palette.error).toBeDefined();
308
+ // Red/orange hue should be 0-40 or 350-360
309
+ const errorHue = palette.error?.hct.h || 0;
310
+ expect(errorHue <= 40 || errorHue >= 350).toBe(true);
311
+ });
312
+
313
+ it("should throw error for empty image", async () => {
314
+ const { QuantizerCelebi } = await import(
315
+ "../quantize/quantizer_celebi.js"
316
+ );
317
+ const { Score } = await import("../score/score.js");
318
+
319
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(new Map());
320
+ vi.mocked(Score.score).mockReturnValue([]);
321
+
322
+ const imageData = {
323
+ data: new Uint8ClampedArray([]),
324
+ height: 0,
325
+ width: 0,
326
+ };
327
+
328
+ expect(() => extractThemePalette(imageData)).toThrow(
329
+ "No colors could be extracted from image",
330
+ );
331
+ });
332
+
333
+ it("should handle single color image", async () => {
334
+ const { QuantizerCelebi } = await import(
335
+ "../quantize/quantizer_celebi.js"
336
+ );
337
+ const { Score } = await import("../score/score.js");
338
+
339
+ const mockQuantized = new Map([
340
+ [utils.argbFromRgb(0, 128, 255), 100], // Single blue color
341
+ ]);
342
+
343
+ vi.mocked(QuantizerCelebi.quantize).mockReturnValue(mockQuantized);
344
+ vi.mocked(Score.score).mockReturnValue([utils.argbFromRgb(0, 128, 255)]);
345
+
346
+ const imageData = {
347
+ data: new Uint8ClampedArray([0, 128, 255, 255]),
348
+ height: 1,
349
+ width: 1,
350
+ };
351
+
352
+ const palette = extractThemePalette(imageData);
353
+
354
+ expect(palette.primary).toBeDefined();
355
+ expect(palette.primary.hex).toBe("#0080ff");
356
+ expect(palette.secondary).toBeUndefined();
357
+ expect(palette.tertiary).toBeUndefined();
358
+ });
359
+ });
360
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for image processing utilities
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import {
8
+ filterExtremeTones,
9
+ imageDataToPixels,
10
+ samplePixels,
11
+ } from "../image-utils";
12
+ import * as utils from "../utils/color_utils";
13
+
14
+ describe("Image Processing Utilities", () => {
15
+ describe("imageDataToPixels", () => {
16
+ it("should convert RGBA data to ARGB pixels", () => {
17
+ const imageData = {
18
+ data: new Uint8ClampedArray([
19
+ 255,
20
+ 0,
21
+ 0,
22
+ 255, // Red pixel
23
+ 0,
24
+ 255,
25
+ 0,
26
+ 255, // Green pixel
27
+ 0,
28
+ 0,
29
+ 255,
30
+ 255, // Blue pixel
31
+ ]),
32
+ height: 1,
33
+ width: 3,
34
+ };
35
+
36
+ const pixels = imageDataToPixels(imageData);
37
+
38
+ expect(pixels).toHaveLength(3);
39
+ expect(pixels[0]).toBe(utils.argbFromRgb(255, 0, 0));
40
+ expect(pixels[1]).toBe(utils.argbFromRgb(0, 255, 0));
41
+ expect(pixels[2]).toBe(utils.argbFromRgb(0, 0, 255));
42
+ });
43
+
44
+ it("should skip transparent pixels", () => {
45
+ const imageData = {
46
+ data: new Uint8ClampedArray([
47
+ 255,
48
+ 0,
49
+ 0,
50
+ 255, // Opaque red
51
+ 0,
52
+ 255,
53
+ 0,
54
+ 0, // Fully transparent green
55
+ 0,
56
+ 0,
57
+ 255,
58
+ 128, // Semi-transparent blue
59
+ 255,
60
+ 255,
61
+ 0,
62
+ 2, // Nearly transparent yellow (< 1%)
63
+ ]),
64
+ height: 2,
65
+ width: 2,
66
+ };
67
+
68
+ const pixels = imageDataToPixels(imageData);
69
+
70
+ // Should keep opaque and semi-transparent, skip fully and nearly transparent
71
+ expect(pixels).toHaveLength(2);
72
+ expect(pixels[0]).toBe(utils.argbFromRgb(255, 0, 0));
73
+ expect(pixels[1]).toBe(utils.argbFromRgb(0, 0, 255));
74
+ });
75
+
76
+ it("should handle regular number array input", () => {
77
+ const imageData = {
78
+ data: [255, 255, 255, 255, 0, 0, 0, 255],
79
+ height: 1,
80
+ width: 2,
81
+ };
82
+
83
+ const pixels = imageDataToPixels(imageData);
84
+
85
+ expect(pixels).toHaveLength(2);
86
+ expect(pixels[0]).toBe(utils.argbFromRgb(255, 255, 255));
87
+ expect(pixels[1]).toBe(utils.argbFromRgb(0, 0, 0));
88
+ });
89
+
90
+ it("should handle empty image data", () => {
91
+ const imageData = {
92
+ data: new Uint8ClampedArray([]),
93
+ height: 0,
94
+ width: 0,
95
+ };
96
+
97
+ const pixels = imageDataToPixels(imageData);
98
+ expect(pixels).toHaveLength(0);
99
+ });
100
+
101
+ it("should handle single pixel", () => {
102
+ const imageData = {
103
+ data: new Uint8ClampedArray([128, 64, 192, 255]),
104
+ height: 1,
105
+ width: 1,
106
+ };
107
+
108
+ const pixels = imageDataToPixels(imageData);
109
+ expect(pixels).toHaveLength(1);
110
+ expect(pixels[0]).toBe(utils.argbFromRgb(128, 64, 192));
111
+ });
112
+ });
113
+
114
+ describe("samplePixels", () => {
115
+ it("should return all pixels when under limit", () => {
116
+ const pixels = [1, 2, 3, 4, 5];
117
+ const sampled = samplePixels(pixels, 10);
118
+
119
+ expect(sampled).toEqual(pixels);
120
+ });
121
+
122
+ it("should sample evenly when over limit", () => {
123
+ const pixels = Array.from({ length: 100 }, (_, i) => i);
124
+ const sampled = samplePixels(pixels, 10);
125
+
126
+ expect(sampled).toHaveLength(10);
127
+ expect(sampled[0]).toBe(0);
128
+ expect(sampled[1]).toBe(10);
129
+ expect(sampled[2]).toBe(20);
130
+ expect(sampled[9]).toBe(90);
131
+ });
132
+
133
+ it("should handle exact limit", () => {
134
+ const pixels = [1, 2, 3, 4, 5];
135
+ const sampled = samplePixels(pixels, 5);
136
+
137
+ expect(sampled).toEqual(pixels);
138
+ });
139
+
140
+ it("should handle single pixel with limit 1", () => {
141
+ const pixels = [42];
142
+ const sampled = samplePixels(pixels, 1);
143
+
144
+ expect(sampled).toEqual([42]);
145
+ });
146
+
147
+ it("should use default maxPixels of 10000", () => {
148
+ const pixels = Array.from({ length: 5000 }, (_, i) => i);
149
+ const sampled = samplePixels(pixels);
150
+
151
+ expect(sampled).toHaveLength(5000); // Under default limit
152
+ });
153
+
154
+ it("should sample large arrays with default limit", () => {
155
+ const pixels = Array.from({ length: 20000 }, (_, i) => i);
156
+ const sampled = samplePixels(pixels);
157
+
158
+ expect(sampled.length).toBeLessThanOrEqual(10000);
159
+ expect(sampled[0]).toBe(0);
160
+ });
161
+ });
162
+
163
+ describe("filterExtremeTones", () => {
164
+ it("should filter near-black pixels", () => {
165
+ const blackPixel = utils.argbFromRgb(10, 10, 10); // Very dark
166
+ const midPixel = utils.argbFromRgb(128, 128, 128); // Middle gray
167
+
168
+ const pixels = [blackPixel, midPixel];
169
+ const filtered = filterExtremeTones(pixels);
170
+
171
+ expect(filtered).toHaveLength(1);
172
+ expect(filtered[0]).toBe(midPixel);
173
+ });
174
+
175
+ it("should filter near-white pixels", () => {
176
+ const whitePixel = utils.argbFromRgb(250, 250, 250); // Very light
177
+ const midPixel = utils.argbFromRgb(128, 128, 128); // Middle gray
178
+
179
+ const pixels = [whitePixel, midPixel];
180
+ const filtered = filterExtremeTones(pixels);
181
+
182
+ expect(filtered).toHaveLength(1);
183
+ expect(filtered[0]).toBe(midPixel);
184
+ });
185
+
186
+ it("should keep mid-tone pixels", () => {
187
+ const pixels = [
188
+ utils.argbFromRgb(50, 50, 50), // Dark but not too dark
189
+ utils.argbFromRgb(100, 100, 100), // Mid-dark
190
+ utils.argbFromRgb(150, 150, 150), // Mid-light
191
+ utils.argbFromRgb(200, 200, 200), // Light but not too light
192
+ ];
193
+
194
+ const filtered = filterExtremeTones(pixels);
195
+ expect(filtered).toHaveLength(4);
196
+ });
197
+
198
+ it("should handle colored pixels correctly", () => {
199
+ const pixels = [
200
+ utils.argbFromRgb(255, 0, 0), // Pure red - should be kept
201
+ utils.argbFromRgb(0, 255, 0), // Pure green - should be kept
202
+ utils.argbFromRgb(0, 0, 255), // Pure blue - should be kept
203
+ utils.argbFromRgb(5, 5, 5), // Near black - should be filtered
204
+ utils.argbFromRgb(250, 250, 250), // Near white - should be filtered
205
+ ];
206
+
207
+ const filtered = filterExtremeTones(pixels);
208
+ expect(filtered).toHaveLength(3);
209
+
210
+ // Check that primary colors are kept
211
+ const filteredRgbs = filtered.map((pixel) => ({
212
+ b: utils.blueFromArgb(pixel),
213
+ g: utils.greenFromArgb(pixel),
214
+ r: utils.redFromArgb(pixel),
215
+ }));
216
+
217
+ expect(filteredRgbs).toContainEqual({ b: 0, g: 0, r: 255 });
218
+ expect(filteredRgbs).toContainEqual({ b: 0, g: 255, r: 0 });
219
+ expect(filteredRgbs).toContainEqual({ b: 255, g: 0, r: 0 });
220
+ });
221
+
222
+ it("should handle empty array", () => {
223
+ const filtered = filterExtremeTones([]);
224
+ expect(filtered).toHaveLength(0);
225
+ });
226
+
227
+ it("should filter based on luminance calculation", () => {
228
+ // Test edge cases for luminance calculation
229
+ // Luminance = 0.299 * R + 0.587 * G + 0.114 * B
230
+
231
+ // Just above 5% threshold (12.75)
232
+ const justAbove = utils.argbFromRgb(13, 13, 13);
233
+ // Just below 95% threshold (242.25)
234
+ const justBelow = utils.argbFromRgb(242, 242, 242);
235
+
236
+ const pixels = [justAbove, justBelow];
237
+ const filtered = filterExtremeTones(pixels);
238
+
239
+ expect(filtered).toHaveLength(2); // Both should be kept
240
+ });
241
+ });
242
+ });