Skip to content

Commit a2aab7c

Browse files
authored
Fix category display and implement unified category system with comprehensive unit tests (#82)
1 parent 4ac575e commit a2aab7c

11 files changed

Lines changed: 1026 additions & 35 deletions

File tree

ApprenticeshipScraper/build.gradle.kts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ dependencies {
2626

2727
// JetBrains Annotations
2828
implementation(libs.jetbrains.annotations)
29+
30+
// Testing
31+
testImplementation(libs.bundles.testing)
32+
testRuntimeOnly(libs.junit.platform.launcher)
33+
}
34+
35+
tasks.test {
36+
useJUnitPlatform()
37+
testLogging {
38+
events("passed", "skipped", "failed")
39+
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
40+
showStandardStreams = false
41+
}
2942
}
3043

3144
tasks.jar {

ApprenticeshipScraper/src/main/java/io/github/yusufsdiscordbot/mystiguardian/apprenticeship/Apprenticeship.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package io.github.yusufsdiscordbot.mystiguardian.apprenticeship;
2020

21+
import io.github.yusufsdiscordbot.mystiguardian.categories.CategoryMapper;
2122
import java.time.LocalDate;
2223
import java.util.Collections;
2324
import java.util.List;
@@ -108,4 +109,20 @@ public interface Apprenticeship {
108109
default List<String> getCategories() {
109110
return Collections.emptyList();
110111
}
112+
113+
/**
114+
* Gets the unified MystiGuardian category names for this apprenticeship.
115+
*
116+
* <p>Maps source-specific categories to standardized MystiGuardian categories (Technology,
117+
* Finance, Business, Engineering, etc.). This provides consistent categorization across all
118+
* apprenticeship sources.
119+
*
120+
* <p>Default implementation uses {@link CategoryMapper} to map the source categories to unified
121+
* groups.
122+
*
123+
* @return list of unified category names (e.g., "Technology", "Finance")
124+
*/
125+
default List<String> getUnifiedCategories() {
126+
return CategoryMapper.getUnifiedCategoryNames(getCategories());
127+
}
111128
}

ApprenticeshipScraper/src/main/java/io/github/yusufsdiscordbot/mystiguardian/apprenticeship/FindAnApprenticeship.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import java.awt.*;
2222
import java.time.LocalDate;
23+
import java.util.Collections;
24+
import java.util.List;
2325
import java.util.Objects;
2426
import lombok.Getter;
2527
import lombok.Setter;
@@ -182,4 +184,20 @@ private void addFields(EmbedBuilder embed) {
182184
public String getTitle() {
183185
return name;
184186
}
187+
188+
/**
189+
* Gets the categories/tags associated with this GOV.UK apprenticeship.
190+
*
191+
* <p>Returns the GOV.UK route category (e.g., "Digital", "Engineering and manufacturing") as a
192+
* single-item list for consistency with the Apprenticeship interface.
193+
*
194+
* @return a list containing the route category, or empty list if no category is set
195+
*/
196+
@Override
197+
public List<String> getCategories() {
198+
if (category != null && !category.isEmpty()) {
199+
return Collections.singletonList(category);
200+
}
201+
return Collections.emptyList();
202+
}
185203
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright 2025 RealYusufIsmail.
3+
*
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
*
7+
* you may not use this file except in compliance with the License.
8+
*
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
package io.github.yusufsdiscordbot.mystiguardian.categories;
20+
21+
import io.github.yusufsdiscordbot.mystiguardian.config.ApprenticeshipCategoryGroup;
22+
import java.util.*;
23+
import lombok.extern.slf4j.Slf4j;
24+
25+
/**
26+
* Maps apprenticeship categories from different sources to unified MystiGuardian category groups.
27+
*
28+
* <p>This mapper creates a consistent categorization system across:
29+
*
30+
* <ul>
31+
* <li>Higher In (83 specific categories like "software-engineering", "cyber-security")
32+
* <li>GOV.UK (15 broad routes like "Digital", "Engineering and manufacturing")
33+
* </ul>
34+
*
35+
* <p>The unified system uses {@link ApprenticeshipCategoryGroup} enum with 14 sectors: Technology,
36+
* Finance, Business, Engineering, Marketing, Design, Legal, Construction, Retail, Hospitality, HR,
37+
* Property, Public Sector, and Science.
38+
*
39+
* <p>Usage examples:
40+
*
41+
* <pre>{@code
42+
* // Map Higher In category
43+
* List<ApprenticeshipCategoryGroup> groups = CategoryMapper.mapToUnifiedCategories("software-engineering");
44+
* // Returns: [TECHNOLOGY]
45+
*
46+
* // Map GOV.UK route
47+
* List<ApprenticeshipCategoryGroup> groups = CategoryMapper.mapToUnifiedCategories("Digital");
48+
* // Returns: [TECHNOLOGY]
49+
*
50+
* // Get unified category names
51+
* List<String> names = CategoryMapper.getUnifiedCategoryNames("cyber-security");
52+
* // Returns: ["Technology"]
53+
* }</pre>
54+
*
55+
* @see ApprenticeshipCategoryGroup
56+
* @see HigherinCategories
57+
* @see GovUkRoutes
58+
*/
59+
@Slf4j
60+
public final class CategoryMapper {
61+
62+
private CategoryMapper() {
63+
throw new UnsupportedOperationException("Utility class");
64+
}
65+
66+
/**
67+
* Map of GOV.UK route names to their corresponding unified category groups.
68+
*
69+
* <p>GOV.UK routes are broader than Higher In categories, so some routes map to multiple category
70+
* groups.
71+
*/
72+
private static final Map<String, List<ApprenticeshipCategoryGroup>> GOV_UK_ROUTE_MAPPING =
73+
Map.ofEntries(
74+
Map.entry(
75+
"Agriculture, environmental and animal care",
76+
List.of(ApprenticeshipCategoryGroup.SCIENCE)),
77+
Map.entry("Business and administration", List.of(ApprenticeshipCategoryGroup.BUSINESS)),
78+
Map.entry("Care services", List.of(ApprenticeshipCategoryGroup.PUBLIC_SECTOR)),
79+
Map.entry("Catering and hospitality", List.of(ApprenticeshipCategoryGroup.HOSPITALITY)),
80+
Map.entry(
81+
"Construction and the built environment",
82+
List.of(ApprenticeshipCategoryGroup.CONSTRUCTION)),
83+
Map.entry("Creative and design", List.of(ApprenticeshipCategoryGroup.DESIGN)),
84+
Map.entry("Digital", List.of(ApprenticeshipCategoryGroup.TECHNOLOGY)),
85+
Map.entry(
86+
"Education and early years", List.of(ApprenticeshipCategoryGroup.PUBLIC_SECTOR)),
87+
Map.entry(
88+
"Engineering and manufacturing", List.of(ApprenticeshipCategoryGroup.ENGINEERING)),
89+
Map.entry("Hair and beauty", List.of(ApprenticeshipCategoryGroup.RETAIL)),
90+
Map.entry(
91+
"Health and science",
92+
List.of(
93+
ApprenticeshipCategoryGroup.SCIENCE, ApprenticeshipCategoryGroup.PUBLIC_SECTOR)),
94+
Map.entry(
95+
"Legal, finance and accounting",
96+
List.of(ApprenticeshipCategoryGroup.LEGAL, ApprenticeshipCategoryGroup.FINANCE)),
97+
Map.entry("Protective services", List.of(ApprenticeshipCategoryGroup.PUBLIC_SECTOR)),
98+
Map.entry(
99+
"Sales, marketing and procurement",
100+
List.of(ApprenticeshipCategoryGroup.MARKETING, ApprenticeshipCategoryGroup.BUSINESS)),
101+
Map.entry("Transport and logistics", List.of(ApprenticeshipCategoryGroup.BUSINESS)));
102+
103+
/**
104+
* Maps a source-specific category to unified MystiGuardian category groups.
105+
*
106+
* <p>This method handles both Higher In categories (e.g., "software-engineering") and GOV.UK
107+
* routes (e.g., "Digital", "Engineering and manufacturing").
108+
*
109+
* <p>For Higher In categories, uses {@link
110+
* ApprenticeshipCategoryGroup#findGroupsForCategory(String)}. For GOV.UK routes, uses the
111+
* predefined {@link #GOV_UK_ROUTE_MAPPING}.
112+
*
113+
* @param sourceCategory the category from the scraping source
114+
* @return list of unified category groups (may be empty if no mapping found, may contain multiple
115+
* groups)
116+
*/
117+
public static List<ApprenticeshipCategoryGroup> mapToUnifiedCategories(String sourceCategory) {
118+
if (sourceCategory == null || sourceCategory.isEmpty()) {
119+
return Collections.emptyList();
120+
}
121+
122+
// First try to map as Higher In category (lowercase with hyphens)
123+
List<ApprenticeshipCategoryGroup> groups =
124+
ApprenticeshipCategoryGroup.findGroupsForCategory(sourceCategory);
125+
126+
// If no match, try GOV.UK route mapping (case-sensitive, with spaces)
127+
if (groups.isEmpty() && GOV_UK_ROUTE_MAPPING.containsKey(sourceCategory)) {
128+
groups = GOV_UK_ROUTE_MAPPING.get(sourceCategory);
129+
}
130+
131+
// Log if no mapping found
132+
if (groups.isEmpty()) {
133+
logger.debug("No unified category mapping found for source category: {}", sourceCategory);
134+
}
135+
136+
return groups;
137+
}
138+
139+
/**
140+
* Maps a list of source categories to unified category groups.
141+
*
142+
* <p>This method processes multiple categories and returns a deduplicated list of all matching
143+
* unified category groups.
144+
*
145+
* @param sourceCategories list of categories from the scraping source
146+
* @return deduplicated list of unified category groups
147+
*/
148+
public static List<ApprenticeshipCategoryGroup> mapToUnifiedCategories(
149+
List<String> sourceCategories) {
150+
if (sourceCategories == null || sourceCategories.isEmpty()) {
151+
return Collections.emptyList();
152+
}
153+
154+
Set<ApprenticeshipCategoryGroup> uniqueGroups = new LinkedHashSet<>();
155+
for (String category : sourceCategories) {
156+
uniqueGroups.addAll(mapToUnifiedCategories(category));
157+
}
158+
159+
return new ArrayList<>(uniqueGroups);
160+
}
161+
162+
/**
163+
* Gets the unified category names as strings for a source category.
164+
*
165+
* <p>Useful for display purposes, returns the enum names formatted as title case (e.g.,
166+
* "Technology", "Finance").
167+
*
168+
* @param sourceCategory the category from the scraping source
169+
* @return list of unified category names
170+
*/
171+
public static List<String> getUnifiedCategoryNames(String sourceCategory) {
172+
return mapToUnifiedCategories(sourceCategory).stream()
173+
.map(CategoryMapper::formatCategoryGroupName)
174+
.toList();
175+
}
176+
177+
/**
178+
* Gets the unified category names as strings for a list of source categories.
179+
*
180+
* @param sourceCategories list of categories from the scraping source
181+
* @return deduplicated list of unified category names
182+
*/
183+
public static List<String> getUnifiedCategoryNames(List<String> sourceCategories) {
184+
return mapToUnifiedCategories(sourceCategories).stream()
185+
.map(CategoryMapper::formatCategoryGroupName)
186+
.toList();
187+
}
188+
189+
/**
190+
* Formats a category group enum to a display-friendly name.
191+
*
192+
* <p>Converts enum names like "PUBLIC_SECTOR" to "Public Sector".
193+
*
194+
* @param group the category group enum
195+
* @return formatted category name
196+
*/
197+
private static String formatCategoryGroupName(ApprenticeshipCategoryGroup group) {
198+
String name = group.name().replace("_", " ");
199+
String[] words = name.split(" ");
200+
StringBuilder formatted = new StringBuilder();
201+
202+
for (String word : words) {
203+
if (!word.isEmpty()) {
204+
formatted
205+
.append(Character.toUpperCase(word.charAt(0)))
206+
.append(word.substring(1).toLowerCase())
207+
.append(" ");
208+
}
209+
}
210+
211+
return formatted.toString().trim();
212+
}
213+
214+
/**
215+
* Checks if a source category has a unified category mapping.
216+
*
217+
* @param sourceCategory the category from the scraping source
218+
* @return true if a mapping exists, false otherwise
219+
*/
220+
public static boolean hasUnifiedMapping(String sourceCategory) {
221+
return !mapToUnifiedCategories(sourceCategory).isEmpty();
222+
}
223+
224+
/**
225+
* Gets all possible unified category groups.
226+
*
227+
* @return array of all category groups in the unified system
228+
*/
229+
public static ApprenticeshipCategoryGroup[] getAllUnifiedCategories() {
230+
return ApprenticeshipCategoryGroup.values();
231+
}
232+
233+
/**
234+
* Gets all unified category names as strings.
235+
*
236+
* @return list of all unified category names
237+
*/
238+
public static List<String> getAllUnifiedCategoryNames() {
239+
return Arrays.stream(ApprenticeshipCategoryGroup.values())
240+
.map(CategoryMapper::formatCategoryGroupName)
241+
.toList();
242+
}
243+
}

0 commit comments

Comments
 (0)