Stricter decoding

This commit is contained in:
2026-03-15 20:21:26 +01:00
parent 4669783b67
commit e0d4844c5b
12 changed files with 1241 additions and 4188 deletions

3
.gitignore vendored
View File

@@ -103,6 +103,9 @@ web_modules/
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional npm package-lock.json
package-lock.json
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache

View File

@@ -11,16 +11,22 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint - repo: local
rev: v10.0.3 hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
files: "\\.(js|jsx|ts|tsx)$"
- repo: local
hooks: hooks:
- id: eslint - id: eslint
name: eslint
entry: npx eslint --fix
language: system
files: "\\.(js|jsx|ts|tsx)$" files: "\\.(js|jsx|ts|tsx)$"
exclude: node_modules/
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
# cannot parse modern Sass `@use` and module syntax. This invokes the
# project's installed `stylelint` via `npx` so the devDependency is used.
- repo: local - repo: local
hooks: hooks:
- id: stylelint - id: stylelint

3283
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.maze.io/ham/aprs.js" "url": "https://git.maze.io/ham/aprs.ts"
}, },
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
@@ -38,7 +38,6 @@
"lint": "eslint .", "lint": "eslint .",
"prepare": "npm run build" "prepare": "npm run build"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
@@ -48,5 +47,8 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
},
"dependencies": {
"@hamradio/packet": "^1.1.0"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -16,51 +16,52 @@ export interface IFrame {
// APRS Data Type Identifiers (first character of payload) // APRS Data Type Identifiers (first character of payload)
export const DataTypeIdentifier = { export const DataTypeIdentifier = {
// Position Reports // Position Reports
PositionNoTimestampNoMessaging: '!', PositionNoTimestampNoMessaging: "!",
PositionNoTimestampWithMessaging: '=', PositionNoTimestampWithMessaging: "=",
PositionWithTimestampNoMessaging: '/', PositionWithTimestampNoMessaging: "/",
PositionWithTimestampWithMessaging: '@', PositionWithTimestampWithMessaging: "@",
// Mic-E // Mic-E
MicECurrent: '`', MicECurrent: "`",
MicEOld: "'", MicEOld: "'",
// Messages and Bulletins // Messages and Bulletins
Message: ':', Message: ":",
// Objects and Items // Objects and Items
Object: ';', Object: ";",
Item: ')', Item: ")",
// Status // Status
Status: '>', Status: ">",
// Query // Query
Query: '?', Query: "?",
// Telemetry // Telemetry
TelemetryData: 'T', TelemetryData: "T",
// Weather // Weather
WeatherReportNoPosition: '_', WeatherReportNoPosition: "_",
// Raw GPS Data // Raw GPS Data
RawGPS: '$', RawGPS: "$",
// Station Capabilities // Station Capabilities
StationCapabilities: '<', StationCapabilities: "<",
// User-Defined // User-Defined
UserDefined: '{', UserDefined: "{",
// Third-Party Traffic // Third-Party Traffic
ThirdParty: '}', ThirdParty: "}",
// Invalid/Test Data // Invalid/Test Data
InvalidOrTest: ',', InvalidOrTest: ",",
} as const; } as const;
export type DataTypeIdentifier = typeof DataTypeIdentifier[keyof typeof DataTypeIdentifier]; export type DataTypeIdentifier =
(typeof DataTypeIdentifier)[keyof typeof DataTypeIdentifier];
export interface ISymbol { export interface ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
@@ -91,14 +92,14 @@ export interface ITimestamp {
hours: number; hours: number;
minutes: number; minutes: number;
seconds?: number; seconds?: number;
format: 'DHM' | 'HMS' | 'MDHM'; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute format: "DHM" | "HMS" | "MDHM"; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute
zulu?: boolean; // Is UTC/Zulu time zulu?: boolean; // Is UTC/Zulu time
toDate(): Date; // Convert to Date object respecting timezone toDate(): Date; // Convert to Date object respecting timezone
} }
// Position Report Payload // Position Report Payload
export interface PositionPayload { export interface PositionPayload {
type: 'position'; type: "position";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled messaging: boolean; // Whether APRS messaging is enabled
@@ -122,12 +123,12 @@ export interface CompressedPosition {
range?: number; // Miles range?: number; // Miles
altitude?: number; // Feet altitude?: number; // Feet
radioRange?: number; // Miles radioRange?: number; // Miles
compression: 'old' | 'current'; compression: "old" | "current";
} }
// Mic-E Payload (compressed in destination address) // Mic-E Payload (compressed in destination address)
export interface MicEPayload { export interface MicEPayload {
type: 'mic-e'; type: "mic-e";
position: IPosition; position: IPosition;
course?: number; course?: number;
speed?: number; speed?: number;
@@ -139,7 +140,7 @@ export interface MicEPayload {
// Message Payload // Message Payload
export interface MessagePayload { export interface MessagePayload {
type: 'message'; type: "message";
addressee: string; // 9 character padded callsign addressee: string; // 9 character padded callsign
text: string; // Message text text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment messageNumber?: string; // Message ID for acknowledgment
@@ -149,7 +150,7 @@ export interface MessagePayload {
// Bulletin/Announcement (variant of message) // Bulletin/Announcement (variant of message)
export interface BulletinPayload { export interface BulletinPayload {
type: 'bulletin'; type: "bulletin";
bulletinId: string; // Bulletin identifier (BLN#) bulletinId: string; // Bulletin identifier (BLN#)
text: string; text: string;
group?: string; // Optional group bulletin group?: string; // Optional group bulletin
@@ -157,7 +158,7 @@ export interface BulletinPayload {
// Object Payload // Object Payload
export interface ObjectPayload { export interface ObjectPayload {
type: 'object'; type: "object";
name: string; // 9 character object name name: string; // 9 character object name
timestamp: ITimestamp; timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed alive: boolean; // True if object is active, false if killed
@@ -168,7 +169,7 @@ export interface ObjectPayload {
// Item Payload // Item Payload
export interface ItemPayload { export interface ItemPayload {
type: 'item'; type: "item";
name: string; // 3-9 character item name name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed alive: boolean; // True if item is active, false if killed
position: IPosition; position: IPosition;
@@ -176,7 +177,7 @@ export interface ItemPayload {
// Status Payload // Status Payload
export interface StatusPayload { export interface StatusPayload {
type: 'status'; type: "status";
timestamp?: ITimestamp; timestamp?: ITimestamp;
text: string; text: string;
maidenhead?: string; // Optional Maidenhead grid locator maidenhead?: string; // Optional Maidenhead grid locator
@@ -188,14 +189,14 @@ export interface StatusPayload {
// Query Payload // Query Payload
export interface QueryPayload { export interface QueryPayload {
type: 'query'; type: "query";
queryType: string; // e.g., 'APRSD', 'APRST', 'PING' queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
target?: string; // Target callsign or area target?: string; // Target callsign or area
} }
// Telemetry Data Payload // Telemetry Data Payload
export interface TelemetryDataPayload { export interface TelemetryDataPayload {
type: 'telemetry-data'; type: "telemetry-data";
sequence: number; sequence: number;
analog: number[]; // Up to 5 analog channels analog: number[]; // Up to 5 analog channels
digital: number; // 8-bit digital value digital: number; // 8-bit digital value
@@ -203,19 +204,19 @@ export interface TelemetryDataPayload {
// Telemetry Parameter Names // Telemetry Parameter Names
export interface TelemetryParameterPayload { export interface TelemetryParameterPayload {
type: 'telemetry-parameters'; type: "telemetry-parameters";
names: string[]; // Parameter names names: string[]; // Parameter names
} }
// Telemetry Unit/Label // Telemetry Unit/Label
export interface TelemetryUnitPayload { export interface TelemetryUnitPayload {
type: 'telemetry-units'; type: "telemetry-units";
units: string[]; // Units for each parameter units: string[]; // Units for each parameter
} }
// Telemetry Coefficients // Telemetry Coefficients
export interface TelemetryCoefficientsPayload { export interface TelemetryCoefficientsPayload {
type: 'telemetry-coefficients'; type: "telemetry-coefficients";
coefficients: { coefficients: {
a: number[]; // a coefficients a: number[]; // a coefficients
b: number[]; // b coefficients b: number[]; // b coefficients
@@ -225,14 +226,14 @@ export interface TelemetryCoefficientsPayload {
// Telemetry Bit Sense/Project Name // Telemetry Bit Sense/Project Name
export interface TelemetryBitSensePayload { export interface TelemetryBitSensePayload {
type: 'telemetry-bitsense'; type: "telemetry-bitsense";
sense: number; // 8-bit sense value sense: number; // 8-bit sense value
projectName?: string; projectName?: string;
} }
// Weather Report Payload // Weather Report Payload
export interface WeatherPayload { export interface WeatherPayload {
type: 'weather'; type: "weather";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position?: IPosition; position?: IPosition;
windDirection?: number; // Degrees windDirection?: number; // Degrees
@@ -253,33 +254,33 @@ export interface WeatherPayload {
// Raw GPS Payload (NMEA sentences) // Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload { export interface RawGPSPayload {
type: 'raw-gps'; type: "raw-gps";
sentence: string; // Raw NMEA sentence sentence: string; // Raw NMEA sentence
} }
// Station Capabilities Payload // Station Capabilities Payload
export interface StationCapabilitiesPayload { export interface StationCapabilitiesPayload {
type: 'capabilities'; type: "capabilities";
capabilities: string[]; capabilities: string[];
} }
// User-Defined Payload // User-Defined Payload
export interface UserDefinedPayload { export interface UserDefinedPayload {
type: 'user-defined'; type: "user-defined";
userPacketType: string; userPacketType: string;
data: string; data: string;
} }
// Third-Party Traffic Payload // Third-Party Traffic Payload
export interface ThirdPartyPayload { export interface ThirdPartyPayload {
type: 'third-party'; type: "third-party";
header: string; // Source path of third-party packet header: string; // Source path of third-party packet
payload: string; // Nested APRS packet payload: string; // Nested APRS packet
} }
// DF Report Payload // DF Report Payload
export interface DFReportPayload { export interface DFReportPayload {
type: 'df-report'; type: "df-report";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
course?: number; course?: number;
@@ -296,7 +297,8 @@ export interface BasePayload {
} }
// Union type for all decoded payload types // Union type for all decoded payload types
export type Payload = BasePayload & ( export type Payload = BasePayload &
(
| PositionPayload | PositionPayload
| MicEPayload | MicEPayload
| MessagePayload | MessagePayload
@@ -316,7 +318,7 @@ export type Payload = BasePayload & (
| UserDefinedPayload | UserDefinedPayload
| ThirdPartyPayload | ThirdPartyPayload
| DFReportPayload | DFReportPayload
); );
// Extended Frame with decoded payload // Extended Frame with decoded payload
export interface DecodedFrame extends IFrame { export interface DecodedFrame extends IFrame {

View File

@@ -1,14 +1,6 @@
export { export { Frame, Address, Timestamp } from "./frame";
Frame,
Address,
Timestamp,
} from "./frame";
export { export { type IAddress, type IFrame, DataTypeIdentifier } from "./frame.types";
type IAddress,
type IFrame,
DataTypeIdentifier,
} from "./frame.types";
export { export {
type ISymbol, type ISymbol,
@@ -48,10 +40,3 @@ export {
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius, fahrenheitToCelsius,
} from "./parser"; } from "./parser";
export {
type PacketStructure,
type PacketSegment,
type PacketField,
type PacketFieldBit,
FieldType,
} from "./parser.types";

View File

@@ -15,14 +15,16 @@ export const base91ToNumber = (str: string): number => {
const digit = charCode - 33; // Base91 uses chars 33-123 (! to {) const digit = charCode - 33; // Base91 uses chars 33-123 (! to {)
if (digit < 0 || digit >= base) { if (digit < 0 || digit >= base) {
throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`); throw new Error(
`Invalid Base91 character: '${str[i]}' (code ${charCode})`,
);
} }
value = value * base + digit; value = value * base + digit;
} }
return value; return value;
} };
/* Conversions from Freedom Units to whatever the rest of the world uses and understands. */ /* Conversions from Freedom Units to whatever the rest of the world uses and understands. */
@@ -38,7 +40,7 @@ const FAHRENHEIT_TO_CELSIUS_OFFSET = 32;
*/ */
export const knotsToKmh = (knots: number): number => { export const knotsToKmh = (knots: number): number => {
return knots * KNOTS_TO_KMH; return knots * KNOTS_TO_KMH;
} };
/** /**
* Convert speed from kilometers per hour to knots. * Convert speed from kilometers per hour to knots.
@@ -48,7 +50,7 @@ export const knotsToKmh = (knots: number): number => {
*/ */
export const kmhToKnots = (kmh: number): number => { export const kmhToKnots = (kmh: number): number => {
return kmh / KNOTS_TO_KMH; return kmh / KNOTS_TO_KMH;
} };
/** /**
* Convert altitude from feet to meters. * Convert altitude from feet to meters.
@@ -58,7 +60,7 @@ export const kmhToKnots = (kmh: number): number => {
*/ */
export const feetToMeters = (feet: number): number => { export const feetToMeters = (feet: number): number => {
return feet * FEET_TO_METERS; return feet * FEET_TO_METERS;
} };
/** /**
* Convert altitude from meters to feet. * Convert altitude from meters to feet.
@@ -68,7 +70,7 @@ export const feetToMeters = (feet: number): number => {
*/ */
export const metersToFeet = (meters: number): number => { export const metersToFeet = (meters: number): number => {
return meters / FEET_TO_METERS; return meters / FEET_TO_METERS;
} };
/** /**
* Convert temperature from Celsius to Fahrenheit. * Convert temperature from Celsius to Fahrenheit.
@@ -77,8 +79,8 @@ export const metersToFeet = (meters: number): number => {
* @returns equivalent temperature in Fahrenheit * @returns equivalent temperature in Fahrenheit
*/ */
export const celsiusToFahrenheit = (celsius: number): number => { export const celsiusToFahrenheit = (celsius: number): number => {
return (celsius * 9/5) + FAHRENHEIT_TO_CELSIUS_OFFSET; return (celsius * 9) / 5 + FAHRENHEIT_TO_CELSIUS_OFFSET;
} };
/** /**
* Convert temperature from Fahrenheit to Celsius. * Convert temperature from Fahrenheit to Celsius.
@@ -87,5 +89,5 @@ export const celsiusToFahrenheit = (celsius: number): number => {
* @returns equivalent temperature in Celsius * @returns equivalent temperature in Celsius
*/ */
export const fahrenheitToCelsius = (fahrenheit: number): number => { export const fahrenheitToCelsius = (fahrenheit: number): number => {
return (fahrenheit - FAHRENHEIT_TO_CELSIUS_OFFSET) * 5/9; return ((fahrenheit - FAHRENHEIT_TO_CELSIUS_OFFSET) * 5) / 9;
} };

View File

@@ -1,37 +0,0 @@
export enum FieldType {
BITS = 0,
UINT8 = 1,
UINT16_LE = 2,
UINT16_BE = 3,
UINT32_LE = 4,
UINT32_BE = 5,
BYTES = 6, // 8-bits per value
WORDS = 7, // 16-bits per value
DWORDS = 8, // 32-bits per value
QWORDS = 9, // 64-bits per value
STRING = 10,
C_STRING = 11, // Null-terminated string
CHAR = 12, // Single ASCII character
}
// Interface for the parsed packet segments, used for debugging and testing.
export type PacketStructure = PacketSegment[];
export interface PacketSegment {
name: string;
data: Uint8Array;
fields: PacketField[];
}
export interface PacketField {
type: FieldType;
size: number; // Size in bytes
name?: string;
bits?: PacketFieldBit[]; // Only for bit fields in FieldType.BITS
value?: any; // Optional decoded value
}
export interface PacketFieldBit {
name: string;
size: number; // Size in bits
}

View File

@@ -10,7 +10,9 @@ export class Symbol implements ISymbol {
this.code = table[1]; this.code = table[1];
this.table = table[0]; this.table = table[0];
} else { } else {
throw new Error(`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`); throw new Error(
`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`,
);
} }
} else { } else {
this.table = table; this.table = table;
@@ -40,7 +42,7 @@ export class Position implements IPosition {
this.altitude = data.altitude; this.altitude = data.altitude;
this.speed = data.speed; this.speed = data.speed;
this.course = data.course; this.course = data.course;
if (typeof data.symbol === 'string') { if (typeof data.symbol === "string") {
this.symbol = new Symbol(data.symbol); this.symbol = new Symbol(data.symbol);
} else if (data.symbol) { } else if (data.symbol) {
this.symbol = new Symbol(data.symbol.table, data.symbol.code); this.symbol = new Symbol(data.symbol.table, data.symbol.code);
@@ -51,21 +53,21 @@ export class Position implements IPosition {
public toString(): string { public toString(): string {
const latStr = this.latitude.toFixed(5); const latStr = this.latitude.toFixed(5);
const lonStr = this.longitude.toFixed(5); const lonStr = this.longitude.toFixed(5);
const altStr = this.altitude !== undefined ? `,${this.altitude}m` : ''; const altStr = this.altitude !== undefined ? `,${this.altitude}m` : "";
return `${latStr},${lonStr}${altStr}`; return `${latStr},${lonStr}${altStr}`;
} }
public distanceTo(other: IPosition): number { public distanceTo(other: IPosition): number {
const R = 6371e3; // Earth radius in meters const R = 6371e3; // Earth radius in meters
const lat1 = this.latitude * Math.PI / 180; const lat1 = (this.latitude * Math.PI) / 180;
const lat2 = other.latitude * Math.PI / 180; const lat2 = (other.latitude * Math.PI) / 180;
const dLat = (other.latitude - this.latitude) * Math.PI / 180; const dLat = ((other.latitude - this.latitude) * Math.PI) / 180;
const dLon = (other.longitude - this.longitude) * Math.PI / 180; const dLon = ((other.longitude - this.longitude) * Math.PI) / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + const a =
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.sin(dLon/2) * Math.sin(dLon/2); Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters return R * c; // Distance in meters
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { import {
base91ToNumber, base91ToNumber,
knotsToKmh, knotsToKmh,
@@ -7,79 +7,79 @@ import {
metersToFeet, metersToFeet,
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius, fahrenheitToCelsius,
} from '../src/parser'; } from "../src/parser";
describe('parser utilities', () => { describe("parser utilities", () => {
describe('base91ToNumber', () => { describe("base91ToNumber", () => {
it('decodes all-! to 0', () => { it("decodes all-! to 0", () => {
expect(base91ToNumber('!!!!')).toBe(0); expect(base91ToNumber("!!!!")).toBe(0);
}); });
it('decodes single character correctly', () => { it("decodes single character correctly", () => {
// 'A' === 65, digit = 65 - 33 = 32 // 'A' === 65, digit = 65 - 33 = 32
expect(base91ToNumber('A')).toBe(32); expect(base91ToNumber("A")).toBe(32);
}); });
it('should decode multiple Base91 characters', () => { it("should decode multiple Base91 characters", () => {
// "!!" = 0 * 91 + 0 = 0 // "!!" = 0 * 91 + 0 = 0
expect(base91ToNumber('!!')).toBe(0); expect(base91ToNumber("!!")).toBe(0);
// "!#" = 0 * 91 + 2 = 2 // "!#" = 0 * 91 + 2 = 2
expect(base91ToNumber('!#')).toBe(2); expect(base91ToNumber("!#")).toBe(2);
// "#!" = 2 * 91 + 0 = 182 // "#!" = 2 * 91 + 0 = 182
expect(base91ToNumber('#!')).toBe(182); expect(base91ToNumber("#!")).toBe(182);
// "##" = 2 * 91 + 2 = 184 // "##" = 2 * 91 + 2 = 184
expect(base91ToNumber('##')).toBe(184); expect(base91ToNumber("##")).toBe(184);
}); });
it('should decode 4-character Base91 strings (used in APRS)', () => { it("should decode 4-character Base91 strings (used in APRS)", () => {
// Test with printable ASCII Base91 characters (33-123) // Test with printable ASCII Base91 characters (33-123)
const testValue = base91ToNumber('!#%\''); const testValue = base91ToNumber("!#%'");
expect(testValue).toBeGreaterThan(0); expect(testValue).toBeGreaterThan(0);
expect(testValue).toBeLessThan(91 * 91 * 91 * 91); expect(testValue).toBeLessThan(91 * 91 * 91 * 91);
}); });
it('should decode maximum valid Base91 value', () => { it("should decode maximum valid Base91 value", () => {
// Maximum is '{' (ASCII 123, digit 90) repeated // Maximum is '{' (ASCII 123, digit 90) repeated
const maxValue = base91ToNumber('{{{{'); const maxValue = base91ToNumber("{{{{");
const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90; const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90;
expect(maxValue).toBe(expected); expect(maxValue).toBe(expected);
}); });
it('should handle APRS compressed position example', () => { it("should handle APRS compressed position example", () => {
// Using actual characters from APRS test vector // Using actual characters from APRS test vector
const latStr = '/:*E'; const latStr = "/:*E";
const lonStr = 'qZ=O'; const lonStr = "qZ=O";
const latValue = base91ToNumber(latStr); const latValue = base91ToNumber(latStr);
const lonValue = base91ToNumber(lonStr); const lonValue = base91ToNumber(lonStr);
// Just verify they decode without error and produce valid numbers // Just verify they decode without error and produce valid numbers
expect(typeof latValue).toBe('number'); expect(typeof latValue).toBe("number");
expect(typeof lonValue).toBe('number'); expect(typeof lonValue).toBe("number");
expect(latValue).toBeGreaterThanOrEqual(0); expect(latValue).toBeGreaterThanOrEqual(0);
expect(lonValue).toBeGreaterThanOrEqual(0); expect(lonValue).toBeGreaterThanOrEqual(0);
}); });
it('throws on invalid character', () => { it("throws on invalid character", () => {
expect(() => base91ToNumber(' ')).toThrow(); // space (code 32) is invalid expect(() => base91ToNumber(" ")).toThrow(); // space (code 32) is invalid
}); });
}); });
describe('unit conversions', () => { describe("unit conversions", () => {
it('converts knots <-> km/h', () => { it("converts knots <-> km/h", () => {
expect(knotsToKmh(10)).toBeCloseTo(18.52, 5); expect(knotsToKmh(10)).toBeCloseTo(18.52, 5);
expect(kmhToKnots(18.52)).toBeCloseTo(10, 3); expect(kmhToKnots(18.52)).toBeCloseTo(10, 3);
}); });
it('converts feet <-> meters', () => { it("converts feet <-> meters", () => {
expect(feetToMeters(10)).toBeCloseTo(3.048, 6); expect(feetToMeters(10)).toBeCloseTo(3.048, 6);
expect(metersToFeet(3.048)).toBeCloseTo(10, 6); expect(metersToFeet(3.048)).toBeCloseTo(10, 6);
}); });
it('converts celsius <-> fahrenheit', () => { it("converts celsius <-> fahrenheit", () => {
expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6); expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6);
expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6); expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6);
expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6); expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6);