From e7bd55d20974f4ee5b892cd2e81c62b5c5408920 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 16 Aug 2026 11:30:30 +0200 Subject: refactor(web): extract testable profile logic --- .mise.toml | 10 +++- web/server_test.go | 27 ++++------ web/static/official-climb-profile-logic.js | 47 +++++++++++++++++ web/static/official-climb-profile.js | 61 ++++------------------ .../official-climb-profile-logic.test.js | 41 +++++++++++++++ web/templates.templ | 1 + web/templates_templ.go | 10 ++-- 7 files changed, 123 insertions(+), 74 deletions(-) create mode 100644 web/static/official-climb-profile-logic.js create mode 100644 web/static_test/official-climb-profile-logic.test.js diff --git a/.mise.toml b/.mise.toml index 6c64293..785f457 100644 --- a/.mise.toml +++ b/.mise.toml @@ -12,9 +12,15 @@ description = "Format Go sources" run = "go fmt ./..." [tasks.check] -description = "Run generation, vet, and tests" +description = "Run formatting, JavaScript and Go gate checks" depends = ["generate", "format"] -run = "go vet ./... && go test ./..." +run = [ + "for file in web/static/*.js; do node --check \"$file\"; done", + "node --test web/static_test/*.test.js", + "go vet ./...", + "go build ./...", + "go test ./...", +] [tasks.build] description = "Build the application" diff --git a/web/server_test.go b/web/server_test.go index 281f46a..a4ee0b7 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -135,9 +135,9 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.NotContains(t, body, "Climbs to match") assert.Contains(t, body, `class="ride-detail-main"`) assert.Contains(t, body, ``) + assert.Contains(t, body, ``) assert.Contains(t, body, ``) assert.Contains(t, body, ``) - assert.NotContains(t, body, "pointermove") staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil) staticResponse := httptest.NewRecorder() @@ -146,17 +146,8 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { script := staticResponse.Body.String() assert.Equal(t, http.StatusOK, staticResponse.Code) assert.Equal(t, "text/javascript; charset=utf-8", staticResponse.Header().Get("Content-Type")) - assert.Contains(t, script, "pointermove") - assert.Contains(t, script, "circleMarker") - assert.Contains(t, script, "crossing.passElevationM") - assert.Contains(t, script, "climbRoute") - assert.Contains(t, script, "L.polyline") - assert.Contains(t, script, "zoomToClimb") - assert.Contains(t, script, "Cotacol") - assert.Contains(t, script, "cotacolForClimb") - assert.Contains(t, script, "const labelY = plot.top - 6") + assert.NotEmpty(t, script) assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js") - assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png") assert.Contains(t, body, `"type":"FeatureCollection"`) assert.Contains(t, body, `"type":"LineString"`) assert.Contains(t, body, `"coordinates":[[5,43]`) @@ -168,11 +159,15 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { profileScript := profileStaticResponse.Body.String() assert.Equal(t, http.StatusOK, profileStaticResponse.Code) assert.Equal(t, "text/javascript; charset=utf-8", profileStaticResponse.Header().Get("Content-Type")) - assert.Contains(t, profileScript, "officialProfileSections") - assert.Contains(t, profileScript, "profileStepSizesM = [100, 200, 500, 1000]") - assert.Contains(t, profileScript, "displayStepForLength") - assert.Contains(t, profileScript, "profileBandForSlope") - assert.Contains(t, profileScript, "section.slopePercent.toFixed(1)") + assert.NotEmpty(t, profileScript) + + logicStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile-logic.js", nil) + logicStaticResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(logicStaticResponse, logicStaticRequest) + + assert.Equal(t, http.StatusOK, logicStaticResponse.Code) + assert.Equal(t, "text/javascript; charset=utf-8", logicStaticResponse.Header().Get("Content-Type")) + assert.NotEmpty(t, logicStaticResponse.Body.String()) } func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) { diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js new file mode 100644 index 0000000..c84960b --- /dev/null +++ b/web/static/official-climb-profile-logic.js @@ -0,0 +1,47 @@ +(() => { + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const profileStepSizesM = [100, 200, 500, 1000]; + const displayStepForLength = (lengthM) => profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; + const profileBandForSlope = (slopePercent) => { + if (slopePercent < 0) return "downhill"; + if (slopePercent < 3) return "0-3"; + if (slopePercent < 6) return "3-6"; + if (slopePercent < 9) return "6-9"; + if (slopePercent < 12) return "9-12"; + return "12-plus"; + }; + const officialProfileSections = (points, startIndex, endIndex, stepM) => { + const startDistanceM = points[startIndex].distanceKm * 1000; + const endDistanceM = points[endIndex].distanceKm * 1000; + const sections = []; + let pointIndex = startIndex; + const elevationAtDistance = (distanceM) => { + while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++; + const nextIndex = Math.min(pointIndex + 1, endIndex); + const first = points[pointIndex]; + const next = points[nextIndex]; + const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000; + if (distanceSpan <= 0) return first.elevationM; + const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan; + return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM); + }; + for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) { + const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM); + const startElevation = elevationAtDistance(sectionStartM); + const endElevation = elevationAtDistance(sectionEndM); + const slopePercent = (endElevation - startElevation) / (sectionEndM - sectionStartM) * 100; + sections.push({ + startDistanceKm: sectionStartM / 1000, + endDistanceKm: sectionEndM / 1000, + startElevation, + endElevation, + slopePercent, + band: profileBandForSlope(slopePercent), + }); + } + return sections; + }; + const api = { displayStepForLength, profileBandForSlope, officialProfileSections }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else window.OfficialClimbProfileLogic = api; +})(); diff --git a/web/static/official-climb-profile.js b/web/static/official-climb-profile.js index d03f0d5..603edfe 100644 --- a/web/static/official-climb-profile.js +++ b/web/static/official-climb-profile.js @@ -1,5 +1,6 @@ (() => { const createOfficialClimbProfileController = (points) => { + const { displayStepForLength, officialProfileSections } = window.OfficialClimbProfileLogic; const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); @@ -8,12 +9,12 @@ return getComputedStyle(colorProbe).color; }; const colors = { - profileDownhill: resolveColor("--color-profile-downhill"), - profile0To3: resolveColor("--color-profile-0-3"), - profile3To6: resolveColor("--color-profile-3-6"), - profile6To9: resolveColor("--color-profile-6-9"), - profile9To12: resolveColor("--color-profile-9-12"), - profile12Plus: resolveColor("--color-profile-12-plus"), + downhill: resolveColor("--color-profile-downhill"), + "0-3": resolveColor("--color-profile-0-3"), + "3-6": resolveColor("--color-profile-3-6"), + "6-9": resolveColor("--color-profile-6-9"), + "9-12": resolveColor("--color-profile-9-12"), + "12-plus": resolveColor("--color-profile-12-plus"), plotSurface: resolveColor("--color-plot-surface"), grid: resolveColor("--color-plot-grid"), subtle: resolveColor("--color-subtle"), @@ -21,48 +22,6 @@ }; colorProbe.remove(); const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const profileStepSizesM = [100, 200, 500, 1000]; - const displayStepForLength = (lengthM) => profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; - const profileBandForSlope = (slopePercent) => { - if (slopePercent < 0) return colors.profileDownhill; - if (slopePercent < 3) return colors.profile0To3; - if (slopePercent < 6) return colors.profile3To6; - if (slopePercent < 9) return colors.profile6To9; - if (slopePercent < 12) return colors.profile9To12; - return colors.profile12Plus; - }; - const officialProfileSections = (startIndex, endIndex, stepM) => { - const startDistanceM = points[startIndex].distanceKm * 1000; - const endDistanceM = points[endIndex].distanceKm * 1000; - const sections = []; - let pointIndex = startIndex; - const elevationAtDistance = (distanceM) => { - while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++; - const nextIndex = Math.min(pointIndex + 1, endIndex); - const first = points[pointIndex]; - const next = points[nextIndex]; - const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000; - if (distanceSpan <= 0) return first.elevationM; - const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan; - return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM); - }; - for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) { - const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM); - const startElevation = elevationAtDistance(sectionStartM); - const endElevation = elevationAtDistance(sectionEndM); - const slopePercent = (endElevation - startElevation) / (sectionEndM - sectionStartM) * 100; - sections.push({ - startDistanceKm: sectionStartM / 1000, - endDistanceKm: sectionEndM / 1000, - startElevation, - endElevation, - slopePercent, - color: profileBandForSlope(slopePercent), - }); - } - return sections; - }; const drawOfficialProfile = (profileCanvas) => { const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10); const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10); @@ -77,7 +36,7 @@ context.setTransform(ratio, 0, 0, ratio, 0, 0); const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 }; const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; - const sections = officialProfileSections(startIndex, endIndex, displayStepForLength(climbLengthM)); + const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); if (sections.length === 0) return; let minElevation = sections[0].startElevation; let maxElevation = minElevation; @@ -113,13 +72,13 @@ context.lineTo(endX, plot.bottom); context.closePath(); context.globalAlpha = 0.25; - context.fillStyle = section.color; + context.fillStyle = colors[section.band]; context.fill(); context.globalAlpha = 1; context.beginPath(); context.moveTo(startX, yForElevation(section.startElevation)); context.lineTo(endX, yForElevation(section.endElevation)); - context.strokeStyle = section.color; + context.strokeStyle = colors[section.band]; context.lineWidth = 2.5; context.stroke(); } diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/static_test/official-climb-profile-logic.test.js new file mode 100644 index 0000000..f663a65 --- /dev/null +++ b/web/static_test/official-climb-profile-logic.test.js @@ -0,0 +1,41 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + displayStepForLength, + profileBandForSlope, + officialProfileSections, +} = require("../static/official-climb-profile-logic.js"); + +test("selects a display step that keeps long profiles readable", () => { + assert.equal(displayStepForLength(2_400), 100); + assert.equal(displayStepForLength(4_500), 200); + assert.equal(displayStepForLength(7_000), 500); + assert.equal(displayStepForLength(20_000), 1000); +}); + +test("assigns each slope to its color band", () => { + assert.equal(profileBandForSlope(-0.1), "downhill"); + assert.equal(profileBandForSlope(2.9), "0-3"); + assert.equal(profileBandForSlope(3), "3-6"); + assert.equal(profileBandForSlope(6), "6-9"); + assert.equal(profileBandForSlope(9), "9-12"); + assert.equal(profileBandForSlope(12), "12-plus"); +}); + +test("interpolates profile sections at the selected step", () => { + const points = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.12, elevationM: 106 }, + { distanceKm: 0.2, elevationM: 114 }, + { distanceKm: 0.25, elevationM: 120 }, + ]; + const sections = officialProfileSections(points, 0, 3, 100); + + assert.equal(sections.length, 3); + assert.equal(sections[0].startDistanceKm, 0); + assert.equal(sections[0].endDistanceKm, 0.1); + assert.equal(sections[0].startElevation, 100); + assert.equal(sections[0].endElevation, 105); + assert.equal(sections[2].endDistanceKm, 0.25); + assert.equal(sections[2].endElevation, 120); +}); diff --git a/web/templates.templ b/web/templates.templ index 1decad5..fe5c363 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -171,6 +171,7 @@ templ RideDetailContent(data RideDetailView) { @templ.JSONScript("ride-route", data.Route) @templ.JSONScript("ride-profile", data.Profile) + } diff --git a/web/templates_templ.go b/web/templates_templ.go index 0ecca3d..eee38dd 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -912,7 +912,7 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -983,7 +983,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var59 string templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 188, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 189, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { @@ -1007,7 +1007,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var60 string templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 193, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 194, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) if templ_7745c5c3_Err != nil { @@ -1049,7 +1049,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var62 string templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 204, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { @@ -1062,7 +1062,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var63 string templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 206, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { -- cgit v1.2.3