fix low level issues
This commit is contained in:
@@ -27,6 +27,22 @@
|
|||||||
- **No error handling in `uploadImages`** (`EditDrawer.svelte`) — wrapped upload loop in try/catch/finally so a network error clears the `uploading` spinner and surfaces an error message instead of locking the UI
|
- **No error handling in `uploadImages`** (`EditDrawer.svelte`) — wrapped upload loop in try/catch/finally so a network error clears the `uploading` spinner and surfaces an error message instead of locking the UI
|
||||||
- **Stats page pagination** (`StatsView.svelte`) — heatmap now shows 4 years per page with ← Newer / Older → controls; `?page=` persisted in URL
|
- **Stats page pagination** (`StatsView.svelte`) — heatmap now shows 4 years per page with ← Newer / Older → controls; `?page=` persisted in URL
|
||||||
|
|
||||||
|
### Bug fixes — data (continued)
|
||||||
|
|
||||||
|
- **`_best_climb` joins non-contiguous elevation segments** (`metrics.py`) — `None` elevation samples now reset the Kadane's window instead of being skipped and joined; GPS blackout segments can no longer inflate climb values
|
||||||
|
- **`save_athlete` mutated `athlete.json` in-place** (`edit/server.py`, `render/merge.py`) — server now only writes `edits/athlete.yaml`; `merge_all()` applies the sidecar overlay when producing `_merged/athlete.json`, preserving extract immutability
|
||||||
|
- **`preview_coords` off-by-one** (`simplify.py`) — subsampler was appending the final GPS point on top of `max_points`, returning `max_points + 1`; now samples `max_points - 1` slots then appends last point
|
||||||
|
- **Non-monotonic timeseries** (`timeseries.py`) — dedup guard changed from `t == last_t` to `t <= last_t`; backwards timestamps from corrupt files are now dropped instead of creating out-of-order `t` arrays
|
||||||
|
- **`_patch_duplicate_of` silently swallows exceptions** (`cli.py`) — changed bare `except: pass` to log a warning so failures surface during extract
|
||||||
|
|
||||||
|
### Bug fixes — frontend (continued)
|
||||||
|
|
||||||
|
- **Hardcoded `/activity/` URLs ignore `BASE_URL`** (`RecordsView.svelte`, `Base.astro`) — `base` prop now threaded from Astro page → `AthleteView` → `RecordsView`; upload redirect uses `import.meta.env.BASE_URL` via `define:vars`
|
||||||
|
- **No error handling on stats page fetch** (`StatsView.svelte`) — `index.json` fetch wrapped in try/catch; error message displayed in place of heatmap instead of silent failure
|
||||||
|
- **Map doesn't resize on container change** (`ActivityMap.svelte`) — `ResizeObserver` added to call `map.resize()` when the map container is resized
|
||||||
|
- **`formatDuration` fractional seconds** (`format.ts`) — input floored with `Math.floor` before arithmetic; `1500.7 s` no longer displays as `25m 00.7s`
|
||||||
|
- **Empty YAML config crashes** (`render/cli.py`, `edit/cli.py`) — `yaml.safe_load()` result guarded with `or {}`; empty config file no longer throws `AttributeError` on `.get()`
|
||||||
|
|
||||||
### Schema
|
### Schema
|
||||||
|
|
||||||
- **Writer output now matches schema** (`bas-v1.schema.json`) — `mmp`, `best_efforts`, `best_climb_m`, `preview_coords`, and `custom` are all declared in the schema; previously `additionalProperties: false` caused validation failures
|
- **Writer output now matches schema** (`bas-v1.schema.json`) — `mmp`, `best_efforts`, `best_climb_m`, `preview_coords`, and `custom` are all declared in the schema; previously `additionalProperties: false` caused validation failures
|
||||||
@@ -34,6 +50,12 @@
|
|||||||
- **Sub-sport enum extended** — `nordic`, `alpine`, `open_water`, `pool` added to schema
|
- **Sub-sport enum extended** — `nordic`, `alpine`, `open_water`, `pool` added to schema
|
||||||
- **Activity ID format corrected in SCHEMA.md** — examples updated from `+0200` offset to `Z` UTC suffix (matching actual code behaviour since v0.1.0)
|
- **Activity ID format corrected in SCHEMA.md** — examples updated from `+0200` offset to `Z` UTC suffix (matching actual code behaviour since v0.1.0)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **Exact ID assertions** (`test_writer.py`) — `test_id_with_title` and `test_id_without_title` now assert the full ID string (`2024-06-01T073012Z-morning-ride`) instead of substrings
|
||||||
|
- **`normalise_sub_sport` test coverage** (`test_sport.py`) — 3 new tests: Strava CamelCase conversion, ski variants, and unknown/None → `None`
|
||||||
|
- **Invalid sport in test_merge** (`test_merge.py`) — `sport: "gravel"` replaced with valid `"running"`
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
|
|
||||||
- **URL state persistence** — filter and tab state is now stored in the URL query string so the browser back button always restores the exact view you left
|
- **URL state persistence** — filter and tab state is now stored in the URL query string so the browser back button always restores the exact view you left
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Pa
|
|||||||
|
|
||||||
if config_path and Path(config_path).exists():
|
if config_path and Path(config_path).exists():
|
||||||
import yaml
|
import yaml
|
||||||
raw = yaml.safe_load(Path(config_path).read_text())
|
raw = yaml.safe_load(Path(config_path).read_text()) or {}
|
||||||
out = raw.get("output", {}).get("dir")
|
out = raw.get("output", {}).get("dir")
|
||||||
if out:
|
if out:
|
||||||
return Path(out).expanduser().resolve()
|
return Path(out).expanduser().resolve()
|
||||||
|
|||||||
@@ -506,12 +506,7 @@ async def save_athlete(payload: dict[str, Any]) -> JSONResponse:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Patch athlete.json in-place (preserves power_curve, updated_at, etc.)
|
# Re-merge — merge_all() applies edits/athlete.yaml on top of athlete.json
|
||||||
data = json.loads(athlete_path.read_text(encoding="utf-8"))
|
|
||||||
data.update(overrides)
|
|
||||||
athlete_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
# Re-merge so _merged/athlete.json symlink stays valid
|
|
||||||
from bincio.render.merge import merge_all
|
from bincio.render.merge import merge_all
|
||||||
merge_all(dd)
|
merge_all(dd)
|
||||||
|
|
||||||
|
|||||||
@@ -346,5 +346,6 @@ def _patch_duplicate_of(output_dir: Path, activity_id: str, canonical_id: str) -
|
|||||||
data = json.loads(p.read_text())
|
data = json.loads(p.read_text())
|
||||||
data["duplicate_of"] = canonical_id
|
data["duplicate_of"] = canonical_id
|
||||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
p.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("_patch_duplicate_of failed for %s: %s", activity_id, e)
|
||||||
|
|||||||
@@ -237,19 +237,29 @@ def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Opti
|
|||||||
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
|
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
|
||||||
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
|
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
|
||||||
|
|
||||||
Ignores samples where elevation is None. Returns None if fewer than two
|
None samples are treated as breaks between segments — the Kadane window is
|
||||||
valid elevation samples exist.
|
reset to 0 at each gap so non-contiguous elevation data is never joined.
|
||||||
|
Returns None if fewer than two non-None samples exist.
|
||||||
"""
|
"""
|
||||||
valid = [e for e in ele_1hz if e is not None]
|
non_null = sum(1 for e in ele_1hz if e is not None)
|
||||||
if len(valid) < 2:
|
if non_null < 2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
max_gain = 0.0
|
max_gain = 0.0
|
||||||
current = 0.0
|
current = 0.0
|
||||||
for a, b in zip(valid, valid[1:]):
|
prev: Optional[float] = None
|
||||||
current = max(0.0, current + (b - a))
|
|
||||||
if current > max_gain:
|
for e in ele_1hz:
|
||||||
max_gain = current
|
if e is None:
|
||||||
|
# Gap — reset window so we don't bridge the discontinuity
|
||||||
|
current = 0.0
|
||||||
|
prev = None
|
||||||
|
continue
|
||||||
|
if prev is not None:
|
||||||
|
current = max(0.0, current + (e - prev))
|
||||||
|
if current > max_gain:
|
||||||
|
max_gain = current
|
||||||
|
prev = e
|
||||||
|
|
||||||
return round(max_gain, 1) if max_gain > 0 else None
|
return round(max_gain, 1) if max_gain > 0 else None
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ def preview_coords(
|
|||||||
mask = rdp(coords, epsilon=0.001, return_mask=True)
|
mask = rdp(coords, epsilon=0.001, return_mask=True)
|
||||||
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
|
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
|
||||||
|
|
||||||
# Subsample if still too many
|
# Subsample if still too many — always include last point without exceeding max_points
|
||||||
if len(reduced) > max_points:
|
if len(reduced) > max_points:
|
||||||
step = len(reduced) / max_points
|
step = len(reduced) / (max_points - 1)
|
||||||
reduced = [reduced[int(i * step)] for i in range(max_points)]
|
reduced = [reduced[int(i * step)] for i in range(max_points - 1)]
|
||||||
reduced.append(gps[-1]) # always include the last point
|
reduced.append(gps[-1])
|
||||||
|
|
||||||
return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced]
|
return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced]
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ def build_timeseries(
|
|||||||
t = int((p.timestamp - started_at).total_seconds())
|
t = int((p.timestamp - started_at).total_seconds())
|
||||||
if t < 0:
|
if t < 0:
|
||||||
continue
|
continue
|
||||||
if last_t is not None and t == last_t:
|
if last_t is not None and t <= last_t:
|
||||||
continue # skip sub-second duplicates
|
continue # skip sub-second duplicates and non-monotonic points
|
||||||
sampled.append(p)
|
sampled.append(p)
|
||||||
last_t = t
|
last_t = t
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
|
|||||||
|
|
||||||
if config_path and Path(config_path).exists():
|
if config_path and Path(config_path).exists():
|
||||||
import yaml
|
import yaml
|
||||||
raw = yaml.safe_load(Path(config_path).read_text())
|
raw = yaml.safe_load(Path(config_path).read_text()) or {}
|
||||||
out = raw.get("output", {}).get("dir")
|
out = raw.get("output", {}).get("dir")
|
||||||
if out:
|
if out:
|
||||||
return Path(out).expanduser().resolve()
|
return Path(out).expanduser().resolve()
|
||||||
@@ -47,7 +47,7 @@ def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
|
|||||||
auto_config = Path.cwd() / "extract_config.yaml"
|
auto_config = Path.cwd() / "extract_config.yaml"
|
||||||
if auto_config.exists():
|
if auto_config.exists():
|
||||||
import yaml
|
import yaml
|
||||||
raw = yaml.safe_load(auto_config.read_text())
|
raw = yaml.safe_load(auto_config.read_text()) or {}
|
||||||
out = raw.get("output", {}).get("dir")
|
out = raw.get("output", {}).get("dir")
|
||||||
if out:
|
if out:
|
||||||
return Path(out).expanduser().resolve()
|
return Path(out).expanduser().resolve()
|
||||||
|
|||||||
+16
-2
@@ -140,13 +140,27 @@ def merge_all(data_dir: Path) -> int:
|
|||||||
if not dest_img.exists():
|
if not dest_img.exists():
|
||||||
dest_img.symlink_to(img_dir.resolve())
|
dest_img.symlink_to(img_dir.resolve())
|
||||||
|
|
||||||
# Symlink athlete.json if present
|
# Produce merged athlete.json — base from extract overlaid with edits/athlete.yaml
|
||||||
athlete_src = data_dir / "athlete.json"
|
athlete_src = data_dir / "athlete.json"
|
||||||
athlete_dest = merged_dir / "athlete.json"
|
athlete_dest = merged_dir / "athlete.json"
|
||||||
if athlete_dest.exists() or athlete_dest.is_symlink():
|
if athlete_dest.exists() or athlete_dest.is_symlink():
|
||||||
athlete_dest.unlink()
|
athlete_dest.unlink()
|
||||||
if athlete_src.exists():
|
if athlete_src.exists():
|
||||||
athlete_dest.symlink_to(athlete_src.resolve())
|
athlete_edits_path = data_dir / "edits" / "athlete.yaml"
|
||||||
|
if athlete_edits_path.exists():
|
||||||
|
try:
|
||||||
|
import yaml as _yaml
|
||||||
|
edits = _yaml.safe_load(athlete_edits_path.read_text(encoding="utf-8")) or {}
|
||||||
|
except Exception:
|
||||||
|
edits = {}
|
||||||
|
else:
|
||||||
|
edits = {}
|
||||||
|
if edits:
|
||||||
|
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
||||||
|
athlete_data.update(edits)
|
||||||
|
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
athlete_dest.symlink_to(athlete_src.resolve())
|
||||||
|
|
||||||
# Write merged index.json (private filtered, highlight sorted)
|
# Write merged index.json (private filtered, highlight sorted)
|
||||||
index_path = data_dir / "index.json"
|
index_path = data_dir / "index.json"
|
||||||
|
|||||||
@@ -120,7 +120,17 @@
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => map?.remove());
|
onDestroy(() => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
map?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver;
|
||||||
|
$: if (mapEl && map) {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = new ResizeObserver(() => map?.resize());
|
||||||
|
resizeObserver.observe(mapEl);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={mapEl} class="w-full h-full"></div>
|
<div bind:this={mapEl} class="w-full h-full"></div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import RecordsView from './RecordsView.svelte';
|
import RecordsView from './RecordsView.svelte';
|
||||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
|
|
||||||
|
export let base: string = '/';
|
||||||
|
|
||||||
let athlete: AthleteJson | null = null;
|
let athlete: AthleteJson | null = null;
|
||||||
let activities: ActivitySummary[] = [];
|
let activities: ActivitySummary[] = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
@@ -106,7 +108,7 @@
|
|||||||
|
|
||||||
<!-- Records tab -->
|
<!-- Records tab -->
|
||||||
{:else if activeTab === 'records'}
|
{:else if activeTab === 'records'}
|
||||||
<RecordsView {athlete} />
|
<RecordsView {athlete} {base} />
|
||||||
|
|
||||||
<!-- Profile tab -->
|
<!-- Profile tab -->
|
||||||
{:else if activeTab === 'profile'}
|
{:else if activeTab === 'profile'}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { formatDate, sportIcon, sportColor } from '../lib/format';
|
import { formatDate, sportIcon, sportColor } from '../lib/format';
|
||||||
|
|
||||||
export let athlete: AthleteJson;
|
export let athlete: AthleteJson;
|
||||||
|
export let base: string = '/';
|
||||||
|
|
||||||
// ── Distance label formatting ──────────────────────────────────────────────
|
// ── Distance label formatting ──────────────────────────────────────────────
|
||||||
function distLabel(km: number): string {
|
function distLabel(km: number): string {
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
return (athlete as any).records?.[sport]?.[key] ?? null;
|
return (athlete as any).records?.[sport]?.[key] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityUrl = (id: string) => `/activity/${id}/`;
|
const activityUrl = (id: string) => `${base}activity/${id}/`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Sport tabs -->
|
<!-- Sport tabs -->
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
let sport: Sport | 'all' = 'all';
|
let sport: Sport | 'all' = 'all';
|
||||||
let page = 0;
|
let page = 0;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
let theme = 'dark';
|
let theme = 'dark';
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
@@ -28,9 +29,14 @@
|
|||||||
sport = (params.get('sport') as Sport | 'all') ?? 'all';
|
sport = (params.get('sport') as Sport | 'all') ?? 'all';
|
||||||
page = parseInt(params.get('page') ?? '0', 10) || 0;
|
page = parseInt(params.get('page') ?? '0', 10) || 0;
|
||||||
mounted = true;
|
mounted = true;
|
||||||
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
try {
|
||||||
const index: BASIndex = await res.json();
|
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
||||||
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const index: BASIndex = await res.json();
|
||||||
|
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
theme = document.documentElement.getAttribute('data-theme') ?? 'dark';
|
theme = document.documentElement.getAttribute('data-theme') ?? 'dark';
|
||||||
@@ -282,6 +288,8 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6"></div>
|
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6"></div>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-400 text-sm mt-4">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
<!-- Pagination controls -->
|
<!-- Pagination controls -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props;
|
const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props;
|
||||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||||
|
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
@@ -174,7 +175,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{editUrl && (
|
{editUrl && (
|
||||||
<script define:vars={{ editUrl }}>
|
<script define:vars={{ editUrl, baseUrl }}>
|
||||||
const modal = document.getElementById('upload-modal');
|
const modal = document.getElementById('upload-modal');
|
||||||
const drop = document.getElementById('upload-drop');
|
const drop = document.getElementById('upload-drop');
|
||||||
const input = document.getElementById('upload-input');
|
const input = document.getElementById('upload-input');
|
||||||
@@ -217,7 +218,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
status.textContent = 'Done! Opening activity…';
|
status.textContent = 'Done! Opening activity…';
|
||||||
status.style.color = '#4ade80';
|
status.style.color = '#4ade80';
|
||||||
setTimeout(() => { window.location.href = `/activity/${d.id}/`; }, 600);
|
setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = 'Error: ' + e.message;
|
status.textContent = 'Error: ' + e.message;
|
||||||
status.style.color = '#f87171';
|
status.style.color = '#f87171';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = '
|
|||||||
|
|
||||||
export function formatDuration(s: number | null): string {
|
export function formatDuration(s: number | null): string {
|
||||||
if (s == null) return '—';
|
if (s == null) return '—';
|
||||||
|
s = Math.floor(s);
|
||||||
const h = Math.floor(s / 3600);
|
const h = Math.floor(s / 3600);
|
||||||
const m = Math.floor((s % 3600) / 60);
|
const m = Math.floor((s % 3600) / 60);
|
||||||
const sec = s % 60;
|
const sec = s % 60;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
---
|
---
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import AthleteView from '../../components/AthleteView.svelte';
|
import AthleteView from '../../components/AthleteView.svelte';
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
---
|
---
|
||||||
<Base title="Athlete — BincioActivity">
|
<Base title="Athlete — BincioActivity">
|
||||||
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
|
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
|
||||||
<AthleteView client:load />
|
<AthleteView {base} client:load />
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
+2
-2
@@ -67,10 +67,10 @@ BASE_DETAIL = {
|
|||||||
|
|
||||||
|
|
||||||
def test_apply_sidecar_title_and_sport():
|
def test_apply_sidecar_title_and_sport():
|
||||||
fm = {"title": "Renamed", "sport": "gravel"}
|
fm = {"title": "Renamed", "sport": "running"}
|
||||||
result = apply_sidecar(BASE_DETAIL, fm, "")
|
result = apply_sidecar(BASE_DETAIL, fm, "")
|
||||||
assert result["title"] == "Renamed"
|
assert result["title"] == "Renamed"
|
||||||
assert result["sport"] == "gravel"
|
assert result["sport"] == "running"
|
||||||
# Original must be unchanged
|
# Original must be unchanged
|
||||||
assert BASE_DETAIL["title"] == "Morning Ride"
|
assert BASE_DETAIL["title"] == "Morning Ride"
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -1,4 +1,4 @@
|
|||||||
from bincio.extract.sport import normalise_sport
|
from bincio.extract.sport import normalise_sport, normalise_sub_sport
|
||||||
|
|
||||||
|
|
||||||
def test_cycling_variants():
|
def test_cycling_variants():
|
||||||
@@ -14,3 +14,22 @@ def test_running_variants():
|
|||||||
def test_unknown_falls_back_to_other():
|
def test_unknown_falls_back_to_other():
|
||||||
assert normalise_sport("yoga") == "other"
|
assert normalise_sport("yoga") == "other"
|
||||||
assert normalise_sport(None) == "other"
|
assert normalise_sport(None) == "other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sub_sport_strava_camelcase():
|
||||||
|
assert normalise_sub_sport("MountainBikeRide") == "mountain"
|
||||||
|
assert normalise_sub_sport("GravelRide") == "gravel"
|
||||||
|
assert normalise_sub_sport("VirtualRide") == "indoor"
|
||||||
|
assert normalise_sub_sport("Ride") == "road"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sub_sport_ski_variants():
|
||||||
|
assert normalise_sub_sport("AlpineSki") == "alpine"
|
||||||
|
assert normalise_sub_sport("NordicSki") == "nordic"
|
||||||
|
assert normalise_sub_sport("BackcountrySki") == "nordic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sub_sport_unknown_returns_none():
|
||||||
|
assert normalise_sub_sport("yoga") is None
|
||||||
|
assert normalise_sub_sport(None) is None
|
||||||
|
assert normalise_sub_sport("generic") is None
|
||||||
|
|||||||
@@ -18,16 +18,13 @@ def _dummy_activity(title=None):
|
|||||||
def test_id_with_title():
|
def test_id_with_title():
|
||||||
act = _dummy_activity("Morning Ride")
|
act = _dummy_activity("Morning Ride")
|
||||||
aid = make_activity_id(act)
|
aid = make_activity_id(act)
|
||||||
assert aid.startswith("2024-06-01T")
|
assert aid == "2024-06-01T073012Z-morning-ride"
|
||||||
assert "morning-ride" in aid
|
|
||||||
|
|
||||||
|
|
||||||
def test_id_without_title():
|
def test_id_without_title():
|
||||||
act = _dummy_activity()
|
act = _dummy_activity()
|
||||||
aid = make_activity_id(act)
|
aid = make_activity_id(act)
|
||||||
assert "2024-06-01T" in aid
|
assert aid == "2024-06-01T073012Z"
|
||||||
# No trailing dash
|
|
||||||
assert not aid.endswith("-")
|
|
||||||
|
|
||||||
|
|
||||||
def test_slugify():
|
def test_slugify():
|
||||||
|
|||||||
Reference in New Issue
Block a user