@hyperspell/openclaw-hyperspell 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -65
- package/client.ts +8 -2
- package/commands/setup.ts +1 -0
- package/config.ts +9 -3
- package/index.ts +0 -7
- package/openclaw.plugin.json +3 -0
- package/package.json +14 -7
- package/commands/wine.ts +0 -164
- package/lib/run-script.ts +0 -32
- package/sommeliagent/references/cross-domain-mappings.md +0 -63
- package/sommeliagent/scripts/auth.py +0 -222
- package/sommeliagent/scripts/history.py +0 -72
- package/sommeliagent/scripts/rate.py +0 -76
- package/sommeliagent/scripts/recommend.py +0 -777
- package/sommeliagent/scripts/test_recommend.py +0 -459
- package/sommeliagent/scripts/wine_db.py +0 -3224
- package/tools/sommelier.ts +0 -254
|
@@ -1,459 +0,0 @@
|
|
|
1
|
-
# /// script
|
|
2
|
-
# requires-python = ">=3.11"
|
|
3
|
-
# dependencies = ["pytest", "httpx"]
|
|
4
|
-
# ///
|
|
5
|
-
"""Tests for SommeliAgent recommendation engine."""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
# Import from recommend.py in same directory
|
|
12
|
-
sys.path.insert(0, str(Path(__file__).parent))
|
|
13
|
-
from wine_db import WineProfile, Wine, WINE_DB, wp
|
|
14
|
-
from recommend import (
|
|
15
|
-
MusicProfile,
|
|
16
|
-
clamp01,
|
|
17
|
-
weighted_avg,
|
|
18
|
-
music_to_wine_profile,
|
|
19
|
-
score_wine_match,
|
|
20
|
-
generate_connections,
|
|
21
|
-
recommend_wines,
|
|
22
|
-
normalize_scores,
|
|
23
|
-
genre_affinity,
|
|
24
|
-
estimate_features_from_genres,
|
|
25
|
-
derive_mood,
|
|
26
|
-
load_rating_boosts,
|
|
27
|
-
DEMO_PROFILE,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# ──────────────────────────────────────────────
|
|
32
|
-
# Utility tests
|
|
33
|
-
# ──────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
class TestClamp01:
|
|
36
|
-
def test_within_range(self):
|
|
37
|
-
assert clamp01(0.5) == 0.5
|
|
38
|
-
|
|
39
|
-
def test_below_zero(self):
|
|
40
|
-
assert clamp01(-0.5) == 0.0
|
|
41
|
-
|
|
42
|
-
def test_above_one(self):
|
|
43
|
-
assert clamp01(1.5) == 1.0
|
|
44
|
-
|
|
45
|
-
def test_boundaries(self):
|
|
46
|
-
assert clamp01(0.0) == 0.0
|
|
47
|
-
assert clamp01(1.0) == 1.0
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class TestWeightedAvg:
|
|
51
|
-
def test_equal_weights(self):
|
|
52
|
-
result = weighted_avg([(0.0, 1.0), (1.0, 1.0)])
|
|
53
|
-
assert abs(result - 0.5) < 0.01
|
|
54
|
-
|
|
55
|
-
def test_single_value(self):
|
|
56
|
-
assert weighted_avg([(0.7, 1.0)]) == 0.7
|
|
57
|
-
|
|
58
|
-
def test_empty(self):
|
|
59
|
-
assert weighted_avg([]) == 0.0
|
|
60
|
-
|
|
61
|
-
def test_clamps_inputs(self):
|
|
62
|
-
result = weighted_avg([(2.0, 1.0), (-1.0, 1.0)])
|
|
63
|
-
assert 0.0 <= result <= 1.0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class TestDeriveMood:
|
|
67
|
-
def test_euphoric(self):
|
|
68
|
-
assert derive_mood(0.8, 0.8) == "euphoric"
|
|
69
|
-
|
|
70
|
-
def test_melancholic(self):
|
|
71
|
-
assert derive_mood(0.2, 0.2) == "melancholic"
|
|
72
|
-
|
|
73
|
-
def test_intense(self):
|
|
74
|
-
assert derive_mood(0.2, 0.8) == "intense"
|
|
75
|
-
|
|
76
|
-
def test_serene(self):
|
|
77
|
-
assert derive_mood(0.8, 0.2) == "serene"
|
|
78
|
-
|
|
79
|
-
def test_middle_ground(self):
|
|
80
|
-
mood = derive_mood(0.5, 0.5)
|
|
81
|
-
assert mood in ("upbeat", "driven", "contemplative")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# ──────────────────────────────────────────────
|
|
85
|
-
# Genre feature estimation tests
|
|
86
|
-
# ──────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
class TestGenreAffinity:
|
|
89
|
-
def test_matching_genres(self):
|
|
90
|
-
genres = {"jazz": 0.5, "classical": 0.3, "pop": 0.2}
|
|
91
|
-
assert genre_affinity(genres, ["jazz", "classical"]) == 0.8
|
|
92
|
-
|
|
93
|
-
def test_no_match(self):
|
|
94
|
-
genres = {"jazz": 0.5, "classical": 0.5}
|
|
95
|
-
assert genre_affinity(genres, ["metal", "punk"]) == 0.0
|
|
96
|
-
|
|
97
|
-
def test_empty_genres(self):
|
|
98
|
-
assert genre_affinity({}, ["jazz"]) == 0.0
|
|
99
|
-
|
|
100
|
-
def test_word_boundary_match(self):
|
|
101
|
-
genres = {"indie rock": 0.5, "art rock": 0.3}
|
|
102
|
-
assert genre_affinity(genres, ["rock"]) == 0.8
|
|
103
|
-
|
|
104
|
-
def test_no_false_positive_substring(self):
|
|
105
|
-
"""'art' keyword should NOT match 'martial industrial'."""
|
|
106
|
-
genres = {"martial industrial": 0.5}
|
|
107
|
-
assert genre_affinity(genres, ["art"]) == 0.0
|
|
108
|
-
|
|
109
|
-
def test_compound_genre_match(self):
|
|
110
|
-
"""'post-punk' keyword should match 'post-punk' genre."""
|
|
111
|
-
genres = {"post-punk": 0.5, "darkwave": 0.3}
|
|
112
|
-
assert genre_affinity(genres, ["post-punk"]) == 0.5
|
|
113
|
-
|
|
114
|
-
def test_no_double_counting(self):
|
|
115
|
-
"""A genre should only be counted once even if multiple keywords match."""
|
|
116
|
-
genres = {"pop punk": 0.5}
|
|
117
|
-
# Both "pop" and "punk" could match, but weight should be 0.5 not 1.0
|
|
118
|
-
assert genre_affinity(genres, ["pop", "punk"]) == 0.5
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
class TestEstimateFeaturesFromGenres:
|
|
122
|
-
def test_jazz_listener(self):
|
|
123
|
-
artists = [{"genres": ["jazz"]}, {"genres": ["jazz", "bebop"]}]
|
|
124
|
-
valence, energy, dance, acoustic, tempo, complexity = estimate_features_from_genres(artists)
|
|
125
|
-
assert complexity > 0.7 # Jazz = complex
|
|
126
|
-
assert acoustic > 0.5 # Jazz = acoustic
|
|
127
|
-
|
|
128
|
-
def test_edm_listener(self):
|
|
129
|
-
artists = [{"genres": ["edm"]}, {"genres": ["house"]}]
|
|
130
|
-
valence, energy, dance, acoustic, tempo, complexity = estimate_features_from_genres(artists)
|
|
131
|
-
assert energy > 0.6
|
|
132
|
-
assert dance > 0.7
|
|
133
|
-
assert acoustic < 0.2
|
|
134
|
-
|
|
135
|
-
def test_no_genres(self):
|
|
136
|
-
artists = [{"genres": []}, {"genres": []}]
|
|
137
|
-
result = estimate_features_from_genres(artists)
|
|
138
|
-
assert all(0.0 <= v <= 200.0 for v in result) # tempo can be up to 200
|
|
139
|
-
|
|
140
|
-
def test_empty_artists(self):
|
|
141
|
-
result = estimate_features_from_genres([])
|
|
142
|
-
assert len(result) == 6
|
|
143
|
-
|
|
144
|
-
def test_no_genres_returns_bpm_not_normalized(self):
|
|
145
|
-
"""Empty genre list should still return tempo in BPM, not 0-1."""
|
|
146
|
-
artists = [{"genres": []}, {"genres": []}]
|
|
147
|
-
_, _, _, _, tempo, _ = estimate_features_from_genres(artists)
|
|
148
|
-
assert tempo > 50, f"Tempo should be BPM (>50), got {tempo}"
|
|
149
|
-
|
|
150
|
-
def test_tempo_always_in_bpm_range(self):
|
|
151
|
-
"""All paths should return tempo as BPM (roughly 60-200)."""
|
|
152
|
-
# With genres
|
|
153
|
-
artists_with = [{"genres": ["jazz"]}, {"genres": ["rock"]}]
|
|
154
|
-
_, _, _, _, tempo_with, _ = estimate_features_from_genres(artists_with)
|
|
155
|
-
assert 60 < tempo_with < 200, f"Tempo with genres: {tempo_with}"
|
|
156
|
-
# Without genres
|
|
157
|
-
artists_without = [{"genres": []}]
|
|
158
|
-
_, _, _, _, tempo_without, _ = estimate_features_from_genres(artists_without)
|
|
159
|
-
assert 60 < tempo_without < 200, f"Tempo without genres: {tempo_without}"
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
# ──────────────────────────────────────────────
|
|
163
|
-
# Mapping engine tests
|
|
164
|
-
# ──────────────────────────────────────────────
|
|
165
|
-
|
|
166
|
-
class TestMusicToWineProfile:
|
|
167
|
-
def test_all_dimensions_in_range(self):
|
|
168
|
-
profile = music_to_wine_profile(DEMO_PROFILE)
|
|
169
|
-
for dim in ["body", "sweetness", "tannin", "acidity", "complexity", "fruitiness", "earthiness", "spiciness"]:
|
|
170
|
-
val = getattr(profile, dim)
|
|
171
|
-
assert 0.0 <= val <= 1.0, f"{dim} out of range: {val}"
|
|
172
|
-
|
|
173
|
-
def test_dark_complex_listener(self):
|
|
174
|
-
"""Dark, complex music should map to tannic, earthy, complex wines."""
|
|
175
|
-
dark = MusicProfile(
|
|
176
|
-
avg_valence=0.15, avg_energy=0.6, avg_danceability=0.3,
|
|
177
|
-
avg_acousticness=0.7, avg_tempo=100, avg_complexity=0.9,
|
|
178
|
-
obscurity_score=0.8, genre_distribution={"post-punk": 0.4, "jazz": 0.3, "ambient": 0.3},
|
|
179
|
-
mood_label="intense", top_artists=["Swans"], top_tracks=[], has_audio_features=True,
|
|
180
|
-
)
|
|
181
|
-
wine = music_to_wine_profile(dark)
|
|
182
|
-
assert wine.tannin > wine.sweetness
|
|
183
|
-
assert wine.complexity > 0.5
|
|
184
|
-
assert wine.earthiness > 0.5
|
|
185
|
-
|
|
186
|
-
def test_bright_pop_listener(self):
|
|
187
|
-
"""Bright pop listener should map to fruity, sweet, low-tannin wines."""
|
|
188
|
-
pop = MusicProfile(
|
|
189
|
-
avg_valence=0.85, avg_energy=0.7, avg_danceability=0.8,
|
|
190
|
-
avg_acousticness=0.15, avg_tempo=120, avg_complexity=0.2,
|
|
191
|
-
obscurity_score=0.1, genre_distribution={"pop": 0.6, "dance": 0.3, "edm": 0.1},
|
|
192
|
-
mood_label="euphoric", top_artists=["Dua Lipa"], top_tracks=[], has_audio_features=True,
|
|
193
|
-
)
|
|
194
|
-
wine = music_to_wine_profile(pop)
|
|
195
|
-
assert wine.fruitiness > wine.earthiness
|
|
196
|
-
assert wine.sweetness > wine.tannin
|
|
197
|
-
assert wine.body > 0.4 # high energy = some body
|
|
198
|
-
|
|
199
|
-
def test_genre_influence(self):
|
|
200
|
-
"""Genre data should meaningfully shift the profile."""
|
|
201
|
-
base = MusicProfile(
|
|
202
|
-
avg_valence=0.5, avg_energy=0.5, avg_danceability=0.5,
|
|
203
|
-
avg_acousticness=0.5, avg_tempo=120, avg_complexity=0.5,
|
|
204
|
-
obscurity_score=0.5, genre_distribution={}, mood_label="contemplative",
|
|
205
|
-
top_artists=[], top_tracks=[], has_audio_features=True,
|
|
206
|
-
)
|
|
207
|
-
with_jazz = MusicProfile(
|
|
208
|
-
avg_valence=0.5, avg_energy=0.5, avg_danceability=0.5,
|
|
209
|
-
avg_acousticness=0.5, avg_tempo=120, avg_complexity=0.5,
|
|
210
|
-
obscurity_score=0.5, genre_distribution={"jazz": 0.5, "classical": 0.5},
|
|
211
|
-
mood_label="contemplative", top_artists=[], top_tracks=[], has_audio_features=True,
|
|
212
|
-
)
|
|
213
|
-
profile_base = music_to_wine_profile(base)
|
|
214
|
-
profile_jazz = music_to_wine_profile(with_jazz)
|
|
215
|
-
assert profile_jazz.complexity > profile_base.complexity
|
|
216
|
-
assert profile_jazz.acidity > profile_base.acidity
|
|
217
|
-
|
|
218
|
-
def test_extreme_values_dont_break(self):
|
|
219
|
-
"""Edge case: all zeros and all ones."""
|
|
220
|
-
zeros = MusicProfile(has_audio_features=True)
|
|
221
|
-
ones = MusicProfile(
|
|
222
|
-
avg_valence=1.0, avg_energy=1.0, avg_danceability=1.0,
|
|
223
|
-
avg_acousticness=1.0, avg_tempo=200, avg_complexity=1.0,
|
|
224
|
-
obscurity_score=1.0, has_audio_features=True,
|
|
225
|
-
)
|
|
226
|
-
p0 = music_to_wine_profile(zeros)
|
|
227
|
-
p1 = music_to_wine_profile(ones)
|
|
228
|
-
for dim in ["body", "sweetness", "tannin", "acidity", "complexity"]:
|
|
229
|
-
assert 0.0 <= getattr(p0, dim) <= 1.0
|
|
230
|
-
assert 0.0 <= getattr(p1, dim) <= 1.0
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
# ──────────────────────────────────────────────
|
|
234
|
-
# Scoring tests
|
|
235
|
-
# ──────────────────────────────────────────────
|
|
236
|
-
|
|
237
|
-
class TestScoreWineMatch:
|
|
238
|
-
def test_perfect_match(self):
|
|
239
|
-
target = wp(0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5)
|
|
240
|
-
wine = Wine("test", "Test", "Test", "Test", "Test", "Test", "red",
|
|
241
|
-
wp(0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5),
|
|
242
|
-
"mid", "Test", [])
|
|
243
|
-
assert score_wine_match(target, wine) == 1.0
|
|
244
|
-
|
|
245
|
-
def test_opposite_extremes(self):
|
|
246
|
-
target = wp(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
|
247
|
-
wine = Wine("test", "Test", "Test", "Test", "Test", "Test", "red",
|
|
248
|
-
wp(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
|
|
249
|
-
"mid", "Test", [])
|
|
250
|
-
score = score_wine_match(target, wine)
|
|
251
|
-
assert score < 0.2 # should be very low
|
|
252
|
-
|
|
253
|
-
def test_score_in_range(self):
|
|
254
|
-
target = wp(0.3, 0.7, 0.4, 0.8, 0.6, 0.5, 0.3, 0.9)
|
|
255
|
-
for wine in WINE_DB:
|
|
256
|
-
score = score_wine_match(target, wine)
|
|
257
|
-
assert 0.0 <= score <= 1.0, f"Score out of range for {wine.id}: {score}"
|
|
258
|
-
|
|
259
|
-
def test_squared_distance_spreads_scores(self):
|
|
260
|
-
"""Verify that squared differences create meaningful spread."""
|
|
261
|
-
target = music_to_wine_profile(DEMO_PROFILE)
|
|
262
|
-
scores = [score_wine_match(target, w) for w in WINE_DB]
|
|
263
|
-
spread = max(scores) - min(scores)
|
|
264
|
-
assert spread > 0.1, f"Score spread too narrow: {spread}"
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
class TestNormalizeScores:
|
|
268
|
-
def test_rescales_to_display_range(self):
|
|
269
|
-
data = [("a", 0.82, []), ("b", 0.78, []), ("c", 0.71, [])]
|
|
270
|
-
normalized = normalize_scores(data)
|
|
271
|
-
scores = [s for _, s, _ in normalized]
|
|
272
|
-
assert max(scores) >= 0.95
|
|
273
|
-
assert min(scores) >= 0.60
|
|
274
|
-
|
|
275
|
-
def test_single_item(self):
|
|
276
|
-
data = [("a", 0.75, [])]
|
|
277
|
-
result = normalize_scores(data)
|
|
278
|
-
assert len(result) == 1
|
|
279
|
-
|
|
280
|
-
def test_identical_scores(self):
|
|
281
|
-
data = [("a", 0.80, []), ("b", 0.80, [])]
|
|
282
|
-
result = normalize_scores(data)
|
|
283
|
-
assert all(abs(s - 0.90) < 0.01 for _, s, _ in result)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
# ──────────────────────────────────────────────
|
|
287
|
-
# Connection generation tests
|
|
288
|
-
# ──────────────────────────────────────────────
|
|
289
|
-
|
|
290
|
-
class TestGenerateConnections:
|
|
291
|
-
def test_demo_profile_gets_connections(self):
|
|
292
|
-
"""The demo profile should produce at least some connections for top wines."""
|
|
293
|
-
recs = recommend_wines(DEMO_PROFILE, WINE_DB, 5)
|
|
294
|
-
total_connections = sum(len(r.connections) for r in recs)
|
|
295
|
-
assert total_connections > 0, "Demo profile produced zero connections"
|
|
296
|
-
|
|
297
|
-
def test_bright_pop_gets_connections(self):
|
|
298
|
-
"""Even a mainstream listener should get connections."""
|
|
299
|
-
pop = MusicProfile(
|
|
300
|
-
avg_valence=0.8, avg_energy=0.7, avg_danceability=0.8,
|
|
301
|
-
avg_acousticness=0.1, avg_tempo=120, avg_complexity=0.2,
|
|
302
|
-
obscurity_score=0.15, genre_distribution={"pop": 0.5, "dance pop": 0.3, "edm": 0.2},
|
|
303
|
-
mood_label="euphoric", top_artists=["Taylor Swift"], top_tracks=[],
|
|
304
|
-
has_audio_features=True,
|
|
305
|
-
)
|
|
306
|
-
recs = recommend_wines(pop, WINE_DB, 5)
|
|
307
|
-
total_connections = sum(len(r.connections) for r in recs)
|
|
308
|
-
assert total_connections > 0, "Pop listener got zero connections"
|
|
309
|
-
|
|
310
|
-
def test_neutral_profile_gets_connections(self):
|
|
311
|
-
"""A slightly characterful profile should get some connections."""
|
|
312
|
-
neutral = MusicProfile(
|
|
313
|
-
avg_valence=0.45, avg_energy=0.55, avg_danceability=0.5,
|
|
314
|
-
avg_acousticness=0.5, avg_tempo=120, avg_complexity=0.55,
|
|
315
|
-
obscurity_score=0.5, genre_distribution={"indie rock": 0.4, "alternative": 0.3, "folk": 0.3},
|
|
316
|
-
mood_label="contemplative", top_artists=["Radiohead"], top_tracks=[],
|
|
317
|
-
has_audio_features=True,
|
|
318
|
-
)
|
|
319
|
-
recs = recommend_wines(neutral, WINE_DB, 10)
|
|
320
|
-
total_connections = sum(len(r.connections) for r in recs)
|
|
321
|
-
assert total_connections > 0, "Profile with slight character got zero connections"
|
|
322
|
-
|
|
323
|
-
def test_connections_dont_mutate_order(self):
|
|
324
|
-
"""Ensure recommendation building doesn't mutate connection lists."""
|
|
325
|
-
recs = recommend_wines(DEMO_PROFILE, WINE_DB, 3)
|
|
326
|
-
for rec in recs:
|
|
327
|
-
if len(rec.connections) > 1:
|
|
328
|
-
# Check that connections are not necessarily sorted by strength
|
|
329
|
-
# (they should be in generation order, not sorted)
|
|
330
|
-
strengths = [c.strength for c in rec.connections]
|
|
331
|
-
# Just verify they exist and are valid
|
|
332
|
-
assert all(0 < s <= 1.0 for s in strengths)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
# ──────────────────────────────────────────────
|
|
336
|
-
# End-to-end recommendation tests
|
|
337
|
-
# ──────────────────────────────────────────────
|
|
338
|
-
|
|
339
|
-
class TestRecommendWines:
|
|
340
|
-
def test_returns_requested_count(self):
|
|
341
|
-
recs = recommend_wines(DEMO_PROFILE, WINE_DB, 3)
|
|
342
|
-
assert len(recs) == 3
|
|
343
|
-
|
|
344
|
-
def test_sorted_by_score(self):
|
|
345
|
-
recs = recommend_wines(DEMO_PROFILE, WINE_DB, 5)
|
|
346
|
-
scores = [r.score for r in recs]
|
|
347
|
-
assert scores == sorted(scores, reverse=True)
|
|
348
|
-
|
|
349
|
-
def test_all_fields_populated(self):
|
|
350
|
-
recs = recommend_wines(DEMO_PROFILE, WINE_DB, 3)
|
|
351
|
-
for rec in recs:
|
|
352
|
-
assert rec.wine.id
|
|
353
|
-
assert rec.wine.name
|
|
354
|
-
assert rec.reasoning
|
|
355
|
-
assert 0.0 <= rec.score <= 1.0
|
|
356
|
-
|
|
357
|
-
def test_dark_listener_gets_complex_wines(self):
|
|
358
|
-
"""A dark, complex listener should get old-world terroir wines, not pop wines."""
|
|
359
|
-
dark = MusicProfile(
|
|
360
|
-
avg_valence=0.15, avg_energy=0.5, avg_danceability=0.3,
|
|
361
|
-
avg_acousticness=0.7, avg_tempo=100, avg_complexity=0.9,
|
|
362
|
-
obscurity_score=0.85, genre_distribution={"art rock": 0.3, "jazz": 0.4, "post-punk": 0.3},
|
|
363
|
-
mood_label="melancholic", top_artists=["Radiohead"], top_tracks=[],
|
|
364
|
-
has_audio_features=True,
|
|
365
|
-
)
|
|
366
|
-
recs = recommend_wines(dark, WINE_DB, 3)
|
|
367
|
-
# Top picks should include terroir-driven wines
|
|
368
|
-
top_tags = set()
|
|
369
|
-
for r in recs:
|
|
370
|
-
top_tags.update(r.wine.tags)
|
|
371
|
-
assert "terroir-driven" in top_tags or "old-world" in top_tags
|
|
372
|
-
|
|
373
|
-
def test_pop_listener_gets_approachable_wines(self):
|
|
374
|
-
"""A pop listener should get fruit-forward, approachable wines."""
|
|
375
|
-
pop = MusicProfile(
|
|
376
|
-
avg_valence=0.85, avg_energy=0.75, avg_danceability=0.85,
|
|
377
|
-
avg_acousticness=0.1, avg_tempo=125, avg_complexity=0.15,
|
|
378
|
-
obscurity_score=0.1, genre_distribution={"pop": 0.6, "dance pop": 0.4},
|
|
379
|
-
mood_label="euphoric", top_artists=["Dua Lipa"], top_tracks=[],
|
|
380
|
-
has_audio_features=True,
|
|
381
|
-
)
|
|
382
|
-
recs = recommend_wines(pop, WINE_DB, 3)
|
|
383
|
-
top_tags = set()
|
|
384
|
-
for r in recs:
|
|
385
|
-
top_tags.update(r.wine.tags)
|
|
386
|
-
assert "fruit-forward" in top_tags or "approachable" in top_tags or "fun" in top_tags
|
|
387
|
-
|
|
388
|
-
def test_color_filter(self):
|
|
389
|
-
whites = [w for w in WINE_DB if w.color == "white"]
|
|
390
|
-
recs = recommend_wines(DEMO_PROFILE, whites, 3)
|
|
391
|
-
for rec in recs:
|
|
392
|
-
assert rec.wine.color == "white"
|
|
393
|
-
|
|
394
|
-
def test_single_wine_available(self):
|
|
395
|
-
recs = recommend_wines(DEMO_PROFILE, WINE_DB[:1], 1)
|
|
396
|
-
assert len(recs) == 1
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
# ──────────────────────────────────────────────
|
|
400
|
-
# Wine database integrity tests
|
|
401
|
-
# ──────────────────────────────────────────────
|
|
402
|
-
|
|
403
|
-
class TestWineDB:
|
|
404
|
-
def test_unique_ids(self):
|
|
405
|
-
ids = [w.id for w in WINE_DB]
|
|
406
|
-
assert len(ids) == len(set(ids)), f"Duplicate wine IDs: {[x for x in ids if ids.count(x) > 1]}"
|
|
407
|
-
|
|
408
|
-
def test_profiles_in_range(self):
|
|
409
|
-
for wine in WINE_DB:
|
|
410
|
-
for dim in ["body", "sweetness", "tannin", "acidity", "complexity", "fruitiness", "earthiness", "spiciness"]:
|
|
411
|
-
val = getattr(wine.profile, dim)
|
|
412
|
-
assert 0.0 <= val <= 1.0, f"{wine.id}.{dim} = {val}"
|
|
413
|
-
|
|
414
|
-
def test_all_have_tags(self):
|
|
415
|
-
for wine in WINE_DB:
|
|
416
|
-
assert len(wine.tags) > 0, f"{wine.id} has no tags"
|
|
417
|
-
|
|
418
|
-
def test_valid_colors(self):
|
|
419
|
-
valid = {"red", "white", "rose", "orange", "sparkling", "dessert"}
|
|
420
|
-
for wine in WINE_DB:
|
|
421
|
-
assert wine.color in valid, f"{wine.id} has invalid color: {wine.color}"
|
|
422
|
-
|
|
423
|
-
def test_valid_price_ranges(self):
|
|
424
|
-
valid = {"budget", "mid", "premium", "luxury"}
|
|
425
|
-
for wine in WINE_DB:
|
|
426
|
-
assert wine.price_range in valid, f"{wine.id} has invalid price: {wine.price_range}"
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
# ──────────────────────────────────────────────
|
|
430
|
-
# Rating boost tests
|
|
431
|
-
# ──────────────────────────────────────────────
|
|
432
|
-
|
|
433
|
-
class TestRatingBoosts:
|
|
434
|
-
def test_no_ratings_file(self, tmp_path, monkeypatch):
|
|
435
|
-
monkeypatch.setattr("recommend.RATINGS_FILE", tmp_path / "nonexistent.json")
|
|
436
|
-
assert load_rating_boosts() == {}
|
|
437
|
-
|
|
438
|
-
def test_corrupt_ratings_file(self, tmp_path, monkeypatch):
|
|
439
|
-
bad_file = tmp_path / "ratings.json"
|
|
440
|
-
bad_file.write_text("not json{{{")
|
|
441
|
-
monkeypatch.setattr("recommend.RATINGS_FILE", bad_file)
|
|
442
|
-
assert load_rating_boosts() == {}
|
|
443
|
-
|
|
444
|
-
def test_boosts_are_bounded(self, tmp_path, monkeypatch):
|
|
445
|
-
ratings_file = tmp_path / "ratings.json"
|
|
446
|
-
# 10 max ratings on same tags
|
|
447
|
-
ratings = [{"rating": 5, "tags": ["terroir-driven", "old-world"]} for _ in range(10)]
|
|
448
|
-
ratings_file.write_text(json.dumps(ratings))
|
|
449
|
-
monkeypatch.setattr("recommend.RATINGS_FILE", ratings_file)
|
|
450
|
-
|
|
451
|
-
boosts = load_rating_boosts()
|
|
452
|
-
# With multiplicative application capped at +/-15%, even strong boosts are safe
|
|
453
|
-
for tag, boost in boosts.items():
|
|
454
|
-
assert -1.0 <= boost <= 1.0
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if __name__ == "__main__":
|
|
458
|
-
import pytest
|
|
459
|
-
pytest.main([__file__, "-v"])
|