@jjlmoya/utils-drones 1.39.0 → 1.40.0

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/fpv-drone-lap-timer/audio.ts +160 -0
  8. package/src/tool/fpv-drone-lap-timer/bibliography.astro +6 -0
  9. package/src/tool/fpv-drone-lap-timer/bibliography.ts +14 -0
  10. package/src/tool/fpv-drone-lap-timer/bootstrap.ts +20 -0
  11. package/src/tool/fpv-drone-lap-timer/component.astro +136 -0
  12. package/src/tool/fpv-drone-lap-timer/contract.test.ts +24 -0
  13. package/src/tool/fpv-drone-lap-timer/controller.ts +304 -0
  14. package/src/tool/fpv-drone-lap-timer/dom-views.ts +213 -0
  15. package/src/tool/fpv-drone-lap-timer/entry.ts +27 -0
  16. package/src/tool/fpv-drone-lap-timer/export.ts +41 -0
  17. package/src/tool/fpv-drone-lap-timer/fpv-drone-lap-timer.css +705 -0
  18. package/src/tool/fpv-drone-lap-timer/i18n/de.ts +216 -0
  19. package/src/tool/fpv-drone-lap-timer/i18n/en.ts +216 -0
  20. package/src/tool/fpv-drone-lap-timer/i18n/es.ts +216 -0
  21. package/src/tool/fpv-drone-lap-timer/i18n/fr.ts +216 -0
  22. package/src/tool/fpv-drone-lap-timer/i18n/id.ts +216 -0
  23. package/src/tool/fpv-drone-lap-timer/i18n/it.ts +216 -0
  24. package/src/tool/fpv-drone-lap-timer/i18n/ja.ts +216 -0
  25. package/src/tool/fpv-drone-lap-timer/i18n/ko.ts +216 -0
  26. package/src/tool/fpv-drone-lap-timer/i18n/nl.ts +216 -0
  27. package/src/tool/fpv-drone-lap-timer/i18n/pl.ts +216 -0
  28. package/src/tool/fpv-drone-lap-timer/i18n/pt.ts +216 -0
  29. package/src/tool/fpv-drone-lap-timer/i18n/ru.ts +216 -0
  30. package/src/tool/fpv-drone-lap-timer/i18n/sv.ts +216 -0
  31. package/src/tool/fpv-drone-lap-timer/i18n/tr.ts +216 -0
  32. package/src/tool/fpv-drone-lap-timer/i18n/zh.ts +216 -0
  33. package/src/tool/fpv-drone-lap-timer/index.ts +10 -0
  34. package/src/tool/fpv-drone-lap-timer/logic.test.ts +152 -0
  35. package/src/tool/fpv-drone-lap-timer/logic.ts +228 -0
  36. package/src/tool/fpv-drone-lap-timer/seo.astro +16 -0
  37. package/src/tool/fpv-drone-lap-timer/storage.ts +52 -0
  38. package/src/tool/fpv-drone-lap-timer/ui.ts +58 -0
  39. package/src/tools.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-drones",
3
- "version": "1.39.0",
3
+ "version": "1.40.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -9,6 +9,7 @@ import { fpvDroneThrustToWeightRatio } from '../tool/fpv-drone-thrust-to-weight-
9
9
  import { droneMotorPropellerCalculator } from '../tool/drone-motor-propeller-calculator/entry';
10
10
  import { fpvDroneSpeedCalculator } from '../tool/fpv-drone-speed-calculator/entry';
11
11
  import { droneMissionBatteryReservePlanner } from '../tool/drone-mission-battery-reserve-planner/entry';
12
+ import { fpvDroneLapTimer } from '../tool/fpv-drone-lap-timer/entry';
12
13
 
13
14
  export const dronesCategory: DronesCategoryEntry = {
14
15
  icon: 'mdi:drone',
@@ -23,6 +24,7 @@ export const dronesCategory: DronesCategoryEntry = {
23
24
  droneMotorPropellerCalculator,
24
25
  fpvDroneSpeedCalculator,
25
26
  droneMissionBatteryReservePlanner,
27
+ fpvDroneLapTimer,
26
28
  ],
27
29
  i18n: {
28
30
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -18,6 +18,8 @@ export { fpvDroneSpeedCalculator } from './tool/fpv-drone-speed-calculator/entry
18
18
  export type { FpvDroneSpeedUI, FpvDroneSpeedLocaleContent } from './tool/fpv-drone-speed-calculator/entry';
19
19
  export { droneMissionBatteryReservePlanner } from './tool/drone-mission-battery-reserve-planner/entry';
20
20
  export type { DroneMissionBatteryReservePlannerUI, DroneMissionBatteryReservePlannerLocaleContent } from './tool/drone-mission-battery-reserve-planner/entry';
21
+ export { fpvDroneLapTimer } from './tool/fpv-drone-lap-timer/entry';
22
+ export type { FpvDroneLapTimerUI, FpvDroneLapTimerLocaleContent } from './tool/fpv-drone-lap-timer/entry';
21
23
  export { dronesCategory } from './category';
22
24
 
23
25
  import { antennaLengthCalculator } from './tool/antenna-length-calculator/entry';
@@ -30,6 +32,7 @@ import { fpvDroneThrustToWeightRatio } from './tool/fpv-drone-thrust-to-weight-r
30
32
  import { droneMotorPropellerCalculator } from './tool/drone-motor-propeller-calculator/entry';
31
33
  import { fpvDroneSpeedCalculator } from './tool/fpv-drone-speed-calculator/entry';
32
34
  import { droneMissionBatteryReservePlanner } from './tool/drone-mission-battery-reserve-planner/entry';
35
+ import { fpvDroneLapTimer } from './tool/fpv-drone-lap-timer/entry';
33
36
 
34
37
  export const ALL_ENTRIES = [
35
38
  antennaLengthCalculator,
@@ -42,4 +45,5 @@ export const ALL_ENTRIES = [
42
45
  droneMotorPropellerCalculator,
43
46
  fpvDroneSpeedCalculator,
44
47
  droneMissionBatteryReservePlanner,
48
+ fpvDroneLapTimer,
45
49
  ];
package/src/index.ts CHANGED
@@ -23,3 +23,4 @@ export { DRONE_POWER_ANALYZER_TOOL } from './tool/drone-power-analyzer/index';
23
23
  export { GSD_FLIGHT_PLANNER_TOOL } from './tool/gsd-flight-planner/index';
24
24
  export { DRONE_MOTOR_PROPELLER_CALCULATOR_TOOL } from './tool/drone-motor-propeller-calculator/index';
25
25
  export { FPV_DRONE_SPEED_CALCULATOR_TOOL } from './tool/fpv-drone-speed-calculator/index';
26
+ export { FPV_DRONE_LAP_TIMER_TOOL } from './tool/fpv-drone-lap-timer/index';
@@ -17,7 +17,7 @@ describe('Locale Completeness Validation', () => {
17
17
  });
18
18
  });
19
19
 
20
- it('all 10 tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(10);
20
+ it('all 11 tools registered', () => {
21
+ expect(ALL_TOOLS.length).toBe(11);
22
22
  });
23
23
  });
@@ -4,8 +4,8 @@ import { dronesCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 10 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(10);
7
+ it('should have 11 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(11);
9
9
  });
10
10
 
11
11
  it('dronesCategory should be defined', () => {
@@ -0,0 +1,160 @@
1
+ export class RaceAudioSynthesizer {
2
+ private audioCtx: AudioContext | null = null;
3
+ private enabled = true;
4
+ private activeTimeouts: number[] = [];
5
+
6
+ constructor(enabled = true) {
7
+ this.enabled = enabled;
8
+ }
9
+
10
+ public setEnabled(enabled: boolean): void {
11
+ this.enabled = enabled;
12
+ if (!enabled) {
13
+ this.cancelPendingTones();
14
+ }
15
+ }
16
+
17
+ public isEnabled(): boolean {
18
+ return this.enabled;
19
+ }
20
+
21
+ private initContext(): AudioContext | null {
22
+ if (typeof window === 'undefined') {
23
+ return null;
24
+ }
25
+ if (!this.audioCtx) {
26
+ const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
27
+ if (AudioContextClass) {
28
+ this.audioCtx = new AudioContextClass();
29
+ }
30
+ }
31
+ if (this.audioCtx && this.audioCtx.state === 'suspended') {
32
+ this.audioCtx.resume().catch(() => {});
33
+ }
34
+ return this.audioCtx;
35
+ }
36
+
37
+ public playTone(frequency: number, durationSeconds: number, type: OscillatorType = 'sine', peakGain = 0.25): void {
38
+ if (!this.enabled) {
39
+ return;
40
+ }
41
+ const ctx = this.initContext();
42
+ if (!ctx) {
43
+ return;
44
+ }
45
+
46
+ try {
47
+ const osc = ctx.createOscillator();
48
+ const gain = ctx.createGain();
49
+
50
+ osc.type = type;
51
+ osc.frequency.setValueAtTime(frequency, ctx.currentTime);
52
+
53
+ gain.gain.setValueAtTime(0.0001, ctx.currentTime);
54
+ gain.gain.exponentialRampToValueAtTime(peakGain, ctx.currentTime + 0.02);
55
+ gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + durationSeconds);
56
+
57
+ osc.connect(gain);
58
+ gain.connect(ctx.destination);
59
+
60
+ osc.start(ctx.currentTime);
61
+ osc.stop(ctx.currentTime + durationSeconds + 0.05);
62
+ } catch {}
63
+ }
64
+
65
+ public playFaiCountdown(onTick: (label: string) => void, onGo: () => void): void {
66
+ this.cancelPendingTones();
67
+ const steps = ['3', '2', '1'];
68
+
69
+ if (!this.enabled) {
70
+ steps.forEach((label, idx) => {
71
+ const tid = window.setTimeout(() => onTick(label), idx * 1000);
72
+ this.activeTimeouts.push(tid);
73
+ });
74
+ const goTid = window.setTimeout(() => {
75
+ onTick('Go');
76
+ onGo();
77
+ }, steps.length * 1000);
78
+ this.activeTimeouts.push(goTid);
79
+ return;
80
+ }
81
+
82
+ this.initContext();
83
+
84
+ steps.forEach((label, idx) => {
85
+ const tid = window.setTimeout(() => {
86
+ this.playTone(440, 0.18, 'sine', 0.3);
87
+ onTick(label);
88
+ }, idx * 1000);
89
+ this.activeTimeouts.push(tid);
90
+ });
91
+
92
+ const goTid = window.setTimeout(() => {
93
+ this.playTone(880, 0.45, 'sawtooth', 0.4);
94
+ onTick('Go');
95
+ onGo();
96
+ }, steps.length * 1000);
97
+ this.activeTimeouts.push(goTid);
98
+ }
99
+
100
+ public playCountdownSequence(onGo: () => void, onTick?: (label: string) => void): void {
101
+ this.playFaiCountdown(
102
+ (label) => {
103
+ onTick?.(label);
104
+ },
105
+ () => {
106
+ onGo();
107
+ }
108
+ );
109
+ }
110
+
111
+ public playLapSound(isFastest = false): void {
112
+ if (isFastest) {
113
+ this.playFastestLapChime();
114
+ } else {
115
+ this.playLapChirp();
116
+ }
117
+ }
118
+
119
+ public playLapChirp(): void {
120
+ if (!this.enabled) {
121
+ return;
122
+ }
123
+ this.playTone(660, 0.06, 'sine', 0.25);
124
+ const tid = window.setTimeout(() => {
125
+ this.playTone(880, 0.08, 'sine', 0.3);
126
+ }, 70);
127
+ this.activeTimeouts.push(tid);
128
+ }
129
+
130
+ public playFastestLapChime(): void {
131
+ if (!this.enabled) {
132
+ return;
133
+ }
134
+ const notes = [523.25, 659.25, 783.99, 1046.5];
135
+ notes.forEach((freq, idx) => {
136
+ const tid = window.setTimeout(() => {
137
+ this.playTone(freq, 0.12, 'triangle', 0.35);
138
+ }, idx * 75);
139
+ this.activeTimeouts.push(tid);
140
+ });
141
+ }
142
+
143
+ public playFinishFanfare(): void {
144
+ if (!this.enabled) {
145
+ return;
146
+ }
147
+ const notes = [440, 554.37, 659.25, 880];
148
+ notes.forEach((freq, idx) => {
149
+ const tid = window.setTimeout(() => {
150
+ this.playTone(freq, 0.22, 'triangle', 0.3);
151
+ }, idx * 110);
152
+ this.activeTimeouts.push(tid);
153
+ });
154
+ }
155
+
156
+ public cancelPendingTones(): void {
157
+ this.activeTimeouts.forEach((tid) => clearTimeout(tid));
158
+ this.activeTimeouts = [];
159
+ }
160
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as BibliographyComponent } from "@jjlmoya/utils-shared";
3
+ import { bibliography } from "./bibliography";
4
+ ---
5
+
6
+ <BibliographyComponent links={bibliography} />
@@ -0,0 +1,14 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Fédération Aéronautique Internationale (FAI) - CIAM Sporting Code Section 4: F9U Drone Racing World Championship Regulations',
6
+ url: 'https://www.fai.org/sites/default/files/2026-06/CIAM-F9-Drones-2026-06-01.pdf',
7
+ },
8
+ {
9
+ name: 'MultiGP Drone Racing Championship - Official Track Design and Lap Timing Specifications',
10
+ url: 'https://www.multigp.com/class-specifications/',
11
+ }
12
+ ];
13
+
14
+ export const BIBLIOGRAPHY_ITEMS = bibliography;
@@ -0,0 +1,20 @@
1
+ import type { FpvDroneLapTimerUI } from './ui';
2
+ import { LapTimerSession } from './controller';
3
+
4
+ export function initializeLapTimer(root: HTMLElement, ui: FpvDroneLapTimerUI): () => void {
5
+ const session = new LapTimerSession(root, ui);
6
+ const onKeyDown = (e: KeyboardEvent) => {
7
+ if (e.code === 'Space') {
8
+ const tag = document.activeElement?.tagName;
9
+ if (tag !== 'INPUT' && tag !== 'BUTTON') {
10
+ e.preventDefault();
11
+ session.recordLap();
12
+ }
13
+ }
14
+ };
15
+ window.addEventListener('keydown', onKeyDown);
16
+ return () => {
17
+ session.destroy();
18
+ window.removeEventListener('keydown', onKeyDown);
19
+ };
20
+ }
@@ -0,0 +1,136 @@
1
+ ---
2
+ import type { FpvDroneLapTimerUI } from './ui';
3
+ import { DEFAULT_CONFIG, calculateSessionMetrics, computeLapRecords } from './logic';
4
+ import { renderTelemetryScene, renderLapTable } from './dom-views';
5
+ import './fpv-drone-lap-timer.css';
6
+
7
+ interface Props {
8
+ ui: FpvDroneLapTimerUI;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+
13
+ const initialLaps = computeLapRecords([], DEFAULT_CONFIG.trackLengthM, DEFAULT_CONFIG.batteryCapacityMah);
14
+ const initialMetrics = calculateSessionMetrics([], DEFAULT_CONFIG.trackLengthM, DEFAULT_CONFIG.batteryCapacityMah);
15
+ const initialTelemetryHtml = renderTelemetryScene({
16
+ metrics: initialMetrics,
17
+ laps: initialLaps,
18
+ currentLapNumber: 1,
19
+ targetLaps: DEFAULT_CONFIG.targetLaps,
20
+ status: 'idle',
21
+ ui,
22
+ });
23
+ const initialTableHtml = renderLapTable(initialLaps, ui);
24
+ ---
25
+
26
+ <div class="fpv-lap-timer-card" data-fpv-lap-timer>
27
+ <script is:inline type="application/json" data-fpv-lap-timer-ui set:html={JSON.stringify(ui)}></script>
28
+
29
+ <aside class="fpv-lap-timer-sidebar" aria-labelledby="fpv-lap-timer-setup-title">
30
+ <div class="fpv-lap-timer-sidebar-header">
31
+ <span class="fpv-lap-timer-kicker">OSD CONFIG</span>
32
+ <h3 id="fpv-lap-timer-setup-title" class="fpv-lap-timer-sidebar-title">{ui.setupHeading}</h3>
33
+ </div>
34
+
35
+ <div class="fpv-lap-timer-presets" role="group" aria-label="Session Presets">
36
+ <button type="button" class="fpv-lap-timer-preset active" data-preset="multigp">{ui.presetMultiGpLabel}</button>
37
+ <button type="button" class="fpv-lap-timer-preset" data-preset="whoop">{ui.presetWhoopLabel}</button>
38
+ <button type="button" class="fpv-lap-timer-preset" data-preset="sprint">{ui.presetSprintLabel}</button>
39
+ </div>
40
+
41
+ <div class="fpv-lap-timer-fields">
42
+ <label class="fpv-lap-timer-field">
43
+ <span class="fpv-lap-timer-field-label">{ui.trackLengthLabel} <small>({ui.trackLengthUnit})</small></span>
44
+ <input id="fpv-lap-timer-track-length" type="number" min="10" max="2000" step="5" value={DEFAULT_CONFIG.trackLengthM} />
45
+ </label>
46
+
47
+ <label class="fpv-lap-timer-field">
48
+ <span class="fpv-lap-timer-field-label">{ui.targetLapsLabel} <small>({ui.targetLapsUnit})</small></span>
49
+ <input id="fpv-lap-timer-target-laps" type="number" min="0" max="50" step="1" value={DEFAULT_CONFIG.targetLaps} />
50
+ </label>
51
+
52
+ <label class="fpv-lap-timer-field">
53
+ <span class="fpv-lap-timer-field-label">{ui.batteryCapacityLabel} <small>({ui.batteryCapacityUnit})</small></span>
54
+ <input id="fpv-lap-timer-battery" type="number" min="100" max="10000" step="50" value={DEFAULT_CONFIG.batteryCapacityMah} />
55
+ </label>
56
+
57
+ <label class="fpv-lap-timer-field">
58
+ <span class="fpv-lap-timer-field-label">{ui.debounceThresholdLabel} <small>({ui.debounceThresholdUnit})</small></span>
59
+ <input id="fpv-lap-timer-debounce" type="number" min="1" max="10" step="0.5" value="3.0" />
60
+ </label>
61
+
62
+ <label class="fpv-lap-timer-checkbox">
63
+ <input id="fpv-lap-timer-sound" type="checkbox" checked />
64
+ <span>{ui.soundEnabledLabel}</span>
65
+ </label>
66
+ </div>
67
+
68
+ <div class="fpv-lap-timer-specs-card">
69
+ <div class="fpv-lap-timer-spec-row">
70
+ <span>{ui.completedLapsLabel}</span>
71
+ <strong id="fpv-lap-timer-stat-completed" class="font-mono">0</strong>
72
+ </div>
73
+ <div class="fpv-lap-timer-spec-row">
74
+ <span>{ui.averageLapHeading}</span>
75
+ <strong id="fpv-lap-timer-stat-avg" class="font-mono">--</strong>
76
+ </div>
77
+ <div class="fpv-lap-timer-spec-row">
78
+ <span>{ui.estimatedSpeedHeading}</span>
79
+ <strong id="fpv-lap-timer-stat-speed" class="font-mono">--</strong>
80
+ </div>
81
+ </div>
82
+ </aside>
83
+
84
+ <div class="fpv-lap-timer-stage">
85
+ <section class="fpv-lap-timer-hud" aria-label="Race Stopwatch Display">
86
+ <div class="fpv-lap-timer-hud-bar">
87
+ <div class="fpv-lap-timer-chip fpv-lap-timer-status-chip">
88
+ <span class="fpv-lap-timer-status-dot"></span>
89
+ <span id="fpv-lap-timer-status-text">{ui.statusIdle}</span>
90
+ </div>
91
+ <div class="fpv-lap-timer-chip fpv-lap-timer-lap-chip">
92
+ <span id="fpv-lap-timer-hud-lap">{ui.lapNumberPrefix} 1 / {DEFAULT_CONFIG.targetLaps}</span>
93
+ </div>
94
+ </div>
95
+
96
+ <div class="fpv-lap-timer-digits font-mono" aria-live="off">00:00.000</div>
97
+ <div class="fpv-lap-timer-hud-sublabel">{ui.currentLapHeading}</div>
98
+ </section>
99
+
100
+ <section class="fpv-lap-timer-trigger-box" aria-label="Timing Controls">
101
+ <button type="button" class="fpv-lap-timer-giant-btn" disabled aria-keyshortcuts="Space">
102
+ <span class="fpv-lap-timer-btn-caption">{ui.recordLapButton}</span>
103
+ </button>
104
+ <div class="fpv-lap-timer-hint">{ui.spacebarHint}</div>
105
+
106
+ <div class="fpv-lap-timer-controls">
107
+ <button type="button" class="fpv-lap-timer-btn fpv-lap-timer-btn-primary" id="fpv-lap-timer-btn-start">{ui.startCountdownButton}</button>
108
+ <button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-btn-pause" style="display: none;">{ui.pauseTimerButton}</button>
109
+ <button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-btn-reset">{ui.resetTimerButton}</button>
110
+ </div>
111
+ </section>
112
+
113
+ <section class="fpv-lap-timer-telemetry-mount" set:html={initialTelemetryHtml}></section>
114
+
115
+ <section class="fpv-lap-timer-table-mount" set:html={initialTableHtml}></section>
116
+
117
+ <footer class="fpv-lap-timer-footer">
118
+ <button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-export-csv">{ui.exportCsvButton}</button>
119
+ <button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-copy-summary">{ui.copySummaryButton}</button>
120
+ <span class="fpv-lap-timer-notice" id="fpv-lap-timer-copy-notice" style="display: none;">{ui.copiedNotice}</span>
121
+ </footer>
122
+ </div>
123
+ </div>
124
+
125
+ <script>
126
+ import { initializeLapTimer } from './controller';
127
+
128
+ const root = document.querySelector<HTMLElement>('[data-fpv-lap-timer]');
129
+ const scriptData = root?.querySelector<HTMLScriptElement>('[data-fpv-lap-timer-ui]');
130
+ if (root && scriptData) {
131
+ try {
132
+ const parsedUi = JSON.parse(scriptData.textContent || '{}');
133
+ initializeLapTimer(root, parsedUi);
134
+ } catch {}
135
+ }
136
+ </script>
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FPV_DRONE_LAP_TIMER_TOOL, fpvDroneLapTimer } from './index';
3
+
4
+ describe('fpv-drone-lap-timer contract suite', () => {
5
+ it('exposes a valid tool definition with lazy component loaders', () => {
6
+ expect(FPV_DRONE_LAP_TIMER_TOOL.entry).toBe(fpvDroneLapTimer);
7
+ expect(typeof FPV_DRONE_LAP_TIMER_TOOL.Component).toBe('function');
8
+ expect(typeof FPV_DRONE_LAP_TIMER_TOOL.SEOComponent).toBe('function');
9
+ expect(typeof FPV_DRONE_LAP_TIMER_TOOL.BibliographyComponent).toBe('function');
10
+ });
11
+
12
+ it('loads English locale content without errors', async () => {
13
+ expect(typeof fpvDroneLapTimer.i18n.en).toBe('function');
14
+ const content = await fpvDroneLapTimer.i18n.en!();
15
+ expect(content.slug).toBe('fpv-drone-lap-timer');
16
+ expect(content.title).toBeDefined();
17
+ expect(content.ui).toBeDefined();
18
+ expect(content.seo.length).toBeGreaterThanOrEqual(4);
19
+ expect(content.faq.length).toBeGreaterThanOrEqual(4);
20
+ expect(content.howTo.length).toBeGreaterThanOrEqual(3);
21
+ expect(content.bibliography.length).toBeGreaterThanOrEqual(2);
22
+ expect(content.schemas.length).toBeGreaterThanOrEqual(3);
23
+ });
24
+ });