From 3d5cc6a187c651c094233ff1d42118b85a60959d Mon Sep 17 00:00:00 2001 From: DeccanHYD Date: Thu, 8 Oct 2026 04:37:10 +0200 Subject: [PATCH] fix: align library tutorial lookup and native web controls --- README.md | 4 +- app/build.gradle.kts | 4 +- .../screens/settings/ExerciseLibraryScreen.kt | 6 +- .../ironlog/app/util/ExerciseTutorialLinks.kt | 7 + .../app/util/ExerciseTutorialLinksTest.kt | 20 + .../2026-10-08-library-tutorial-parity.md | 11 + web/scripts/extract-native.mjs | 30 +- web/src/App.tsx | 12 +- web/src/domain/exercise-search.ts | 2 +- web/src/domain/exercise-tutorial.ts | 13 + web/src/features/ExerciseLibrary.tsx | 57 +- web/src/generated/exercise-tutorials.json | 875 ++++++++++++++++++ web/src/generated/native-provenance.json | 6 +- web/src/styles.css | 7 + web/src/ui/components.tsx | 7 +- web/tests/exercise-library-ui.test.tsx | 49 +- web/tests/exercise-search.test.ts | 65 +- web/tests/exercise-tutorial.test.ts | 16 + 18 files changed, 1142 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/ironlog/app/util/ExerciseTutorialLinks.kt create mode 100644 app/src/test/java/com/ironlog/app/util/ExerciseTutorialLinksTest.kt create mode 100644 docs/reviews/2026-10-08-library-tutorial-parity.md create mode 100644 web/src/domain/exercise-tutorial.ts create mode 100644 web/src/generated/exercise-tutorials.json create mode 100644 web/tests/exercise-tutorial.test.ts diff --git a/README.md b/README.md index 3bbec7e..f8fb772 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ **A strength-training companion that keeps the workout, recovery context, and earned progress in one local record.** -[Open the web app](https://yannam-builds.github.io/Ironlog/app/) · [Download Android preview](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.9) · [See the native screens](#inside-the-android-app) · [Build locally](#build-and-verify) +[Open the web app](https://yannam-builds.github.io/Ironlog/app/) · [Download Android preview](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.10) · [See the native screens](#inside-the-android-app) · [Build locally](#build-and-verify) > [!NOTE] -> IronLog is in active pre-alpha development. The signed [`0.1.0-pre-alpha.9` Android preview APK](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.9) is available; there is no Play Store listing yet. +> IronLog is in active pre-alpha development. The signed [`0.1.0-pre-alpha.10` Android preview APK](https://github.com/Yannam-Builds/Ironlog/releases/tag/v0.1.0-pre-alpha.10) is available; there is no Play Store listing yet. ## The training loop diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fc8b61..ebdc75d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,9 +37,9 @@ android { minSdk = 26 targetSdk = 36 versionCode = providers.gradleProperty("IRONLOG_VERSION_CODE").orNull?.toIntOrNull() - ?: localProps.getProperty("version.code", "10").toInt() + ?: 11 versionName = providers.gradleProperty("IRONLOG_VERSION_NAME").orNull - ?: localProps.getProperty("version.name", "0.1.0-pre-alpha.9") + ?: "0.1.0-pre-alpha.10" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt index 362c241..59f6ccd 100644 --- a/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt @@ -54,7 +54,7 @@ import com.ironlog.app.ui.theme.IronLogRadius import com.ironlog.app.ui.theme.IronLogType import com.ironlog.app.util.buildFilterChipOptions import com.ironlog.app.util.matchesExerciseFilter -import com.ironlog.app.util.normalizeExerciseNameKey +import com.ironlog.app.util.exerciseTutorialLink import com.ironlog.app.util.queryExerciseSearch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -340,7 +340,7 @@ fun ExerciseLibraryScreen( } items(filtered, key = { it.id }) { ex -> - val hasVideo = videoMap.containsKey(normalizeExerciseNameKey(ex.name)) + val hasVideo = exerciseTutorialLink(videoMap, ex.name) != null ExerciseRow( exercise = ex, isFavorite = favoriteIds.contains(ex.id), @@ -351,7 +351,7 @@ fun ExerciseLibraryScreen( onClick = { onExerciseClick?.invoke(ex) }, hasVideo = hasVideo, onVideo = { - val link = videoMap[normalizeExerciseNameKey(ex.name)] ?: return@ExerciseRow + val link = exerciseTutorialLink(videoMap, ex.name) ?: return@ExerciseRow runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } diff --git a/app/src/main/java/com/ironlog/app/util/ExerciseTutorialLinks.kt b/app/src/main/java/com/ironlog/app/util/ExerciseTutorialLinks.kt new file mode 100644 index 0000000..f506422 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/ExerciseTutorialLinks.kt @@ -0,0 +1,7 @@ +package com.ironlog.app.util + +/** Preserve exact normalized names, then support the compact keys in the bundled tutorial asset. */ +fun exerciseTutorialLink(links: Map, name: String): String? { + val key = normalizeExerciseNameKey(name) + return links[key] ?: links[key.replace("_", "")] +} diff --git a/app/src/test/java/com/ironlog/app/util/ExerciseTutorialLinksTest.kt b/app/src/test/java/com/ironlog/app/util/ExerciseTutorialLinksTest.kt new file mode 100644 index 0000000..0d07b44 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/util/ExerciseTutorialLinksTest.kt @@ -0,0 +1,20 @@ +package com.ironlog.app.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ExerciseTutorialLinksTest { + @Test fun resolvesDisplayNamesAgainstCompactAssetKeys() { + val links = mapOf("abcrunchmachine" to "https://example.com/ab", "plank" to "https://example.com/plank") + assertEquals("https://example.com/ab", exerciseTutorialLink(links, " Ab Crunch Machine ")) + assertEquals("https://example.com/ab", exerciseTutorialLink(links, "AbCrunchMachine")) + assertEquals("https://example.com/plank", exerciseTutorialLink(links, "PLANK")) + assertNull(exerciseTutorialLink(links, "Athlete custom exercise")) + } + + @Test fun exactNormalizedKeyTakesPrecedence() { + val links = mapOf("ab_crunch_machine" to "https://example.com/exact", "abcrunchmachine" to "https://example.com/compact") + assertEquals("https://example.com/exact", exerciseTutorialLink(links, "Ab Crunch Machine")) + } +} diff --git a/docs/reviews/2026-10-08-library-tutorial-parity.md b/docs/reviews/2026-10-08-library-tutorial-parity.md new file mode 100644 index 0000000..49244f6 --- /dev/null +++ b/docs/reviews/2026-10-08-library-tutorial-parity.md @@ -0,0 +1,11 @@ +# 2026-10-08 — Library tutorial lookup and controls + +The native library looked up names with underscores against a compact-name tutorial asset. Of 1,731 seeded exercise display names, only 16 matched. Kotlin and web now prefer an exact normalized key, then try its compact spelling. This resolves 852 exercises using the existing 873 URLs; absent exercises receive no invented links. The web export includes the asset source hash and rejects non-HTTP(S) URLs. + +Web library changes include the native full-screen destination without bottom tabs, back header with filtered count, separate 18px Compose trend control, stacked favorite/progress/delete/video actions, and query-driven custom creation. The two-letter broad-prefix search discrepancy and case-insensitive imported difficulty options are corrected. The list still uses reachable 100-row batches rather than a native LazyColumn; fixed header/list scrolling and exact filter-sheet geometry remain follow-ups. + +Release version defaults are now public in Gradle (code 11, 0.1.0-pre-alpha.10), with explicit Gradle property overrides retained. Private local.properties version fields no longer silently change release identity. Signing settings and app data are untouched. + +Verification before publication: 54 web test files / 268 tests pass, TypeScript/production build and 185-file output check pass. Browser QA at 320px verifies native tutorial URL, no horizontal overflow, persistent favorite, fullscreen library, separate progress route and both Back transitions. All 764 Kotlin tests (180 files) pass. Debug lint has zero errors, 162 warnings and 19 hints. Signed minified release build succeeds; apksigner verifies v2 with one signer. APK SHA-256: 511161590b5085e9df0c88227e436c39da36ef792073c0e979e5a1dca52e0789. Isolated API 36.1 QA update from pre-alpha.9 to .10 succeeded with adb install -r, firstInstallTime retained at 2026-10-05 23:45:09, Athlete retained, and empty crash buffer. Before/after QA confirms Ab Crunch Machine had no VIDEO button in pre-alpha.9 and now has one in .10. Evidence: ignored output/android/tutorial-before.xml, tutorial-after.xml and tutorial-after.png. The existing profile remains Athlete; no physical phone was touched. Test output included ObjectBox cursor-cleanup diagnostics despite zero failures; this is separate from Android runtime crash verification and remains an audit candidate. + +Broader parity remains open: custom editing and native duplicate/rename persistence audit; plan drag/progression/editor guard details; history calendar/edit controls; cloud AI/provider/gym/retry flows; training intelligence and statistics/chart drills; body/photo export details; onboarding migration/retry checks; differential animation, gesture and transition captures. Android-only APIs are excluded. No 100% parity claim. diff --git a/web/scripts/extract-native.mjs b/web/scripts/extract-native.mjs index b9a8df0..fba85ea 100644 --- a/web/scripts/extract-native.mjs +++ b/web/scripts/extract-native.mjs @@ -144,8 +144,12 @@ function extract(source) { throw Error("Native theme contract changed"); output("src/generated/themes.json", themes); const raw = JSON.parse(read(prefix + "assets/exerciseLibrary.json")); - const exercises = nativeExerciseCatalog(raw.exercises, - read(prefix + "java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt").toString()); + const exercises = nativeExerciseCatalog( + raw.exercises, + read( + prefix + "java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt", + ).toString(), + ); output("public/data/exercises.json", exercises); const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); const lookup = new Map( @@ -163,6 +167,24 @@ function extract(source) { for (const e of d.exercises) e.exerciseId = lookup.get(norm(e.name)) ?? ""; output("src/generated/templates.json", templates); + const tutorials = JSON.parse( + read(prefix + "assets/ironlog/exercise_youtube_by_normalized_name.json"), + ); + output( + "src/generated/exercise-tutorials.json", + Object.fromEntries( + Object.entries(tutorials).flatMap(([key, row]) => { + try { + const url = new URL(row.youtubeLink); + return ["http:", "https:"].includes(url.protocol) + ? [[key, row.youtubeLink]] + : []; + } catch { + return []; + } + }), + ), + ); output( "src/generated/body-map.json", JSON.parse(read(prefix + "assets/ironlog/body_map_paths.json")), @@ -171,7 +193,9 @@ function extract(source) { // by Compose. Export the complete set so web screens can select the same // mascot pose, badge and grade art instead of maintaining approximations. const art = prefix + "res/drawable-nodpi/"; - const artwork = readdirSync(resolve(source, art)).filter((n) => n.endsWith(".png")); + const artwork = readdirSync(resolve(source, art)).filter((n) => + n.endsWith(".png"), + ); const fonts = readdirSync(resolve(source, prefix + "res/font/")).filter((n) => n.endsWith(".ttf"), ); diff --git a/web/src/App.tsx b/web/src/App.tsx index 9403708..36cd4db 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -320,21 +320,21 @@ export function App() { ) : ( <> -
- {detail && ( +
+ {detail && route !== "library" && (
backFrom(route)} /> - {route === "library" ?

Exercise library

: + {route.startsWith("plan/") ? "EDIT PLAN" : route.startsWith("history/") ? "WORKOUT" : detailTitles[route] ?? "IRONLOG"} - } +
)} @@ -416,7 +416,7 @@ export function App() {
- } )} {busy && ( diff --git a/web/src/domain/exercise-search.ts b/web/src/domain/exercise-search.ts index eac536b..3f789fd 100644 --- a/web/src/domain/exercise-search.ts +++ b/web/src/domain/exercise-search.ts @@ -14,7 +14,7 @@ export function queryExerciseSearch(exercises: Exercise[], query: string): Exerc exercise.equipment, exercise.category ?? "", exercise.movementPattern ?? "", exercise.difficulty ?? ""].join(" ")); const score = name === q ? 0 : name.startsWith(q) ? 1 : name.includes(q) || aliases.includes(q) ? 2 : words.length && words.every(word => name.includes(word)) ? 3 - : words.some(word => name.split(" ").some(part => part.startsWith(word))) ? 4 + : words.some(word => word.length >= 3 && name.split(" ").some(part => part.startsWith(word))) ? 4 : words.length && words.every(word => haystack.includes(word)) ? 5 : words.some(word => word.length >= 3 && haystack.includes(word)) ? 6 : 1000; return { exercise, score }; diff --git a/web/src/domain/exercise-tutorial.ts b/web/src/domain/exercise-tutorial.ts new file mode 100644 index 0000000..c639518 --- /dev/null +++ b/web/src/domain/exercise-tutorial.ts @@ -0,0 +1,13 @@ +import links from "../generated/exercise-tutorials.json"; +/** Kotlin exerciseTutorialLink: exact name key first, then bundled compact-name keys. */ +export function exerciseTutorial(name: string): string | undefined { + const key = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/_+$/, ""); + const lookup = Object.hasOwn(links, key) ? key : key.replace(/_/g, ""); + return Object.hasOwn(links, lookup) + ? (links as Record)[lookup] + : undefined; +} diff --git a/web/src/features/ExerciseLibrary.tsx b/web/src/features/ExerciseLibrary.tsx index 0561927..c6e9220 100644 --- a/web/src/features/ExerciseLibrary.tsx +++ b/web/src/features/ExerciseLibrary.tsx @@ -3,6 +3,8 @@ import { useApp, navigate } from "../ui/context"; import { Button, Icon, IconButton, Sheet } from "../ui/components"; import { deleteCustomExercise, setExerciseFavorite } from "../data/store"; import { queryExerciseSearch } from "../domain/exercise-search"; +import { exerciseTutorial } from "../domain/exercise-tutorial"; +import { backFrom } from "../ui/navigation"; import type { Exercise } from "../domain/types"; import { ExercisePicker } from "./Plans"; @@ -93,7 +95,10 @@ export function ExerciseLibrary() { category: values("category"), movementPattern: values("movementPattern"), difficulty: ["beginner", "intermediate", "advanced", "expert"].filter( - (value) => values("difficulty").includes(value), + (value) => + values("difficulty").some( + (difficulty) => difficulty.toLowerCase() === value, + ), ), }; }, [data.exercises]); @@ -163,6 +168,21 @@ export function ExerciseLibrary() { return (
+
+ backFrom("library")} + /> +
+

Exercise library

+ + {list.length} exercise{list.length === 1 ? "" : "s"} + +
+
{activeFilters.map(([key, value]) => { @@ -226,10 +246,6 @@ export function ExerciseLibrary() {

{list.length} exercise{list.length === 1 ? "" : "s"}

-
{!!recent.length && !debounced.trim() && !activeFilters.length && (
@@ -268,6 +284,7 @@ export function ExerciseLibrary() {
+ + navigate(`exercise/${encodeURIComponent(ex.id)}`) + } + /> {ex.custom && ( setDeleting(ex)} /> )} + {exerciseTutorial(ex.name) && ( + + ▶ VIDEO + + )}
))} @@ -322,7 +359,10 @@ export function ExerciseLibrary() { ); } diff --git a/web/tests/exercise-library-ui.test.tsx b/web/tests/exercise-library-ui.test.tsx index 5ed62da..c35b865 100644 --- a/web/tests/exercise-library-ui.test.tsx +++ b/web/tests/exercise-library-ui.test.tsx @@ -26,7 +26,7 @@ const bench: Exercise = { tracking: "weight_reps", secondaryMuscles: ["Triceps"], category: "strength", - difficulty: "beginner", + difficulty: "Beginner", movementPattern: "push", }; const row: Exercise = { @@ -55,6 +55,35 @@ async function openLibrary() { ); return screen.findByRole("heading", { name: "Exercise library" }); } +it("uses the native full-screen library and a separate progress control", async () => { + await openLibrary(); + expect( + screen.queryByRole("navigation", { name: "Main" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Open progress for Bench Press" }), + ).toBeVisible(); +}); +it("offers the exact native tutorial link only for mapped exercise names", async () => { + await saveExercise({ + ...bench, + id: "ab-machine", + name: "Ab Crunch Machine", + custom: true, + }); + await openLibrary(); + const video = screen.getByRole("link", { + name: "Watch tutorial for Ab Crunch Machine", + }); + expect(video).toHaveAttribute( + "href", + "https://www.youtube.com/results?search_query=Ab+Crunch+Machine+Abdominals+exercise+tutorial", + ); + expect(video).toHaveAttribute("rel", "noopener noreferrer"); + expect( + screen.queryByRole("link", { name: "Watch tutorial for Bench Press" }), + ).not.toBeInTheDocument(); +}); it("opens a separate library destination and persists favorites without dismissing the screen", async () => { await openLibrary(); expect(window.location.hash).toBe("#/library"); @@ -98,7 +127,7 @@ it("combines secondary muscle and difficulty filters, clears them and respects a fireEvent.click( within(dialog).getByRole("button", { name: "Show exercises" }), ); - expect(screen.getByText("1 exercise")).toBeVisible(); + expect(screen.getAllByText("1 exercise")).toHaveLength(2); expect( screen.getByRole("button", { name: "Remove Triceps filter" }), ).toBeVisible(); @@ -119,7 +148,7 @@ it("reaches results beyond the old 50-row limit and follows native progress navi })), ); await openLibrary(); - expect(screen.getByText("151 exercises")).toBeVisible(); + expect(screen.getAllByText("151 exercises")).toHaveLength(2); fireEvent.click(screen.getByRole("button", { name: /Show more exercises/ })); fireEvent.click( screen.getByRole("button", { name: "View progress for Exercise 150" }), @@ -230,17 +259,18 @@ it("All scope clears the BW-only restriction as in Kotlin", async () => { fireEvent.click(screen.getByRole("button", { name: "Filters" })); const dialog = screen.getByRole("dialog", { name: "Filters" }); fireEvent.click(within(dialog).getByRole("button", { name: "BW Only" })); - expect(within(dialog).getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "false"); - fireEvent.click( - within(dialog).getByRole("button", { name: "All" }), + expect(within(dialog).getByRole("button", { name: "All" })).toHaveAttribute( + "aria-pressed", + "false", ); + fireEvent.click(within(dialog).getByRole("button", { name: "All" })); expect( within(dialog).getByRole("button", { name: "BW Only" }), ).toHaveAttribute("aria-pressed", "false"); fireEvent.click( within(dialog).getByRole("button", { name: "Show exercises" }), ); - expect(screen.getByText("2 exercises")).toBeVisible(); + expect(screen.getAllByText("2 exercises")).toHaveLength(2); }); it("uses the latest completed workout names as recent search chips", async () => { await db.workouts.put({ @@ -272,9 +302,7 @@ it("uses the latest completed workout names as recent search chips", async () => ], }); await openLibrary(); - fireEvent.click( - screen.getByRole("button", { name: "Bench Press" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Bench Press" })); expect(screen.getByLabelText("Search exercises")).toHaveValue("Bench Press"); await waitFor(() => expect( @@ -282,4 +310,3 @@ it("uses the latest completed workout names as recent search chips", async () => ).not.toBeInTheDocument(), ); }); - diff --git a/web/tests/exercise-search.test.ts b/web/tests/exercise-search.test.ts index 9247447..8303711 100644 --- a/web/tests/exercise-search.test.ts +++ b/web/tests/exercise-search.test.ts @@ -1,15 +1,58 @@ import { expect, it } from "vitest"; import { queryExerciseSearch } from "../src/domain/exercise-search"; import type { Exercise } from "../src/domain/types"; -const exercise = (id:string,name:string,extra:Partial={}):Exercise=>({id,name,muscle:"Chest",equipment:"Barbell",tracking:"weight_reps",...extra}); -it("ranks exact, prefix, substring and aliases before broad metadata matches",()=>{ - const rows=[exercise("broad","Z Push",{secondaryMuscles:["Bench"]}),exercise("alias","A Press",{aliases:["Bench"]}),exercise("substring","Incline Bench Press"),exercise("prefix","Bench Press"),exercise("exact","Bench")]; - expect(queryExerciseSearch(rows,"bench").map(row=>row.id)).toEqual(["exact","prefix","alias","substring","broad"]); - expect(queryExerciseSearch(rows,"!!!")).toEqual(rows); -}); -it("normalizes camel case and punctuation and searches metadata while excluding unrelated rows",()=>{ - const rows=[exercise("one","UpperBodyPress",{difficulty:"beginner",movementPattern:"push"}),exercise("two","Cable Row",{muscle:"Back",equipment:"Cable"})]; - expect(queryExerciseSearch(rows,"upper-body").map(row=>row.id)).toEqual(["one"]); - expect(queryExerciseSearch(rows,"beginner push").map(row=>row.id)).toEqual(["one"]); - expect(queryExerciseSearch(rows,"xyz")).toEqual([]); +const exercise = ( + id: string, + name: string, + extra: Partial = {}, +): Exercise => ({ + id, + name, + muscle: "Chest", + equipment: "Barbell", + tracking: "weight_reps", + ...extra, +}); +it("does not treat a lone two-character word as a broad prefix match", () => { + expect( + queryExerciseSearch([exercise("one", "Bench Press")], "be zz"), + ).toEqual([]); + expect( + queryExerciseSearch([exercise("one", "Bench Press")], "ben zz").map( + (row) => row.id, + ), + ).toEqual(["one"]); +}); +it("ranks exact, prefix, substring and aliases before broad metadata matches", () => { + const rows = [ + exercise("broad", "Z Push", { secondaryMuscles: ["Bench"] }), + exercise("alias", "A Press", { aliases: ["Bench"] }), + exercise("substring", "Incline Bench Press"), + exercise("prefix", "Bench Press"), + exercise("exact", "Bench"), + ]; + expect(queryExerciseSearch(rows, "bench").map((row) => row.id)).toEqual([ + "exact", + "prefix", + "alias", + "substring", + "broad", + ]); + expect(queryExerciseSearch(rows, "!!!")).toEqual(rows); +}); +it("normalizes camel case and punctuation and searches metadata while excluding unrelated rows", () => { + const rows = [ + exercise("one", "UpperBodyPress", { + difficulty: "beginner", + movementPattern: "push", + }), + exercise("two", "Cable Row", { muscle: "Back", equipment: "Cable" }), + ]; + expect(queryExerciseSearch(rows, "upper-body").map((row) => row.id)).toEqual([ + "one", + ]); + expect( + queryExerciseSearch(rows, "beginner push").map((row) => row.id), + ).toEqual(["one"]); + expect(queryExerciseSearch(rows, "xyz")).toEqual([]); }); diff --git a/web/tests/exercise-tutorial.test.ts b/web/tests/exercise-tutorial.test.ts new file mode 100644 index 0000000..aaaea1a --- /dev/null +++ b/web/tests/exercise-tutorial.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; +import { exerciseTutorial } from "../src/domain/exercise-tutorial"; +import links from "../src/generated/exercise-tutorials.json"; +import { readFileSync } from "node:fs"; +it("uses the native name key and preserves mapped URLs exactly", () => { + expect(exerciseTutorial(" PLANK ")).toBe(links.plank); + expect(exerciseTutorial("Ab Crunch Machine")).toBe(links.abcrunchmachine); + expect(exerciseTutorial("not a catalog exercise")).toBeUndefined(); + expect(exerciseTutorial("constructor")).toBeUndefined(); +}); +it("contains only HTTP or HTTPS destinations exported from the native map", () => { + const native = JSON.parse(readFileSync("../app/src/main/assets/ironlog/exercise_youtube_by_normalized_name.json", "utf8")) as Record; + expect(links).toEqual(Object.fromEntries(Object.entries(native).map(([key, row]) => [key, row.youtubeLink]))); + for (const link of Object.values(links)) + expect(["http:", "https:"]).toContain(new URL(link).protocol); +});