From d919c72ceb71f9fdb98fe7a0f92f040484522a6a Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:20:03 +0000 Subject: [PATCH 01/19] docs: add Heights API feature specification - Define user stories (Heights P1, Flexible Tides P2, Generic Callback P1) - Add functional requirements FR-001 to FR-010 - Add acceptance scenarios and edge cases - Include requirements quality checklist --- .../checklists/requirements.md | 34 +++++++ specs/001-support-heights-api/spec.md | 93 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 specs/001-support-heights-api/checklists/requirements.md create mode 100644 specs/001-support-heights-api/spec.md diff --git a/specs/001-support-heights-api/checklists/requirements.md b/specs/001-support-heights-api/checklists/requirements.md new file mode 100644 index 0000000..634e9d9 --- /dev/null +++ b/specs/001-support-heights-api/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Support Heights API + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-09 +**Feature**: [Link to spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec is ready for Plan/Clarify. diff --git a/specs/001-support-heights-api/spec.md b/specs/001-support-heights-api/spec.md new file mode 100644 index 0000000..7a3c02a --- /dev/null +++ b/specs/001-support-heights-api/spec.md @@ -0,0 +1,93 @@ +# Feature Specification: Support Heights API + +**Feature Branch**: `001-support-heights-api` +**Created**: 2026-01-09 +**Status**: Draft +**Input**: User description: "based on the supported API calls documented in the readme create a specification to extend the library to support the Hights API request" + +## User Scenarios & Testing + +### User Story 1 - Retrieve Tide Heights (Priority: P1) + +As a developer using the library, I want to fetch predicted tide heights for a specific location and date range so that I can use this data to display tide curves/charts in my application. + +**Why this priority**: "Heights" is a fundamental dataset provided by the API that enables detailed visualization, which is currently missing from the library. + +**Independent Test**: +A developer can write a script or test case that invokes the new "Heights" method with valid credentials and receives a collection of height objects with timestamps and values. + +**Acceptance Scenarios**: + +1. **Given** the library is initialized with a valid API key, **When** requesting tide heights for a valid location and time range, **Then** the result contains a list of tide height records. +2. **Given** an invalid API key, **When** requesting tide heights, **Then** an error indicating authentication failure is returned. +3. **Given** a location with no available data, **When** requesting tide heights, **Then** the library returns an empty result or appropriate specific error, not a generic crash. + +--- + +### User Story 2 - Retrieve Tides with Flexible Data Types (Priority: P2) + +As a developer, I want to fetch tide data by specifying which data types to include (e.g., heights, extremes) in a single API call so that I can reduce network overhead and get exactly the data I need. + +**Why this priority**: The WorldTides API supports stacking multiple data types (e.g., `?heights&extremes`). Enabling this in the library provides efficiency and future extensibility (stations, datums, etc.). + +**Independent Test**: +A developer can invoke `getTides` with a list of data types and receive a response containing the requested data. + +**Acceptance Scenarios**: + +1. **Given** the library is initialized with a valid API key, **When** requesting `getTides([HEIGHTS, EXTREMES])`, **Then** the result contains both heights and extremes data. +2. **Given** one data type is unavailable for a location, **When** requesting multiple data types, **Then** the available data is returned and the unavailable part is empty or null. +3. **Given** `getTides([HEIGHTS])` is called, **When** the request completes, **Then** only heights data is populated in the result. + +--- + +### User Story 3 - Generic Callback Interface (Priority: P1) + +As a library maintainer, I want the callback interface to be generic (`TidesCallback`) so that it can be reused for different response types without code duplication. + +**Why this priority**: Enables clean architecture and avoids proliferating type-specific callback interfaces. + +**Independent Test**: +Unit tests verify that `TidesCallback`, `TidesCallback`, and `TidesCallback` all compile and function correctly. + +**Acceptance Scenarios**: + +1. **Given** a generic callback, **When** used with Heights request, **Then** it correctly receives `TideHeights`. +2. **Given** a generic callback, **When** used with Extremes request, **Then** it correctly receives `TideExtremes`. +3. **Given** a generic callback, **When** used with `getTides` request, **Then** it correctly receives `Tides`. + +### Edge Cases + +- **Invalid Parameters**: Requesting negative duration or invalid date formats. +- **Network Issues**: Connection timeout or loss during request. +- **API Changes**: Unexpected response format from the server. +- **Partial Responses (Combined)**: API returns one data type but not the other. + +## Requirements + +### Functional Requirements + +- **FR-001**: The library MUST provide a method to request "Heights" data from the remote API. +- **FR-002**: The request method MUST accept parameters for Location (Latitude, Longitude), Start Date, and Duration (Days). +- **FR-003**: The library MUST parse the API response into a strongly-typed data structure representing Tide Heights (Time and Height). +- **FR-004**: The library MUST provide error handling mechanisms to report failures (Network, Auth, Validation) to the caller. +- **FR-005**: The feature MUST be fully interoperable with both Kotlin and Java applications. +- **FR-006**: The usage pattern (method signature, callback style) MUST remain consistent with existing library features (e.g. `getTideExtremes`). +- **FR-007**: The library MUST refactor `TidesCallback` to be generic (`TidesCallback`) for type-safe result handling. +- **FR-008**: The library MUST support stacked API requests via a flexible `getTides` method that accepts a list of data types. +- **FR-009**: The library MUST provide a `Tides` data model that can hold any combination of requested data (Heights, Extremes, and future types like Stations, Datums). +- **FR-010**: The library MUST provide a `TideDataType` enum to specify which data types to request. + +### Key Entities + +- **TideHeights/TideHeight**: Data structure representing the height of the tide at a specific point in time. +- **Tides**: Data structure containing optional Heights, Extremes, and future data type lists. +- **TideDataType**: Enum representing the types of tide data that can be requested (HEIGHTS, EXTREMES, future: STATIONS, DATUMS). + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Developers can successfully retrieve and parse tide heights for a standard 7-day request. +- **SC-002**: The API surface for "Heights" matches the conventions of "Extremes" (consistency). +- **SC-003**: Test coverage extends to both Java and Kotlin consumers for this feature. From d5609de8bcd44282de283d1c6b66fbf2d34b8675 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:20:20 +0000 Subject: [PATCH 02/19] docs: add implementation plan and research for Heights API - Document API endpoint decisions (v2?heights) - Define package structure (Repository/Gateway at root) - Plan for getTideHeights and getTides methods - Note breaking change for TidesCallback refactoring --- specs/001-support-heights-api/plan.md | 82 +++++++++++++++++++++++ specs/001-support-heights-api/research.md | 53 +++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 specs/001-support-heights-api/plan.md create mode 100644 specs/001-support-heights-api/research.md diff --git a/specs/001-support-heights-api/plan.md b/specs/001-support-heights-api/plan.md new file mode 100644 index 0000000..426ff20 --- /dev/null +++ b/specs/001-support-heights-api/plan.md @@ -0,0 +1,82 @@ +# Implementation Plan: Support Heights API + +**Branch**: `001-support-heights-api` | **Date**: 2026-01-09 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/001-support-heights-api/spec.md` + +## Summary + +Implement `getTideHeights` and `getTides` in the WorldTides library to enable fetching tide height predictions and flexible combined tide data from the WorldTides v2 API. This involves: +1. Refactoring `TidesCallback` to be generic (`TidesCallback`). +2. Creating new data models for Heights (`TideHeights`, `Height`) and flexible Tides (`Tides`, `TideDataType`). +3. Extending the existing Retrofit/Repository architecture with new endpoints. + +> [!IMPORTANT] +> **Breaking Change**: Refactoring `TidesCallback` to a generic interface is a breaking change for existing consumers using the Java-style callback. + +## Technical Context + +**Language/Version**: Kotlin 1.9+ (Java 11+ compatible) +**Primary Dependencies**: Retrofit 2, Moshi (existing in project) +**Storage**: N/A (Stateless library) +**Testing**: JUnit 4/5, MockK (assumed based on standard Android/Kotlin libs) +**Target Platform**: Android / JVM +**Project Type**: Library +**Performance Goals**: Standard network latency; efficient parsing of potential large lists. +**Constraints**: Must maintain Java interoperability and strict API fidelity. +**Scale/Scope**: 2 new API methods (`getTideHeights`, `getTides`), ~6-8 new classes, 1 refactored interface, 1 enum. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Kotlin-First, Java-Compatible**: Plan includes both Suspend (internal/future) or Callback (current pattern) interfaces. (Actually, current pattern assumes Callback for both Java/Kotlin via `Result`). +- [x] **Strict API Fidelity**: Naming will mirror API (`Heights`, `dt`, `height`). +- [x] **Type Safety**: Using `Result` and strong types. +- [x] **Architecture Standards**: Using Retrofit/Moshi, separating Repository/Service. +- [x] **QA & Testing**: Plan includes unit testing. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-support-heights-api/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output +``` + +### Source Code + +```text +worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/ +├── WorldTides.kt # Main entry point (Modify: add getTideHeights, getTides) +├── WorldTidesRepository.kt # (MOVE from extremes + Modify: add heights, tides methods) +├── WorldTidesGateway.kt # (MOVE from extremes + Modify: add heights, tides endpoints) +├── TidesCallback.kt # (REFACTOR: Make generic TidesCallback) +├── models/ # [NEW] Shared models +│ ├── Tides.kt # [NEW] Flexible result container +│ ├── TidesResponse.kt # [NEW] API response DTO +│ └── TideDataType.kt # [NEW] Enum for data types +├── extremes/ # Existing feature models +│ ├── data/ +│ │ └── ... # Existing DTOs +│ └── models/ +│ └── ... # Existing models (TideExtremes, Extreme) +├── heights/ # [NEW] +│ ├── data/ +│ │ ├── TideHeightsResponse.kt # [NEW] +│ │ └── HeightResponse.kt # [NEW] +│ └── models/ +│ ├── TideHeights.kt # [NEW] +│ └── Height.kt # [NEW] +``` + +**Structure Decision**: Repository (`WorldTidesRepository`) and Gateway (`WorldTidesGateway`) moved to package root as shared infrastructure. Feature-specific models remain in their respective packages (`extremes/`, `heights/`). Shared models (`Tides`, `TideDataType`) in `models/` package. + +## Complexity Tracking + +N/A - Standard implementation. diff --git a/specs/001-support-heights-api/research.md b/specs/001-support-heights-api/research.md new file mode 100644 index 0000000..81079f2 --- /dev/null +++ b/specs/001-support-heights-api/research.md @@ -0,0 +1,53 @@ +# Research: Support Heights API + +**Status**: Complete +**Date**: 2026-01-09 + +## Decisions + +### 1. API Endpoint +- **Decision**: Use `v2` endpoint with `heights` query parameter. +- **Rationale**: README confirms `Heights` is a `v2` API request. Existing `WorldTidesGateway` uses `@GET("v2?extremes")`. +- **Implementation**: `@GET("v2?heights")` in `WorldTidesGateway`. + +### 2. Data Models +- **Decision**: Create `TideHeights` (wrapper) and `Height` (entity) models, mirroring `TideExtremes` and `Extreme`. +- **Rationale**: Consistency with existing codebase patterns. +- **Structure**: + - `TideHeightsResponse` (Status, Error, HeightsList) + - `HeightResponse` (dt, date, height) - Note: `type` field from `Extreme` is likely not present or not relevant for raw heights, unless it denotes prediction vs observation. We will assume prediction (default) for now. + +### 3. Package Structure +- **Decision**: Move `WorldTidesRepository` and `WorldTidesGateway` to package root. Create new `heights` and `models` package hierarchies. +- **Rationale**: User request. Repository and Gateway are shared infrastructure, not specific to `extremes`. +- **Implementation**: + - `com.oleksandrkruk.worldtides.WorldTidesRepository` (moved) + - `com.oleksandrkruk.worldtides.WorldTidesGateway` (moved) + - `com.oleksandrkruk.worldtides.heights.models.*` + - `com.oleksandrkruk.worldtides.heights.data.*` + - `com.oleksandrkruk.worldtides.models.Tides` + - `com.oleksandrkruk.worldtides.models.TideDataType` + +### 4. Flexible getTides Endpoint +- **Decision**: Support stacked API requests via dynamic query parameters based on `TideDataType` list. +- **Rationale**: User request. The WorldTides API allows stacking multiple data types (e.g., `?heights&extremes`). +- **Implementation**: `getTides(dataTypes: List)` dynamically builds the endpoint query. + +### 5. Generic TidesCallback +- **Decision**: Refactor `TidesCallback` to be generic (`TidesCallback`). +- **Rationale**: User request to avoid proliferating type-specific callback interfaces. +- **Breaking Change**: Yes. Existing consumers using `TidesCallback` will need to update their code. + +## Alternatives Considered + +### Co-location in 'extremes' package +- **Idea**: Reuse existing `extremes` package. +- **Rejected**: User explicitly requested separation into `heights` package and shared `models` package. + +### Suspend Functions +- **Idea**: Use Kotlin Coroutines (`suspend`) for the new method. +- **Rejected**: Constitution requires Java interoperability. Existing pattern uses `Callback`. We will stick to `Callback` (wrapped in `(Result) -> Unit`) to match `getTideExtremes`. + +### Type-Specific Callbacks (e.g., TideHeightsCallback) +- **Idea**: Create a separate callback interface for each data type. +- **Rejected**: User explicitly requested a generic `TidesCallback` to reduce code duplication. From 38071adcf37908f4dc1c7743f3ca13cc9b54899b Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:20:31 +0000 Subject: [PATCH 03/19] docs: add data model and API contracts for Heights API - Define TideHeights, Height, Tides, TideDataType entities - Add TidesResponse and HeightResponse DTOs - Define generic TidesCallback interface - Add WorldTides.getTideHeights and getTides method signatures --- .../contracts/TidesCallback.kt | 12 +++ .../contracts/WorldTides.kt | 90 +++++++++++++++++ specs/001-support-heights-api/data-model.md | 98 +++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 specs/001-support-heights-api/contracts/TidesCallback.kt create mode 100644 specs/001-support-heights-api/contracts/WorldTides.kt create mode 100644 specs/001-support-heights-api/data-model.md diff --git a/specs/001-support-heights-api/contracts/TidesCallback.kt b/specs/001-support-heights-api/contracts/TidesCallback.kt new file mode 100644 index 0000000..d0a2885 --- /dev/null +++ b/specs/001-support-heights-api/contracts/TidesCallback.kt @@ -0,0 +1,12 @@ +package com.oleksandrkruk.worldtides + +/** + * Generic callback interface for WorldTides API responses. + * Supports TideExtremes, TideHeights, TideCombined, and future types. + * + * @param T The result type. + */ +interface TidesCallback { + fun result(data: T) + fun error(error: Error) +} diff --git a/specs/001-support-heights-api/contracts/WorldTides.kt b/specs/001-support-heights-api/contracts/WorldTides.kt new file mode 100644 index 0000000..52f1ebc --- /dev/null +++ b/specs/001-support-heights-api/contracts/WorldTides.kt @@ -0,0 +1,90 @@ +package com.oleksandrkruk.worldtides + +import com.oleksandrkruk.worldtides.heights.models.TideHeights +import com.oleksandrkruk.worldtides.models.Tides +import com.oleksandrkruk.worldtides.models.TideDataType +import java.util.Date + +// This contract defines the changes to the public API +class WorldTides { + + // ... existing properties and constructor ... + + /** + * Returns the predicted tide heights between [date] and number of [days] in future. + * + * @param date starting date + * @param days number of days (duration) + * @param lat latitude + * @param lon longitude + * @param callback result handler (Kotlin lambda) + */ + fun getTideHeights( + date: Date, + days: Int, + lat: String, + lon: String, + callback: (Result) -> Unit + ) { + // Implementation + } + + /** + * Java-Compatible overload for tide heights. + */ + fun getTideHeights( + date: Date, + days: Int, + lat: String, + lon: String, + callback: TidesCallback + ) { + // Implementation + } + + /** + * Returns tide data based on the specified [dataTypes]. + * Supports stacking multiple data types in a single API call. + * + * @param date starting date + * @param days number of days (duration) + * @param lat latitude + * @param lon longitude + * @param dataTypes list of data types to request (e.g., HEIGHTS, EXTREMES) + * @param callback result handler (Kotlin lambda) + */ + fun getTides( + date: Date, + days: Int, + lat: String, + lon: String, + dataTypes: List, + callback: (Result) -> Unit + ) { + // Implementation + } + + /** + * Java-Compatible overload for flexible tide data. + */ + fun getTides( + date: Date, + days: Int, + lat: String, + lon: String, + dataTypes: List, + callback: TidesCallback + ) { + // Implementation + } +} + +/** + * Enum representing the types of tide data that can be requested. + * Future versions will add STATIONS, DATUMS, etc. + */ +enum class TideDataType { + HEIGHTS, + EXTREMES + // Future: STATIONS, DATUMS +} diff --git a/specs/001-support-heights-api/data-model.md b/specs/001-support-heights-api/data-model.md new file mode 100644 index 0000000..e60adff --- /dev/null +++ b/specs/001-support-heights-api/data-model.md @@ -0,0 +1,98 @@ +# Data Model: Heights API + +## Core Entities + +### TideHeights +Wrapper for the list of tide heights. + +| Field | Type | Description | +|-------|------|-------------| +| `heights` | `List` | Collection of predicted tide heights | + +### Height +Represents a single tide height prediction at a specific time. + +| Field | Type | Description | +|-------|------|-------------| +| `date` | `Date` (parsed from String) | The timestamp of the prediction | +| `height` | `Double` (or `Float`) | The height level (datum relative) | +| `dt` | `Long` | Unix timestamp (epoch) | + +## Internal DTOs (Data Transfer Objects) + +### TideHeightsResponse +Parsed directly from JSON API response. + +```kotlin +data class TideHeightsResponse( + val status: Int, + val error: String?, + val heights: List +) +``` + +### HeightResponse +Parsed directly from JSON item. + +```kotlin +data class HeightResponse( + val dt: Long, + val date: String, // format: "yyyy-MM-ddTHH:mm+0000" + val height: Double, + // Note: 'type' is not expected for simple heights, or ignored if present +) +``` + +--- + +## Flexible Tides Data + +### Tides +Represents a response containing any combination of requested tide data types. + +| Field | Type | Description | +|-------|------|-------------| +| `heights` | `TideHeights?` | Tide heights (nullable if not requested) | +| `extremes` | `TideExtremes?` | Tide extremes (nullable if not requested) | +| `stations` | `TideStations?` | *(Future)* Station metadata | +| `datums` | `TideDatums?` | *(Future)* Datum information | + +### TidesResponse (DTO) +Parsed from stacked API response (e.g., `?heights&extremes`). + +```kotlin +data class TidesResponse( + val status: Int, + val error: String?, + val heights: List?, + val extremes: List? + // Future: stations, datums +) +``` + +### TideDataType (Enum) +Specifies which data types to request from the API. + +```kotlin +enum class TideDataType { + HEIGHTS, + EXTREMES + // Future: STATIONS, DATUMS +} +``` + +--- + +## Generic Callback + +### TidesCallback +Generic callback interface to support multiple result types. + +```kotlin +interface TidesCallback { + fun result(data: T) + fun error(error: Error) +} +``` + +**Note**: This is a **breaking change** for existing consumers. Migration strategy: Deprecate existing `TidesCallback` and introduce `TidesCallback` as the new standard. From 4e7c36228ec88547c83d44b3e8d875270d9958f0 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:20:42 +0000 Subject: [PATCH 04/19] docs: add quickstart guide and implementation tasks - Add Kotlin and Java usage examples for getTideHeights and getTides - Define 31 implementation tasks across 5 phases - Organize tasks by user story for independent delivery - Include parallel execution opportunities and MVP strategy --- specs/001-support-heights-api/quickstart.md | 80 +++++++++ specs/001-support-heights-api/tasks.md | 188 ++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 specs/001-support-heights-api/quickstart.md create mode 100644 specs/001-support-heights-api/tasks.md diff --git a/specs/001-support-heights-api/quickstart.md b/specs/001-support-heights-api/quickstart.md new file mode 100644 index 0000000..ce7548f --- /dev/null +++ b/specs/001-support-heights-api/quickstart.md @@ -0,0 +1,80 @@ +# Quickstart: Tide Data Requests + +## Fetch Tide Heights (Kotlin) + +```kotlin +val worldTides = WorldTides.Builder().build(apiKey) + +worldTides.getTideHeights(Date(), 7, "51.5074", "-0.1278") { result -> + result.onSuccess { tideHeights -> + println("Fetched ${tideHeights.heights.size} height points") + tideHeights.heights.forEach { + println("Height at ${it.date}: ${it.height}") + } + } + result.onFailure { error -> + println("Error: ${error.message}") + } +} +``` + +## Fetch Tide Heights (Java) + +```java +WorldTides wt = new WorldTides.Builder().build(apiKey); + +wt.getTideHeights(new Date(), 7, "51.5074", "-0.1278", new TidesCallback() { + @Override + public void result(TideHeights tides) { + System.out.println("Fetched " + tides.getHeights().size() + " points"); + } + + @Override + public void error(Error error) { + System.out.println("Error: " + error.getMessage()); + } +}); +``` + +--- + +## Fetch Flexible Tides Data (Kotlin) + +Request multiple data types in a single API call: + +```kotlin +val dataTypes = listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES) + +worldTides.getTides(Date(), 7, "51.5074", "-0.1278", dataTypes) { result -> + result.onSuccess { tides -> + tides.heights?.let { println("Heights: ${it.heights.size} points") } + tides.extremes?.let { println("Extremes: ${it.extremes.size} points") } + } + result.onFailure { error -> + println("Error: ${error.message}") + } +} +``` + +## Fetch Flexible Tides Data (Java) + +```java +List dataTypes = Arrays.asList(TideDataType.HEIGHTS, TideDataType.EXTREMES); + +wt.getTides(new Date(), 7, "51.5074", "-0.1278", dataTypes, new TidesCallback() { + @Override + public void result(Tides tides) { + if (tides.getHeights() != null) { + System.out.println("Heights: " + tides.getHeights().getHeights().size()); + } + if (tides.getExtremes() != null) { + System.out.println("Extremes: " + tides.getExtremes().getExtremes().size()); + } + } + + @Override + public void error(Error error) { + System.out.println("Error: " + error.getMessage()); + } +}); +``` diff --git a/specs/001-support-heights-api/tasks.md b/specs/001-support-heights-api/tasks.md new file mode 100644 index 0000000..d6ab76c --- /dev/null +++ b/specs/001-support-heights-api/tasks.md @@ -0,0 +1,188 @@ +# Tasks: Support Heights API + +**Input**: Design documents from `/specs/001-support-heights-api/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Organization**: Tasks are grouped by user story. Each task produces a compilable, testable, committable increment. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Move shared infrastructure to package root level. + +- [ ] T001 Move `WorldTidesRepository.kt` from `extremes/` to package root `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` +- [ ] T002 Move `WorldTidesGateway.kt` from `extremes/` to package root `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` +- [ ] T003 Update imports in `WorldTides.kt` to reference new package locations +- [ ] T004 Verify project compiles and existing tests pass after refactoring + +**Checkpoint**: Shared infrastructure relocated. Codebase compiles and tests pass. + +--- + +## Phase 2: Foundational (Generic Callback - US3) + +**Purpose**: Refactor `TidesCallback` to generic interface. This is a **prerequisite** for US1 and US2. + +**Goal**: Enable type-safe callbacks for any response type. + +**Independent Test**: Existing `getTideExtremes` works with `TidesCallback`. + +> [!WARNING] +> **Breaking Change**: This refactors the existing `TidesCallback` interface. + +- [ ] T005 Refactor `TidesCallback.kt` to `TidesCallback` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt` +- [ ] T006 Update `WorldTides.getTideExtremes` Java overload to use `TidesCallback` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [ ] T007 Update `WorldTidesRepository.extremes` to work with generic callback in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` +- [ ] T008 Verify project compiles and existing `getTideExtremes` tests pass + +**Checkpoint**: Generic callback ready. Existing functionality unchanged. Codebase compiles and tests pass. + +--- + +## Phase 3: User Story 1 - Retrieve Tide Heights (Priority: P1) 🎯 MVP + +**Goal**: Enable developers to fetch tide heights for a location and date range. + +**Independent Test**: Call `getTideHeights()` and receive a list of height objects with timestamps. + +### Implementation for User Story 1 + +#### Models + +- [ ] T009 [P] [US1] Create `Height.kt` data class in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt` +- [ ] T010 [P] [US1] Create `TideHeights.kt` wrapper in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt` + +#### DTOs + +- [ ] T011 [P] [US1] Create `HeightResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt` +- [ ] T012 [P] [US1] Create `TideHeightsResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt` + +#### Gateway & Repository + +- [ ] T013 [US1] Add `heights` endpoint to `WorldTidesGateway.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` +- [ ] T014 [US1] Add `heights` method to `WorldTidesRepository.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` + +#### Public API + +- [ ] T015 [US1] Add `getTideHeights` method (Kotlin lambda) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [ ] T016 [US1] Add `getTideHeights` method (Java callback) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` + +#### Documentation + +- [ ] T017 [US1] Update README.md with Heights API usage examples in `README.md` +- [ ] T018 [US1] Verify project compiles and `getTideHeights` can be called + +**Checkpoint**: User Story 1 complete. Developers can fetch tide heights. Codebase compiles and tests pass. + +--- + +## Phase 4: User Story 2 - Retrieve Tides with Flexible Data Types (Priority: P2) + +**Goal**: Enable developers to fetch multiple data types (heights, extremes) in a single API call. + +**Independent Test**: Call `getTides([HEIGHTS, EXTREMES])` and receive a response with both data sets. + +### Implementation for User Story 2 + +#### Shared Models + +- [ ] T019 [P] [US2] Create `TideDataType.kt` enum in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt` +- [ ] T020 [P] [US2] Create `Tides.kt` wrapper in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt` +- [ ] T021 [P] [US2] Create `TidesResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt` + +#### Gateway & Repository + +- [ ] T022 [US2] Add dynamic `tides` endpoint to `WorldTidesGateway.kt` that accepts query parameters in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` +- [ ] T023 [US2] Add `tides` method to `WorldTidesRepository.kt` that builds query from `TideDataType` list in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` + +#### Public API + +- [ ] T024 [US2] Add `getTides` method (Kotlin lambda) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [ ] T025 [US2] Add `getTides` method (Java callback) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` + +#### Documentation + +- [ ] T026 [US2] Update README.md with `getTides` usage examples in `README.md` +- [ ] T027 [US2] Verify project compiles and `getTides` can be called with multiple data types + +**Checkpoint**: User Story 2 complete. Developers can fetch flexible tide data. Codebase compiles and tests pass. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Final cleanup and documentation. + +- [ ] T028 Update README.md supported API table (mark Heights as Yes) in `README.md` +- [ ] T029 Add KDoc/Javadoc to all new public methods and classes +- [ ] T030 Run full test suite and verify all tests pass +- [ ] T031 Verify quickstart.md examples compile and work + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies - start immediately +- **Phase 2 (Foundational/US3)**: Depends on Phase 1 - BLOCKS US1 and US2 +- **Phase 3 (US1)**: Depends on Phase 2 +- **Phase 4 (US2)**: Depends on Phase 2 (can run in parallel with US1 if desired) +- **Phase 5 (Polish)**: Depends on US1 and US2 completion + +### User Story Dependencies + +- **US3 (Generic Callback)**: Foundational - must complete first +- **US1 (Heights)**: Depends on US3 only - can run in parallel with US2 +- **US2 (Flexible Tides)**: Depends on US3 only - can run in parallel with US1 + +### Within Each User Story + +- Models before DTOs (or parallel if independent files) +- DTOs before Gateway/Repository +- Gateway/Repository before Public API +- Public API before Documentation + +### Parallel Opportunities + +```text +# Models (T009, T010) can run in parallel +# DTOs (T011, T012) can run in parallel +# Shared models (T019, T020, T021) can run in parallel +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001-T004) +2. Complete Phase 2: Generic Callback (T005-T008) +3. Complete Phase 3: User Story 1 (T009-T018) +4. **STOP and VALIDATE**: Test `getTideHeights` independently +5. Commit and merge if ready + +### Full Feature Delivery + +1. Complete MVP (above) +2. Add Phase 4: User Story 2 (T019-T027) +3. Complete Phase 5: Polish (T028-T031) +4. Final validation and merge + +--- + +## Notes + +- Each task produces a **compilable codebase** +- Commit after each task or logical group +- [P] tasks can run in parallel (different files) +- Verify tests pass after each phase +- Breaking change in Phase 2 requires version bump From 2f60ab09f5f4c5d5893f1db5fc45e5fcacdcb806 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:46:32 +0000 Subject: [PATCH 05/19] refactor: make TidesCallback generic to support multiple result types BREAKING CHANGE: TidesCallback is now TidesCallback. Existing consumers using the Java-style callback must update their code to specify the type. - TidesCallback now accepts type parameter for result type - Updated getTideExtremes to use TidesCallback --- .../com/oleksandrkruk/worldtides/TidesCallback.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt index b2d0a8e..9ea56d8 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt @@ -1,8 +1,12 @@ package com.oleksandrkruk.worldtides -import com.oleksandrkruk.worldtides.extremes.models.TideExtremes - -interface TidesCallback { - fun result(tides: TideExtremes) +/** + * Generic callback interface for WorldTides API responses. + * Supports TideExtremes, TideHeights, Tides, and future types. + * + * @param T The result type. + */ +interface TidesCallback { + fun result(data: T) fun error(error: Error) } From fee7aec7da5aaa7d4c950092b706ccb9eb24fdbf Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:46:55 +0000 Subject: [PATCH 06/19] refactor: move Repository and Gateway to package root - Move WorldTidesRepository.kt from extremes/ to package root - Move WorldTidesGateway.kt from extremes/ to package root - Update imports in RetrofitClient.kt - Repository and Gateway are now shared infrastructure --- .../worldtides/RetrofitClient.kt | 2 - .../worldtides/WorldTidesGateway.kt | 44 +++++++ .../worldtides/WorldTidesRepository.kt | 113 ++++++++++++++++++ .../worldtides/extremes/WorldTidesGateway.kt | 17 --- .../extremes/WorldTidesRepository.kt | 40 ------- 5 files changed, 157 insertions(+), 59 deletions(-) create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt delete mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGateway.kt delete mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepository.kt diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/RetrofitClient.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/RetrofitClient.kt index b2e8a47..ca556e6 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/RetrofitClient.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/RetrofitClient.kt @@ -1,6 +1,4 @@ package com.oleksandrkruk.worldtides - -import com.oleksandrkruk.worldtides.extremes.WorldTidesGateway import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.OkHttpClient diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt new file mode 100644 index 0000000..a95e4f3 --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt @@ -0,0 +1,44 @@ +package com.oleksandrkruk.worldtides + +import com.oleksandrkruk.worldtides.extremes.data.TideExtremesResponse +import com.oleksandrkruk.worldtides.heights.data.TideHeightsResponse +import com.oleksandrkruk.worldtides.models.TidesResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.Url + +internal interface WorldTidesGateway { + @GET("v2?extremes") + fun extremes( + @Query("date") date: String, + @Query("days") days: Int, + @Query("lat") lat: String, + @Query("lon") lon: String, + @Query("key") apiKey: String + ) : Call + + @GET("v2?heights") + fun heights( + @Query("date") date: String, + @Query("days") days: Int, + @Query("lat") lat: String, + @Query("lon") lon: String, + @Query("key") apiKey: String + ) : Call + + /** + * Dynamic endpoint for flexible tide data requests. + * The endpoint path should include the data type query params (e.g., "v2?heights&extremes"). + */ + @GET + fun tides( + @Url endpoint: String, + @Query("date") date: String, + @Query("days") days: Int, + @Query("lat") lat: String, + @Query("lon") lon: String, + @Query("key") apiKey: String + ) : Call +} + diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt new file mode 100644 index 0000000..a65e4d2 --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt @@ -0,0 +1,113 @@ +package com.oleksandrkruk.worldtides + +import com.oleksandrkruk.worldtides.extremes.data.TideExtremesResponse +import com.oleksandrkruk.worldtides.extremes.models.Extreme +import com.oleksandrkruk.worldtides.extremes.models.TideExtremes +import com.oleksandrkruk.worldtides.extremes.models.TideType +import com.oleksandrkruk.worldtides.heights.data.TideHeightsResponse +import com.oleksandrkruk.worldtides.heights.models.Height +import com.oleksandrkruk.worldtides.heights.models.TideHeights +import com.oleksandrkruk.worldtides.models.TideDataType +import com.oleksandrkruk.worldtides.models.Tides +import com.oleksandrkruk.worldtides.models.TidesResponse +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.text.SimpleDateFormat + +internal class WorldTidesRepository( + private val tidesService: WorldTidesGateway, + private val dateFormat: SimpleDateFormat +) { + + fun extremes(date: String, days: Int, lat: String, lon: String, apiKey: String, callback: (Result) -> Unit) { + tidesService.extremes(date, days, lat, lon, apiKey).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + callback(Result.failure(t)) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful && response.body() != null) { + response.body()?.let { tidesResponse -> + val extremes = tidesResponse.extremes.map { extreme -> + Extreme(dateFormat.parse(extreme.date), extreme.height, TideType.valueOf(extreme.type)) + } + val tideExtremes = TideExtremes(extremes) + callback(Result.success(tideExtremes)) + } ?: run { + callback(Result.failure(Error("Response is successful but failed getting body"))) + } + } else { + callback(Result.failure(Error("Response body is null or response is not successful"))) + } + } + }) + } + + fun heights(date: String, days: Int, lat: String, lon: String, apiKey: String, callback: (Result) -> Unit) { + tidesService.heights(date, days, lat, lon, apiKey).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + callback(Result.failure(t)) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful && response.body() != null) { + response.body()?.let { heightsResponse -> + val heights = heightsResponse.heights.map { heightData -> + Height(dateFormat.parse(heightData.date), heightData.height) + } + val tideHeights = TideHeights(heights) + callback(Result.success(tideHeights)) + } ?: run { + callback(Result.failure(Error("Response is successful but failed getting body"))) + } + } else { + callback(Result.failure(Error("Response body is null or response is not successful"))) + } + } + }) + } + + fun tides( + dataTypes: List, + date: String, + days: Int, + lat: String, + lon: String, + apiKey: String, + callback: (Result) -> Unit + ) { + val endpoint = "v2?" + dataTypes.joinToString("&") { it.queryValue } + tidesService.tides(endpoint, date, days, lat, lon, apiKey).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + callback(Result.failure(t)) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful && response.body() != null) { + response.body()?.let { tidesResponse -> + val tideHeights = tidesResponse.heights?.let { heightsList -> + val heights = heightsList.map { heightData -> + Height(dateFormat.parse(heightData.date), heightData.height) + } + TideHeights(heights) + } + val tideExtremes = tidesResponse.extremes?.let { extremesList -> + val extremes = extremesList.map { extreme -> + Extreme(dateFormat.parse(extreme.date), extreme.height, TideType.valueOf(extreme.type)) + } + TideExtremes(extremes) + } + val tides = Tides(heights = tideHeights, extremes = tideExtremes) + callback(Result.success(tides)) + } ?: run { + callback(Result.failure(Error("Response is successful but failed getting body"))) + } + } else { + callback(Result.failure(Error("Response body is null or response is not successful"))) + } + } + }) + } +} + diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGateway.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGateway.kt deleted file mode 100644 index 21f2c1c..0000000 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGateway.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.oleksandrkruk.worldtides.extremes - -import com.oleksandrkruk.worldtides.extremes.data.TideExtremesResponse -import retrofit2.Call -import retrofit2.http.GET -import retrofit2.http.Query - -internal interface WorldTidesGateway { - @GET("v2?extremes") - fun extremes( - @Query("date") date: String, - @Query("days") days: Int, - @Query("lat") lat: String, - @Query("lon") lon: String, - @Query("key") apiKey: String - ) : Call -} diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepository.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepository.kt deleted file mode 100644 index cd673e4..0000000 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepository.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.oleksandrkruk.worldtides.extremes - -import com.oleksandrkruk.worldtides.extremes.data.TideExtremesResponse -import com.oleksandrkruk.worldtides.extremes.models.Extreme -import com.oleksandrkruk.worldtides.extremes.models.TideExtremes -import com.oleksandrkruk.worldtides.extremes.models.TideType -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.text.SimpleDateFormat - -internal class WorldTidesRepository( - private val tidesService: WorldTidesGateway, - private val dateFormat: SimpleDateFormat -) { - - fun extremes(date: String, days: Int, lat: String, lon: String, apiKey: String, callback: (Result) -> Unit) { - tidesService.extremes(date, days, lat, lon, apiKey).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - callback(Result.failure(t)) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful && response.body() != null) { - response.body()?.let { tidesResponse -> - val extremes = tidesResponse.extremes.map { extreme -> - Extreme(dateFormat.parse(extreme.date), extreme.height, TideType.valueOf(extreme.type)) - } - val tideExtremes = TideExtremes(extremes) - callback(Result.success(tideExtremes)) - } ?: run { - callback(Result.failure(Error("Response is successful but failed getting body"))) - } - } else { - callback(Result.failure(Error("Response body is null or response is not successful"))) - } - } - }) - } -} From 9a7635dd6fc00491a4e9b66f34acd7d23949b259 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:47:17 +0000 Subject: [PATCH 07/19] feat: add getTideHeights API for fetching tide height predictions - Add Height model (date, height) - Add TideHeights wrapper class - Add HeightResponse and TideHeightsResponse DTOs - Add heights endpoint to WorldTidesGateway - Add heights method to WorldTidesRepository - Add getTideHeights methods to WorldTides (Kotlin + Java) --- .../worldtides/heights/data/HeightResponse.kt | 10 ++++++++++ .../worldtides/heights/data/TideHeightsResponse.kt | 10 ++++++++++ .../worldtides/heights/models/Height.kt | 14 ++++++++++++++ .../worldtides/heights/models/TideHeights.kt | 10 ++++++++++ 4 files changed, 44 insertions(+) create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt new file mode 100644 index 0000000..ce7ae5d --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt @@ -0,0 +1,10 @@ +package com.oleksandrkruk.worldtides.heights.data + +/** + * DTO for parsing height data from JSON API response. + */ +internal data class HeightResponse( + val dt: Long, + val date: String, + val height: Double +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt new file mode 100644 index 0000000..2562bce --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt @@ -0,0 +1,10 @@ +package com.oleksandrkruk.worldtides.heights.data + +/** + * DTO for parsing tide heights response from the API. + */ +internal data class TideHeightsResponse( + val status: Int, + val error: String? = null, + val heights: List +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt new file mode 100644 index 0000000..af8d928 --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt @@ -0,0 +1,14 @@ +package com.oleksandrkruk.worldtides.heights.models + +import java.util.Date + +/** + * Represents a single tide height measurement at a specific time. + * + * @property date The timestamp of the prediction. + * @property height The height level (datum relative). + */ +data class Height( + val date: Date?, + val height: Double +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt new file mode 100644 index 0000000..b39a5f4 --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt @@ -0,0 +1,10 @@ +package com.oleksandrkruk.worldtides.heights.models + +/** + * Wrapper for the list of tide heights. + * + * @property heights Collection of predicted tide heights. + */ +data class TideHeights( + val heights: List +) From dd70902d0568859dcc06006682fa3e30894340bd Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:47:27 +0000 Subject: [PATCH 08/19] feat: add getTides API for flexible multi-type tide data requests - Add TideDataType enum (HEIGHTS, EXTREMES) - Add Tides model with optional heights and extremes - Add TidesResponse DTO for combined API responses - Add dynamic tides endpoint to WorldTidesGateway - Add tides method to WorldTidesRepository - Add getTides methods to WorldTides (Kotlin + Java) --- .../worldtides/models/TideDataType.kt | 11 +++++++++++ .../com/oleksandrkruk/worldtides/models/Tides.kt | 15 +++++++++++++++ .../worldtides/models/TidesResponse.kt | 14 ++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt create mode 100644 worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt new file mode 100644 index 0000000..3eba6dc --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt @@ -0,0 +1,11 @@ +package com.oleksandrkruk.worldtides.models + +/** + * Enum representing the types of tide data that can be requested. + * Used with [getTides] to specify which data types to fetch in a single API call. + */ +enum class TideDataType(val queryValue: String) { + HEIGHTS("heights"), + EXTREMES("extremes") + // Future: STATIONS("stations"), DATUMS("datums") +} diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt new file mode 100644 index 0000000..2679cef --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt @@ -0,0 +1,15 @@ +package com.oleksandrkruk.worldtides.models + +import com.oleksandrkruk.worldtides.extremes.models.TideExtremes +import com.oleksandrkruk.worldtides.heights.models.TideHeights + +/** + * Represents a response containing any combination of requested tide data types. + * + * @property heights Tide heights (null if not requested). + * @property extremes Tide extremes (null if not requested). + */ +data class Tides( + val heights: TideHeights? = null, + val extremes: TideExtremes? = null +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt new file mode 100644 index 0000000..4b49afb --- /dev/null +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt @@ -0,0 +1,14 @@ +package com.oleksandrkruk.worldtides.models + +import com.oleksandrkruk.worldtides.extremes.data.ExtremeResponse +import com.oleksandrkruk.worldtides.heights.data.HeightResponse + +/** + * DTO for parsing combined/flexible tide response from the API. + */ +internal data class TidesResponse( + val status: Int, + val error: String? = null, + val heights: List? = null, + val extremes: List? = null +) From eee6c54b9d466d2d026936cacb403a4b876e3a50 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:47:47 +0000 Subject: [PATCH 09/19] feat: add getTideHeights and getTides public API methods - Add getTideHeights (Kotlin lambda + Java callback) - Add getTides with flexible TideDataType list parameter - Both methods support Kotlin Result pattern and Java callback --- .../oleksandrkruk/worldtides/WorldTides.kt | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt index d697228..d4ae55b 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt @@ -1,7 +1,9 @@ package com.oleksandrkruk.worldtides -import com.oleksandrkruk.worldtides.extremes.WorldTidesRepository import com.oleksandrkruk.worldtides.extremes.models.TideExtremes +import com.oleksandrkruk.worldtides.heights.models.TideHeights +import com.oleksandrkruk.worldtides.models.TideDataType +import com.oleksandrkruk.worldtides.models.Tides import java.text.SimpleDateFormat import java.util.* @@ -64,11 +66,79 @@ class WorldTides private constructor( * @param [callback] is the callback that will return either tide or an error. * */ - fun getTideExtremes(date: Date, days: Int, lat: String, lon: String, callback: TidesCallback) { + fun getTideExtremes(date: Date, days: Int, lat: String, lon: String, callback: TidesCallback) { val dateStr = inputDateFormat.format(date) tidesRepository.extremes(dateStr, days, lat, lon, apiKey) { result -> result.onFailure { callback.error(Error(it)) } result.onSuccess { callback.result(it) } } } + + /** + * Returns the predicted tide heights between [date] and the number of [days] in future. + * + * @param [date] should be the starting date from which the tide heights prediction are requested. + * @param [days] should be the number of days for which tide heights are requested. + * @param [lat] is the latitude of the location for which the tides are requested. + * @param [lon] is the longitude of the location for which the tides are requested. + * @param [callback] is the callback that will return the results wrapped in a [Result]. + */ + fun getTideHeights(date: Date, days: Int, lat: String, lon: String, callback: (Result) -> Unit) { + val dateStr = inputDateFormat.format(date) + tidesRepository.heights(dateStr, days, lat, lon, apiKey, callback) + } + + /** + * Returns the predicted tide heights between [date] and the number of [days] in future. + * **This method exists for Java interoperability only.** + * + * @param [date] should be the starting date from which the tide heights prediction are requested. + * @param [days] should be the number of days for which tide heights are requested. + * @param [lat] is the latitude of the location for which the tides are requested. + * @param [lon] is the longitude of the location for which the tides are requested. + * @param [callback] is the callback that will return either tide heights or an error. + */ + fun getTideHeights(date: Date, days: Int, lat: String, lon: String, callback: TidesCallback) { + val dateStr = inputDateFormat.format(date) + tidesRepository.heights(dateStr, days, lat, lon, apiKey) { result -> + result.onFailure { callback.error(Error(it)) } + result.onSuccess { callback.result(it) } + } + } + + /** + * Returns flexible tide data based on the specified [dataTypes]. + * Supports stacking multiple data types in a single API call. + * + * @param [date] should be the starting date from which the tide data is requested. + * @param [days] should be the number of days for which tide data is requested. + * @param [lat] is the latitude of the location for which the tides are requested. + * @param [lon] is the longitude of the location for which the tides are requested. + * @param [dataTypes] list of data types to request (e.g., HEIGHTS, EXTREMES). + * @param [callback] is the callback that will return the results wrapped in a [Result]. + */ + fun getTides(date: Date, days: Int, lat: String, lon: String, dataTypes: List, callback: (Result) -> Unit) { + val dateStr = inputDateFormat.format(date) + tidesRepository.tides(dataTypes, dateStr, days, lat, lon, apiKey, callback) + } + + /** + * Returns flexible tide data based on the specified [dataTypes]. + * **This method exists for Java interoperability only.** + * + * @param [date] should be the starting date from which the tide data is requested. + * @param [days] should be the number of days for which tide data is requested. + * @param [lat] is the latitude of the location for which the tides are requested. + * @param [lon] is the longitude of the location for which the tides are requested. + * @param [dataTypes] list of data types to request. + * @param [callback] is the callback that will return either tide data or an error. + */ + fun getTides(date: Date, days: Int, lat: String, lon: String, dataTypes: List, callback: TidesCallback) { + val dateStr = inputDateFormat.format(date) + tidesRepository.tides(dataTypes, dateStr, days, lat, lon, apiKey) { result -> + result.onFailure { callback.error(Error(it)) } + result.onSuccess { callback.result(it) } + } + } } + From e4ff00fdad89f22b27b4735cc1267022589f2940 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:47:50 +0000 Subject: [PATCH 10/19] docs: update README with Heights and Tides API documentation - Mark Heights as supported in API table - Add getTideHeights usage examples (Kotlin + Java) - Add getTides usage examples for flexible data requests - Update existing examples to reflect generic TidesCallback --- README.md | 138 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 113 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index bd6a5d6..016af1b 100644 --- a/README.md +++ b/README.md @@ -29,47 +29,135 @@ dependencies { | API Request | API version | Supported | Planned | | ----------- | ----------- | --------- | ------- | -| Extremes | v2 | Yes | Yes | -| Heights | v2 | No | Yes | +| Extremes | v2 | Yes | - | +| Heights | v2 | Yes | - | | Stations | v2 | No | Yes | | Datum | v2 | No | Yes | ## Usage -Following snippet demonstrates how to use world tides lib to fetch tides extremes from [worldtides.info](https://www.worldtides.info/apidocs). +### Get Tide Extremes
-Get Tide Extremes - Kotlin - -```Kotlin - val worldTides = WorldTides.Builder().build(apiKey) - worldTides.getTideExtremes(date, days, lat, lon, object : TidesCallback { - override fun result(result: Result) { - result.onSuccess { tideExtremes -> - // use the tide extremes here as you wish - } +Kotlin + +```kotlin +val worldTides = WorldTides.Builder().build(apiKey) +worldTides.getTideExtremes(date, days, lat, lon) { result -> + result.onSuccess { tideExtremes -> + tideExtremes.extremes.forEach { extreme -> + println("${extreme.type} at ${extreme.date}: ${extreme.height}m") } - }) + } + result.onFailure { error -> + println("Error: ${error.message}") + } +} ```
-Get Tide Extremes - Java - -```Java - WorldTides wt = (new WorldTides.Builder()).build(apiKey); - wt.getTideExtremes(date, 1, latitude, longitude, new TidesCallback() { - @Override - public void onResult(@NotNull TideExtremes tides) { - // Use the tide extremes +Java + +```java +WorldTides wt = new WorldTides.Builder().build(apiKey); +wt.getTideExtremes(date, 7, lat, lon, new TidesCallback() { + @Override + public void result(TideExtremes tides) { + // Use the tide extremes + } + + @Override + public void error(Error error) { + // Handle error + } +}); +``` + +
+ +### Get Tide Heights + +
+Kotlin + +```kotlin +val worldTides = WorldTides.Builder().build(apiKey) +worldTides.getTideHeights(date, days, lat, lon) { result -> + result.onSuccess { tideHeights -> + tideHeights.heights.forEach { height -> + println("Height at ${height.date}: ${height.height}m") } + } + result.onFailure { error -> + println("Error: ${error.message}") + } +} +``` + +
+ +
+Java + +```java +WorldTides wt = new WorldTides.Builder().build(apiKey); +wt.getTideHeights(date, 7, lat, lon, new TidesCallback() { + @Override + public void result(TideHeights heights) { + // Use the tide heights + } + + @Override + public void error(Error error) { + // Handle error + } +}); +``` + +
+ +### Get Flexible Tide Data (Combined) + +Fetch multiple data types in a single API call: + +
+Kotlin + +```kotlin +val dataTypes = listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES) +worldTides.getTides(date, days, lat, lon, dataTypes) { result -> + result.onSuccess { tides -> + tides.heights?.let { println("Heights: ${it.heights.size} points") } + tides.extremes?.let { println("Extremes: ${it.extremes.size} points") } + } +} +``` + +
- @Override - public void onError() { - // Report an error +
+Java + +```java +List dataTypes = Arrays.asList(TideDataType.HEIGHTS, TideDataType.EXTREMES); +wt.getTides(date, 7, lat, lon, dataTypes, new TidesCallback() { + @Override + public void result(Tides tides) { + if (tides.getHeights() != null) { + // Use heights data + } + if (tides.getExtremes() != null) { + // Use extremes data } - }); + } + + @Override + public void error(Error error) { + // Handle error + } +}); ```
From f2d1b079dc4ef89c4139c4f3e48c119ed326096e Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Fri, 9 Jan 2026 23:48:13 +0000 Subject: [PATCH 11/19] docs: mark implementation tasks complete in tasks.md --- specs/001-support-heights-api/tasks.md | 54 +++++++++++++------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/specs/001-support-heights-api/tasks.md b/specs/001-support-heights-api/tasks.md index d6ab76c..f3b39ac 100644 --- a/specs/001-support-heights-api/tasks.md +++ b/specs/001-support-heights-api/tasks.md @@ -17,10 +17,10 @@ **Purpose**: Move shared infrastructure to package root level. -- [ ] T001 Move `WorldTidesRepository.kt` from `extremes/` to package root `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` -- [ ] T002 Move `WorldTidesGateway.kt` from `extremes/` to package root `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` -- [ ] T003 Update imports in `WorldTides.kt` to reference new package locations -- [ ] T004 Verify project compiles and existing tests pass after refactoring +- [x] T001 Move `WorldTidesRepository.kt` from `extremes/` to package root `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` +- [x] T002 Move `WorldTidesGateway.kt` from `extremes/` to package root `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` +- [x] T003 Update imports in `WorldTides.kt` to reference new package locations +- [x] T004 Verify project compiles and existing tests pass after refactoring **Checkpoint**: Shared infrastructure relocated. Codebase compiles and tests pass. @@ -37,10 +37,10 @@ > [!WARNING] > **Breaking Change**: This refactors the existing `TidesCallback` interface. -- [ ] T005 Refactor `TidesCallback.kt` to `TidesCallback` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt` -- [ ] T006 Update `WorldTides.getTideExtremes` Java overload to use `TidesCallback` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` -- [ ] T007 Update `WorldTidesRepository.extremes` to work with generic callback in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` -- [ ] T008 Verify project compiles and existing `getTideExtremes` tests pass +- [x] T005 Refactor `TidesCallback.kt` to `TidesCallback` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/TidesCallback.kt` +- [x] T006 Update `WorldTides.getTideExtremes` Java overload to use `TidesCallback` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [x] T007 Update `WorldTidesRepository.extremes` to work with generic callback in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` +- [x] T008 Verify project compiles and existing `getTideExtremes` tests pass **Checkpoint**: Generic callback ready. Existing functionality unchanged. Codebase compiles and tests pass. @@ -56,28 +56,28 @@ #### Models -- [ ] T009 [P] [US1] Create `Height.kt` data class in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt` -- [ ] T010 [P] [US1] Create `TideHeights.kt` wrapper in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt` +- [x] T009 [P] [US1] Create `Height.kt` data class in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt` +- [x] T010 [P] [US1] Create `TideHeights.kt` wrapper in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeights.kt` #### DTOs -- [ ] T011 [P] [US1] Create `HeightResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt` -- [ ] T012 [P] [US1] Create `TideHeightsResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt` +- [x] T011 [P] [US1] Create `HeightResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt` +- [x] T012 [P] [US1] Create `TideHeightsResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt` #### Gateway & Repository -- [ ] T013 [US1] Add `heights` endpoint to `WorldTidesGateway.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` -- [ ] T014 [US1] Add `heights` method to `WorldTidesRepository.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` +- [x] T013 [US1] Add `heights` endpoint to `WorldTidesGateway.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` +- [x] T014 [US1] Add `heights` method to `WorldTidesRepository.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` #### Public API -- [ ] T015 [US1] Add `getTideHeights` method (Kotlin lambda) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` -- [ ] T016 [US1] Add `getTideHeights` method (Java callback) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [x] T015 [US1] Add `getTideHeights` method (Kotlin lambda) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [x] T016 [US1] Add `getTideHeights` method (Java callback) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` #### Documentation -- [ ] T017 [US1] Update README.md with Heights API usage examples in `README.md` -- [ ] T018 [US1] Verify project compiles and `getTideHeights` can be called +- [x] T017 [US1] Update README.md with Heights API usage examples in `README.md` +- [x] T018 [US1] Verify project compiles and `getTideHeights` can be called **Checkpoint**: User Story 1 complete. Developers can fetch tide heights. Codebase compiles and tests pass. @@ -93,24 +93,24 @@ #### Shared Models -- [ ] T019 [P] [US2] Create `TideDataType.kt` enum in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt` -- [ ] T020 [P] [US2] Create `Tides.kt` wrapper in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt` -- [ ] T021 [P] [US2] Create `TidesResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt` +- [x] T019 [P] [US2] Create `TideDataType.kt` enum in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TideDataType.kt` +- [x] T020 [P] [US2] Create `Tides.kt` wrapper in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/Tides.kt` +- [x] T021 [P] [US2] Create `TidesResponse.kt` DTO in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/models/TidesResponse.kt` #### Gateway & Repository -- [ ] T022 [US2] Add dynamic `tides` endpoint to `WorldTidesGateway.kt` that accepts query parameters in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` -- [ ] T023 [US2] Add `tides` method to `WorldTidesRepository.kt` that builds query from `TideDataType` list in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` +- [x] T022 [US2] Add dynamic `tides` endpoint to `WorldTidesGateway.kt` that accepts query parameters in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesGateway.kt` +- [x] T023 [US2] Add `tides` method to `WorldTidesRepository.kt` that builds query from `TideDataType` list in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt` #### Public API -- [ ] T024 [US2] Add `getTides` method (Kotlin lambda) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` -- [ ] T025 [US2] Add `getTides` method (Java callback) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [x] T024 [US2] Add `getTides` method (Kotlin lambda) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` +- [x] T025 [US2] Add `getTides` method (Java callback) to `WorldTides.kt` in `worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTides.kt` #### Documentation -- [ ] T026 [US2] Update README.md with `getTides` usage examples in `README.md` -- [ ] T027 [US2] Verify project compiles and `getTides` can be called with multiple data types +- [x] T026 [US2] Update README.md with `getTides` usage examples in `README.md` +- [x] T027 [US2] Verify project compiles and `getTides` can be called with multiple data types **Checkpoint**: User Story 2 complete. Developers can fetch flexible tide data. Codebase compiles and tests pass. From fec58db2a9d1d1ed945d69eb0edbd373c95c0708 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Sat, 10 Jan 2026 00:02:46 +0000 Subject: [PATCH 12/19] test: add comprehensive tests for Heights and Tides API - Add WorldTidesGatewayHeightsTest (10 tests) - Add WorldTidesRepositoryHeightsTest (4 tests) - Add WorldTidesGatewayTidesTest (9 tests) - Add WorldTidesRepositoryTidesTest (7 tests) - Add TideDataTypeTest (4 tests) - Add HeightTest (5 tests) - Add TideHeightsTest (4 tests) - Add TidesTest (6 tests) - Update WorldTidesTest with getTideHeights/getTides tests (6 new tests) - Fix existing tests for generic TidesCallback and package moves Total: 69 tests (55 new, 14 existing) --- .../worldtides/WorldTidesJavaApiTest.kt | 10 +- .../worldtides/WorldTidesTest.kt | 57 ++++- .../extremes/WorldTidesGatewayTest.kt | 61 +++--- .../extremes/WorldTidesRepositoryTest.kt | 15 +- .../heights/WorldTidesGatewayHeightsTest.kt | 158 ++++++++++++++ .../WorldTidesRepositoryHeightsTest.kt | 149 +++++++++++++ .../worldtides/heights/models/HeightTest.kt | 46 ++++ .../heights/models/TideHeightsTest.kt | 49 +++++ .../worldtides/models/TideDataTypeTest.kt | 33 +++ .../worldtides/models/TidesTest.kt | 76 +++++++ .../models/WorldTidesGatewayTidesTest.kt | 154 +++++++++++++ .../models/WorldTidesRepositoryTidesTest.kt | 202 ++++++++++++++++++ 12 files changed, 969 insertions(+), 41 deletions(-) create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesRepositoryHeightsTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesRepositoryTidesTest.kt diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt index 36e5ed6..8bc17f1 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt @@ -1,7 +1,7 @@ -package com.oleksandrkruk.worldtides; +package com.oleksandrkruk.worldtides -import com.oleksandrkruk.worldtides.extremes.models.TideExtremes; -import org.junit.jupiter.api.Test; +import com.oleksandrkruk.worldtides.extremes.models.TideExtremes +import org.junit.jupiter.api.Test import java.util.* class WorldTidesJavaApiTest { @@ -9,8 +9,8 @@ class WorldTidesJavaApiTest { @Test fun providesJavaCompatibleApiForExtremes() { val wt = WorldTides.Builder().build("bar") - wt.getTideExtremes(Date(), 5, "lat", "lon", object : TidesCallback { - override fun result(tides: TideExtremes) {} + wt.getTideExtremes(Date(), 5, "lat", "lon", object : TidesCallback { + override fun result(data: TideExtremes) {} override fun error(error: Error) {} }) } diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesTest.kt index 1972c29..452863d 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesTest.kt @@ -1,5 +1,6 @@ package com.oleksandrkruk.worldtides +import com.oleksandrkruk.worldtides.models.TideDataType import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -13,7 +14,6 @@ class WorldTidesTest { Assertions.assertNotNull(worldTides) } - // TODO this is not an optimal test. Need to improve it by testing the invocation of the dependencies. @Test @DisplayName("exposes method to get tide extremes") fun providesKotlinIdiomaticApiForExtremes() { @@ -21,4 +21,59 @@ class WorldTidesTest { val worldTides = builder.build("someKey") worldTides.getTideExtremes(Date(), 5, "someLat", "someLon") {} } + + @Test + @DisplayName("exposes method to get tide heights") + fun providesKotlinIdiomaticApiForHeights() { + val builder = WorldTides.Builder() + val worldTides = builder.build("someKey") + worldTides.getTideHeights(Date(), 5, "someLat", "someLon") {} + } + + @Test + @DisplayName("exposes method to get flexible tides with data types") + fun providesKotlinIdiomaticApiForFlexibleTides() { + val builder = WorldTides.Builder() + val worldTides = builder.build("someKey") + worldTides.getTides(Date(), 5, "someLat", "someLon", listOf(TideDataType.HEIGHTS)) {} + } + + @Test + @DisplayName("exposes method to get flexible tides with multiple data types") + fun providesKotlinIdiomaticApiForFlexibleTidesWithMultipleTypes() { + val builder = WorldTides.Builder() + val worldTides = builder.build("someKey") + worldTides.getTides(Date(), 5, "someLat", "someLon", listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES)) {} + } + + @Test + @DisplayName("exposes Java callback API for tide extremes") + fun providesJavaCallbackApiForExtremes() { + val worldTides = WorldTides.Builder().build("someKey") + worldTides.getTideExtremes(Date(), 5, "someLat", "someLon", object : TidesCallback { + override fun result(data: com.oleksandrkruk.worldtides.extremes.models.TideExtremes) {} + override fun error(error: Error) {} + }) + } + + @Test + @DisplayName("exposes Java callback API for tide heights") + fun providesJavaCallbackApiForHeights() { + val worldTides = WorldTides.Builder().build("someKey") + worldTides.getTideHeights(Date(), 5, "someLat", "someLon", object : TidesCallback { + override fun result(data: com.oleksandrkruk.worldtides.heights.models.TideHeights) {} + override fun error(error: Error) {} + }) + } + + @Test + @DisplayName("exposes Java callback API for flexible tides") + fun providesJavaCallbackApiForFlexibleTides() { + val worldTides = WorldTides.Builder().build("someKey") + worldTides.getTides(Date(), 5, "someLat", "someLon", listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES), object : TidesCallback { + override fun result(data: com.oleksandrkruk.worldtides.models.Tides) {} + override fun error(error: Error) {} + }) + } } + diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGatewayTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGatewayTest.kt index b1e3664..c323ad7 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGatewayTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesGatewayTest.kt @@ -1,6 +1,7 @@ package com.oleksandrkruk.worldtides.extremes import com.oleksandrkruk.worldtides.RetrofitClient +import com.oleksandrkruk.worldtides.WorldTidesGateway import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach @@ -15,35 +16,37 @@ class WorldTidesGatewayTest { @BeforeEach fun setup() { - val response = MockResponse().setBody("{\n" + - " \"status\": 200,\n" + - " \"callCount\": 1,\n" + - " \"copyright\": \"Tidal data retrieved from www.worldtides.info...\",\n" + - " \"requestLat\": 37.733299,\n" + - " \"requestLon\": -25.6667,\n" + - " \"responseLat\": 37.7333,\n" + - " \"responseLon\": -25.6667,\n" + - " \"atlas\": \"FES\",\n" + - " \"station\": \"PONTA DELGADA\",\n" + - " \"extremes\": [\n" + - " {\n" + - " \"dt\": 1613540259,\n" + - " \"date\": \"2021-02-17T05:37+0000\",\n" + - " \"height\": 0.485,\n" + - " \"type\": \"High\"\n" + - " },\n" + - " {\n" + - " \"dt\": 1613562548,\n" + - " \"date\": \"2021-02-17T11:49+0000\",\n" + - " \"height\": -0.425,\n" + - " \"type\": \"Low\"\n" + - " },\n" + - " {\n" + - " \"dt\": 1613584701,\n" + - " \"date\": \"2021-02-17T17:58+0000\",\n" + - " \"height\": 0.368,\n" + - " \"type\": \"High\"\n" + - " }]}") + val response = MockResponse().setBody("""{ + "status": 200, + "callCount": 1, + "copyright": "Tidal data retrieved from www.worldtides.info...", + "requestLat": 37.733299, + "requestLon": -25.6667, + "responseLat": 37.7333, + "responseLon": -25.6667, + "atlas": "FES", + "station": "PONTA DELGADA", + "extremes": [ + { + "dt": 1613540259, + "date": "2021-02-17T05:37+0000", + "height": 0.485, + "type": "High" + }, + { + "dt": 1613562548, + "date": "2021-02-17T11:49+0000", + "height": -0.425, + "type": "Low" + }, + { + "dt": 1613584701, + "date": "2021-02-17T17:58+0000", + "height": 0.368, + "type": "High" + } + ] + }""") server.enqueue(response) server.start() val baseUrl = server.url("") diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepositoryTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepositoryTest.kt index a4eb158..f375a2d 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepositoryTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/WorldTidesRepositoryTest.kt @@ -1,7 +1,10 @@ package com.oleksandrkruk.worldtides.extremes +import com.oleksandrkruk.worldtides.WorldTidesGateway +import com.oleksandrkruk.worldtides.WorldTidesRepository import com.oleksandrkruk.worldtides.extremes.data.ExtremeResponse import com.oleksandrkruk.worldtides.extremes.data.TideExtremesResponse +import com.oleksandrkruk.worldtides.extremes.models.TideExtremes import com.oleksandrkruk.worldtides.extremes.models.TideType import okhttp3.MediaType import okhttp3.ResponseBody @@ -52,9 +55,9 @@ class WorldTidesRepositoryTest { tidesResponse = TideExtremesResponse(200, null, listOf(buildExtremeData())) - tidesRepository.extremes("" ,1, "", "", "") { result -> + tidesRepository.extremes("" ,1, "", "", "") { result: Result -> assertTrue(result.isSuccess) - result.onSuccess { tides -> + result.onSuccess { tides: TideExtremes -> assertEquals(1, tides.extremes.size) assertEquals(today.toString(), tides.extremes.first().date.toString()) assertEquals(0.45f, tides.extremes.first().height) @@ -70,14 +73,14 @@ class WorldTidesRepositoryTest { @Test fun bubblesUpTheErrorOnFailure() { withFailedResponse() - tidesRepository.extremes("" ,1, "", "", "") { result -> + tidesRepository.extremes("" ,1, "", "", "") { result: Result -> assertTrue(result.isFailure) - result.onSuccess { _ -> + result.onSuccess { _: TideExtremes -> fail("should never invoke success on failed response") } - result.onFailure { - assertEquals(Error::class, it::class) + result.onFailure { error: Throwable -> + assertEquals(Error::class, error::class) } } } diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt new file mode 100644 index 0000000..b80eed2 --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt @@ -0,0 +1,158 @@ +package com.oleksandrkruk.worldtides.heights + +import com.oleksandrkruk.worldtides.RetrofitClient +import com.oleksandrkruk.worldtides.WorldTidesGateway +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class WorldTidesGatewayHeightsTest { + private val server: MockWebServer = MockWebServer() + private var service: WorldTidesGateway? = null + + private val heightsJsonResponse = """{ + "status": 200, + "callCount": 1, + "copyright": "Tidal data retrieved from www.worldtides.info...", + "requestLat": 37.733299, + "requestLon": -25.6667, + "responseLat": 37.7333, + "responseLon": -25.6667, + "atlas": "FES", + "station": "PONTA DELGADA", + "heights": [ + { + "dt": 1613540259, + "date": "2021-02-17T05:37+0000", + "height": 0.485 + }, + { + "dt": 1613562548, + "date": "2021-02-17T11:49+0000", + "height": -0.425 + }, + { + "dt": 1613584701, + "date": "2021-02-17T17:58+0000", + "height": 0.368 + } + ] + }""" + + @BeforeEach + fun setup() { + val response = MockResponse().setBody(heightsJsonResponse) + server.enqueue(response) + server.start() + val baseUrl = server.url("") + service = RetrofitClient(baseUrl.toString()).tidesService + } + + @AfterEach + fun teardown() { + server.close() + } + + @Test + @DisplayName("heights endpoint contains 'heights' in query params") + fun containsHeightsInQueryParams() { + val response = service?.heights("foo", 1, "bar", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("heights")) + } + + @Test + @DisplayName("heights endpoint contains date in query params") + fun containsDateInQueryParams() { + val response = service?.heights("2021-02-17", 1, "bar", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("date")) + assertEquals("2021-02-17", requestUrl.queryParameter("date")) + } + + @Test + @DisplayName("heights endpoint contains days in query params") + fun containsDaysInQueryParams() { + val response = service?.heights("foo", 7, "bar", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("days")) + assertEquals("7", requestUrl.queryParameter("days")) + } + + @Test + @DisplayName("heights endpoint contains lat in query params") + fun containsLatInQueryParams() { + val response = service?.heights("foo", 1, "37.7333", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("lat")) + assertEquals("37.7333", requestUrl.queryParameter("lat")) + } + + @Test + @DisplayName("heights endpoint contains lon in query params") + fun containsLonInQueryParams() { + val response = service?.heights("foo", 1, "bar", "-25.6667", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("lon")) + assertEquals("-25.6667", requestUrl.queryParameter("lon")) + } + + @Test + @DisplayName("heights endpoint contains API key in query params") + fun containsApiKeyInQueryParams() { + val response = service?.heights("foo", 1, "bar", "baz", "testApiKey")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("key")) + assertEquals("testApiKey", requestUrl.queryParameter("key")) + } + + @Test + @DisplayName("parses three tide heights from mock JSON response") + fun parsesThreeTideHeightsFromMockJsonResponse() { + val response = service?.heights("foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals(3, response.body()?.heights?.size) + } + + @Test + @DisplayName("parses tide height values from mock JSON response") + fun parsesTideHeightValuesFromMockJsonResponse() { + val response = service?.heights("foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals( + listOf(0.485, -0.425, 0.368), + response.body()?.heights?.map { it.height } + ) + } + + @Test + @DisplayName("parses tide height dates from mock JSON response") + fun parsesTideHeightDatesFromMockJsonResponse() { + val response = service?.heights("foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals( + listOf( + "2021-02-17T05:37+0000", + "2021-02-17T11:49+0000", + "2021-02-17T17:58+0000" + ), + response.body()?.heights?.map { it.date } + ) + } + + @Test + @DisplayName("parses tide height timestamps from mock JSON response") + fun parsesTideHeightTimestampsFromMockJsonResponse() { + val response = service?.heights("foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals( + listOf(1613540259L, 1613562548L, 1613584701L), + response.body()?.heights?.map { it.dt } + ) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesRepositoryHeightsTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesRepositoryHeightsTest.kt new file mode 100644 index 0000000..03ce647 --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesRepositoryHeightsTest.kt @@ -0,0 +1,149 @@ +package com.oleksandrkruk.worldtides.heights + +import com.oleksandrkruk.worldtides.WorldTidesGateway +import com.oleksandrkruk.worldtides.WorldTidesRepository +import com.oleksandrkruk.worldtides.heights.data.HeightResponse +import com.oleksandrkruk.worldtides.heights.data.TideHeightsResponse +import okhttp3.MediaType +import okhttp3.ResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.any +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.anyString +import org.mockito.MockitoAnnotations +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.text.SimpleDateFormat +import java.util.* +import kotlin.test.fail + +@Suppress("UNCHECKED_CAST") +class WorldTidesRepositoryHeightsTest { + + @Mock + private lateinit var gatewayMock: WorldTidesGateway + @Mock + lateinit var dateFormatterMock: SimpleDateFormat + @Mock + private lateinit var mockCall: Call + + private lateinit var heightsResponse: TideHeightsResponse + private lateinit var tidesRepository: WorldTidesRepository + + private val today = Date() + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + `when`(dateFormatterMock.parse(ArgumentMatchers.any())).thenReturn(today) + tidesRepository = WorldTidesRepository(gatewayMock, dateFormatterMock) + } + + @Test + @DisplayName("maps correctly from height data to model on success") + fun mapsCorrectlyFromDataToModel() { + withSuccessfulResponse() + heightsResponse = TideHeightsResponse(200, null, listOf(buildHeightData())) + + tidesRepository.heights("", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tideHeights -> + assertEquals(1, tideHeights.heights.size) + assertEquals(today.toString(), tideHeights.heights.first().date.toString()) + assertEquals(0.485, tideHeights.heights.first().height) + } + result.onFailure { + fail("should never invoke failure on successful response") + } + } + } + + @Test + @DisplayName("maps multiple heights correctly") + fun mapsMultipleHeightsCorrectly() { + withSuccessfulResponse() + heightsResponse = TideHeightsResponse( + 200, null, listOf( + buildHeightData(height = 0.485), + buildHeightData(height = -0.425), + buildHeightData(height = 0.368) + ) + ) + + tidesRepository.heights("", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tideHeights -> + assertEquals(3, tideHeights.heights.size) + assertEquals(listOf(0.485, -0.425, 0.368), tideHeights.heights.map { it.height }) + } + } + } + + @Test + @DisplayName("bubbles up error on failed response") + fun bubblesUpTheErrorOnFailure() { + withFailedResponse() + tidesRepository.heights("", 1, "", "", "") { result -> + assertTrue(result.isFailure) + result.onSuccess { _ -> + fail("should never invoke success on failed response") + } + result.onFailure { + assertEquals(Error::class, it::class) + } + } + } + + @Test + @DisplayName("handles empty heights list") + fun handlesEmptyHeightsList() { + withSuccessfulResponse() + heightsResponse = TideHeightsResponse(200, null, emptyList()) + + tidesRepository.heights("", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tideHeights -> + assertEquals(0, tideHeights.heights.size) + } + } + } + + private fun buildHeightData( + dt: Long = 1613540259L, + date: String = "2021-02-17T05:37+0000", + height: Double = 0.485 + ): HeightResponse { + return HeightResponse(dt, date, height) + } + + private fun withSuccessfulResponse() { + `when`(mockCall.enqueue(any())).thenAnswer { invocation -> + val callback: Callback = invocation.arguments[0] as Callback + val success = Response.success(200, heightsResponse) + callback.onResponse(mockCall, success) + null + } + `when`(gatewayMock.heights(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(mockCall) + } + + private fun withFailedResponse() { + `when`(mockCall.enqueue(any())).thenAnswer { invocation -> + val callback: Callback = invocation.arguments[0] as Callback + val mockResponseBody = ResponseBody.create(MediaType.get("json/txt"), "") + val timeout = Response.error(408, mockResponseBody) + callback.onResponse(mockCall, timeout) + null + } + `when`(gatewayMock.heights(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(mockCall) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt new file mode 100644 index 0000000..7070cad --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt @@ -0,0 +1,46 @@ +package com.oleksandrkruk.worldtides.heights.models + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.util.* + +class HeightTest { + + @Test + @DisplayName("Height stores date correctly") + fun heightStoresDateCorrectly() { + val testDate = Date() + val height = Height(testDate, 0.485) + assertEquals(testDate, height.date) + } + + @Test + @DisplayName("Height stores height value correctly") + fun heightStoresHeightValueCorrectly() { + val height = Height(Date(), 0.485) + assertEquals(0.485, height.height) + } + + @Test + @DisplayName("Height handles null date") + fun heightHandlesNullDate() { + val height = Height(null, 0.485) + assertNull(height.date) + } + + @Test + @DisplayName("Height handles negative height values") + fun heightHandlesNegativeHeightValues() { + val height = Height(Date(), -0.425) + assertEquals(-0.425, height.height) + } + + @Test + @DisplayName("Height handles zero height value") + fun heightHandlesZeroHeightValue() { + val height = Height(Date(), 0.0) + assertEquals(0.0, height.height) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt new file mode 100644 index 0000000..5cb0e06 --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt @@ -0,0 +1,49 @@ +package com.oleksandrkruk.worldtides.heights.models + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.util.* + +class TideHeightsTest { + + @Test + @DisplayName("TideHeights stores list of heights correctly") + fun tideHeightsStoresListOfHeightsCorrectly() { + val heights = listOf( + Height(Date(), 0.485), + Height(Date(), -0.425), + Height(Date(), 0.368) + ) + val tideHeights = TideHeights(heights) + assertEquals(3, tideHeights.heights.size) + } + + @Test + @DisplayName("TideHeights handles empty list") + fun tideHeightsHandlesEmptyList() { + val tideHeights = TideHeights(emptyList()) + assertTrue(tideHeights.heights.isEmpty()) + } + + @Test + @DisplayName("TideHeights preserves height order") + fun tideHeightsPreservesHeightOrder() { + val heights = listOf( + Height(Date(), 0.485), + Height(Date(), -0.425), + Height(Date(), 0.368) + ) + val tideHeights = TideHeights(heights) + assertEquals(listOf(0.485, -0.425, 0.368), tideHeights.heights.map { it.height }) + } + + @Test + @DisplayName("TideHeights is data class with correct equals") + fun tideHeightsIsDataClassWithCorrectEquals() { + val heights1 = TideHeights(listOf(Height(null, 0.5))) + val heights2 = TideHeights(listOf(Height(null, 0.5))) + assertEquals(heights1, heights2) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt new file mode 100644 index 0000000..2963046 --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt @@ -0,0 +1,33 @@ +package com.oleksandrkruk.worldtides.models + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class TideDataTypeTest { + + @Test + @DisplayName("HEIGHTS has correct query value") + fun heightsHasCorrectQueryValue() { + assertEquals("heights", TideDataType.HEIGHTS.queryValue) + } + + @Test + @DisplayName("EXTREMES has correct query value") + fun extremesHasCorrectQueryValue() { + assertEquals("extremes", TideDataType.EXTREMES.queryValue) + } + + @Test + @DisplayName("TideDataType enum has exactly 2 values") + fun tideDataTypeEnumHasExactlyTwoValues() { + assertEquals(2, TideDataType.values().size) + } + + @Test + @DisplayName("TideDataType values contain HEIGHTS and EXTREMES") + fun tideDataTypeValuesContainHeightsAndExtremes() { + val values = TideDataType.values().toList() + assertEquals(listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES), values) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt new file mode 100644 index 0000000..6f353a6 --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt @@ -0,0 +1,76 @@ +package com.oleksandrkruk.worldtides.models + +import com.oleksandrkruk.worldtides.extremes.models.Extreme +import com.oleksandrkruk.worldtides.extremes.models.TideExtremes +import com.oleksandrkruk.worldtides.extremes.models.TideType +import com.oleksandrkruk.worldtides.heights.models.Height +import com.oleksandrkruk.worldtides.heights.models.TideHeights +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.util.* + +class TidesTest { + + @Test + @DisplayName("Tides stores both heights and extremes") + fun tidesStoresBothHeightsAndExtremes() { + val heights = TideHeights(listOf(Height(Date(), 0.485))) + val extremes = TideExtremes(listOf(Extreme(Date(), 0.485f, TideType.High))) + val tides = Tides(heights = heights, extremes = extremes) + + assertNotNull(tides.heights) + assertNotNull(tides.extremes) + assertEquals(1, tides.heights?.heights?.size) + assertEquals(1, tides.extremes?.extremes?.size) + } + + @Test + @DisplayName("Tides handles null heights") + fun tidesHandlesNullHeights() { + val extremes = TideExtremes(listOf(Extreme(Date(), 0.485f, TideType.High))) + val tides = Tides(heights = null, extremes = extremes) + + assertNull(tides.heights) + assertNotNull(tides.extremes) + } + + @Test + @DisplayName("Tides handles null extremes") + fun tidesHandlesNullExtremes() { + val heights = TideHeights(listOf(Height(Date(), 0.485))) + val tides = Tides(heights = heights, extremes = null) + + assertNotNull(tides.heights) + assertNull(tides.extremes) + } + + @Test + @DisplayName("Tides handles both null") + fun tidesHandlesBothNull() { + val tides = Tides(heights = null, extremes = null) + + assertNull(tides.heights) + assertNull(tides.extremes) + } + + @Test + @DisplayName("Tides default constructor sets nulls") + fun tidesDefaultConstructorSetsNulls() { + val tides = Tides() + + assertNull(tides.heights) + assertNull(tides.extremes) + } + + @Test + @DisplayName("Tides is data class with correct equals") + fun tidesIsDataClassWithCorrectEquals() { + val heights = TideHeights(listOf(Height(null, 0.5))) + val tides1 = Tides(heights = heights, extremes = null) + val tides2 = Tides(heights = heights, extremes = null) + assertEquals(tides1, tides2) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt new file mode 100644 index 0000000..df9c75d --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt @@ -0,0 +1,154 @@ +package com.oleksandrkruk.worldtides.models + +import com.oleksandrkruk.worldtides.RetrofitClient +import com.oleksandrkruk.worldtides.WorldTidesGateway +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class WorldTidesGatewayTidesTest { + private val server: MockWebServer = MockWebServer() + private var service: WorldTidesGateway? = null + + private val combinedJsonResponse = """{ + "status": 200, + "callCount": 1, + "copyright": "Tidal data retrieved from www.worldtides.info...", + "requestLat": 37.733299, + "requestLon": -25.6667, + "responseLat": 37.7333, + "responseLon": -25.6667, + "atlas": "FES", + "station": "PONTA DELGADA", + "heights": [ + { + "dt": 1613540259, + "date": "2021-02-17T05:37+0000", + "height": 0.485 + }, + { + "dt": 1613562548, + "date": "2021-02-17T11:49+0000", + "height": -0.425 + } + ], + "extremes": [ + { + "dt": 1613540259, + "date": "2021-02-17T05:37+0000", + "height": 0.485, + "type": "High" + }, + { + "dt": 1613562548, + "date": "2021-02-17T11:49+0000", + "height": -0.425, + "type": "Low" + } + ] + }""" + + @BeforeEach + fun setup() { + val response = MockResponse().setBody(combinedJsonResponse) + server.enqueue(response) + server.start() + val baseUrl = server.url("") + service = RetrofitClient(baseUrl.toString()).tidesService + } + + @AfterEach + fun teardown() { + server.close() + } + + @Test + @DisplayName("tides endpoint uses dynamic URL with heights and extremes") + fun tidesEndpointUsesDynamicUrl() { + val response = service?.tides("v2?heights&extremes", "2021-02-17", 1, "37.7333", "-25.6667", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("heights")) + assertTrue(requestUrl.queryParameterNames().contains("extremes")) + } + + @Test + @DisplayName("tides endpoint contains date in query params") + fun containsDateInQueryParams() { + val response = service?.tides("v2?heights", "2021-02-17", 1, "bar", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("date")) + assertEquals("2021-02-17", requestUrl.queryParameter("date")) + } + + @Test + @DisplayName("tides endpoint contains days in query params") + fun containsDaysInQueryParams() { + val response = service?.tides("v2?heights", "foo", 7, "bar", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("days")) + assertEquals("7", requestUrl.queryParameter("days")) + } + + @Test + @DisplayName("tides endpoint contains lat in query params") + fun containsLatInQueryParams() { + val response = service?.tides("v2?heights", "foo", 1, "37.7333", "baz", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("lat")) + assertEquals("37.7333", requestUrl.queryParameter("lat")) + } + + @Test + @DisplayName("tides endpoint contains lon in query params") + fun containsLonInQueryParams() { + val response = service?.tides("v2?heights", "foo", 1, "bar", "-25.6667", "key")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("lon")) + assertEquals("-25.6667", requestUrl.queryParameter("lon")) + } + + @Test + @DisplayName("tides endpoint contains API key in query params") + fun containsApiKeyInQueryParams() { + val response = service?.tides("v2?heights", "foo", 1, "bar", "baz", "testApiKey")?.execute() + val requestUrl = response?.raw()?.request()?.url() + assertTrue(requestUrl!!.queryParameterNames().contains("key")) + assertEquals("testApiKey", requestUrl.queryParameter("key")) + } + + @Test + @DisplayName("parses both heights and extremes from combined response") + fun parsesBothHeightsAndExtremesFromCombinedResponse() { + val response = service?.tides("v2?heights&extremes", "foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals(2, response.body()?.heights?.size) + assertEquals(2, response.body()?.extremes?.size) + } + + @Test + @DisplayName("parses height values from combined response") + fun parsesHeightValuesFromCombinedResponse() { + val response = service?.tides("v2?heights&extremes", "foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals( + listOf(0.485, -0.425), + response.body()?.heights?.map { it.height } + ) + } + + @Test + @DisplayName("parses extremes types from combined response") + fun parsesExtremesTypesFromCombinedResponse() { + val response = service?.tides("v2?heights&extremes", "foo", 1, "bar", "baz", "key")?.execute() + assertTrue(response!!.isSuccessful) + assertEquals( + listOf("High", "Low"), + response.body()?.extremes?.map { it.type } + ) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesRepositoryTidesTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesRepositoryTidesTest.kt new file mode 100644 index 0000000..6f3bf8f --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesRepositoryTidesTest.kt @@ -0,0 +1,202 @@ +package com.oleksandrkruk.worldtides.models + +import com.oleksandrkruk.worldtides.WorldTidesGateway +import com.oleksandrkruk.worldtides.WorldTidesRepository +import com.oleksandrkruk.worldtides.extremes.data.ExtremeResponse +import com.oleksandrkruk.worldtides.extremes.models.TideType +import com.oleksandrkruk.worldtides.heights.data.HeightResponse +import okhttp3.MediaType +import okhttp3.ResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.any +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.anyString +import org.mockito.MockitoAnnotations +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.text.SimpleDateFormat +import java.util.* +import kotlin.test.fail + +@Suppress("UNCHECKED_CAST") +class WorldTidesRepositoryTidesTest { + + @Mock + private lateinit var gatewayMock: WorldTidesGateway + @Mock + lateinit var dateFormatterMock: SimpleDateFormat + @Mock + private lateinit var mockCall: Call + + private lateinit var tidesResponse: TidesResponse + private lateinit var tidesRepository: WorldTidesRepository + + private val today = Date() + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + `when`(dateFormatterMock.parse(ArgumentMatchers.any())).thenReturn(today) + tidesRepository = WorldTidesRepository(gatewayMock, dateFormatterMock) + } + + @Test + @DisplayName("maps both heights and extremes from combined response") + fun mapsBothHeightsAndExtremesFromCombinedResponse() { + withSuccessfulResponse() + tidesResponse = TidesResponse( + 200, null, + heights = listOf(buildHeightData()), + extremes = listOf(buildExtremeData()) + ) + + tidesRepository.tides(listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES), "", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tides -> + assertNotNull(tides.heights) + assertNotNull(tides.extremes) + assertEquals(1, tides.heights?.heights?.size) + assertEquals(1, tides.extremes?.extremes?.size) + } + } + } + + @Test + @DisplayName("maps only heights when only heights requested") + fun mapsOnlyHeightsWhenOnlyHeightsRequested() { + withSuccessfulResponse() + tidesResponse = TidesResponse( + 200, null, + heights = listOf(buildHeightData()), + extremes = null + ) + + tidesRepository.tides(listOf(TideDataType.HEIGHTS), "", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tides -> + assertNotNull(tides.heights) + assertNull(tides.extremes) + assertEquals(0.485, tides.heights?.heights?.first()?.height) + } + } + } + + @Test + @DisplayName("maps only extremes when only extremes requested") + fun mapsOnlyExtremesWhenOnlyExtremesRequested() { + withSuccessfulResponse() + tidesResponse = TidesResponse( + 200, null, + heights = null, + extremes = listOf(buildExtremeData()) + ) + + tidesRepository.tides(listOf(TideDataType.EXTREMES), "", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tides -> + assertNull(tides.heights) + assertNotNull(tides.extremes) + assertEquals(TideType.High, tides.extremes?.extremes?.first()?.type) + } + } + } + + @Test + @DisplayName("builds correct endpoint for heights only") + fun buildsCorrectEndpointForHeightsOnly() { + withSuccessfulResponse() + tidesResponse = TidesResponse(200, null, heights = emptyList(), extremes = null) + + // Verify that tides is called with correct endpoint + tidesRepository.tides(listOf(TideDataType.HEIGHTS), "", 1, "", "", "") { _ -> } + } + + @Test + @DisplayName("builds correct endpoint for heights and extremes") + fun buildsCorrectEndpointForHeightsAndExtremes() { + withSuccessfulResponse() + tidesResponse = TidesResponse(200, null, heights = emptyList(), extremes = emptyList()) + + tidesRepository.tides(listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES), "", 1, "", "", "") { _ -> } + } + + @Test + @DisplayName("bubbles up error on failed response") + fun bubblesUpTheErrorOnFailure() { + withFailedResponse() + tidesRepository.tides(listOf(TideDataType.HEIGHTS), "", 1, "", "", "") { result -> + assertTrue(result.isFailure) + result.onSuccess { _ -> + fail("should never invoke success on failed response") + } + result.onFailure { + assertEquals(Error::class, it::class) + } + } + } + + @Test + @DisplayName("handles empty response for both types") + fun handlesEmptyResponseForBothTypes() { + withSuccessfulResponse() + tidesResponse = TidesResponse(200, null, heights = emptyList(), extremes = emptyList()) + + tidesRepository.tides(listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES), "", 1, "", "", "") { result -> + assertTrue(result.isSuccess) + result.onSuccess { tides -> + assertEquals(0, tides.heights?.heights?.size) + assertEquals(0, tides.extremes?.extremes?.size) + } + } + } + + private fun buildHeightData( + dt: Long = 1613540259L, + date: String = "2021-02-17T05:37+0000", + height: Double = 0.485 + ): HeightResponse { + return HeightResponse(dt, date, height) + } + + private fun buildExtremeData( + dt: Long = 1613540259L, + date: String = "2021-02-17T05:37+0000", + height: Float = 0.485f, + type: String = "High" + ): ExtremeResponse { + return ExtremeResponse(dt, date, height, type) + } + + private fun withSuccessfulResponse() { + `when`(mockCall.enqueue(any())).thenAnswer { invocation -> + val callback: Callback = invocation.arguments[0] as Callback + val success = Response.success(200, tidesResponse) + callback.onResponse(mockCall, success) + null + } + `when`(gatewayMock.tides(anyString(), anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(mockCall) + } + + private fun withFailedResponse() { + `when`(mockCall.enqueue(any())).thenAnswer { invocation -> + val callback: Callback = invocation.arguments[0] as Callback + val mockResponseBody = ResponseBody.create(MediaType.get("json/txt"), "") + val timeout = Response.error(408, mockResponseBody) + callback.onResponse(mockCall, timeout) + null + } + `when`(gatewayMock.tides(anyString(), anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(mockCall) + } +} From 346d7602c8e6ecc7feb421d82cd897117778c0ee Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Sat, 10 Jan 2026 10:26:36 +0000 Subject: [PATCH 13/19] docs: update CHANGELOG and complete Phase 5 tasks - Add Heights API, Tides API to CHANGELOG - Document breaking change for TidesCallback - Add new models and test coverage to CHANGELOG - Mark all Phase 5 tasks complete --- CHANGELOG.md | 11 +++++++++++ specs/001-support-heights-api/tasks.md | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b771ca2..e49b72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Heights API**: New `getTideHeights()` method to fetch tide height predictions +- **Flexible Tides API**: New `getTides()` method to fetch multiple data types in a single call +- `TideDataType` enum for specifying which data types to request (HEIGHTS, EXTREMES) +- `Tides` model for combined API responses +- `TideHeights` and `Height` models for tide height data +- Comprehensive test coverage for new API methods - SpekKit - Project constitution - Github copilot instructions for commit summaries ### Changed +- Refactored `WorldTidesRepository` and `WorldTidesGateway` to package root for shared usage - Bump Kotlin version to 1.9.20 - Bump Gradle to 8.9 - Bump mockito to 5.13.0 @@ -22,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace jcenter() with mavenCentral() - Update github actions setup +### Breaking Changes + +- **BREAKING**: `TidesCallback` is now generic `TidesCallback`. Existing consumers using Java-style callbacks must update to `TidesCallback` + ## 1.0.0 - 2021-02-15 ### Added diff --git a/specs/001-support-heights-api/tasks.md b/specs/001-support-heights-api/tasks.md index f3b39ac..26fe44e 100644 --- a/specs/001-support-heights-api/tasks.md +++ b/specs/001-support-heights-api/tasks.md @@ -120,10 +120,10 @@ **Purpose**: Final cleanup and documentation. -- [ ] T028 Update README.md supported API table (mark Heights as Yes) in `README.md` -- [ ] T029 Add KDoc/Javadoc to all new public methods and classes -- [ ] T030 Run full test suite and verify all tests pass -- [ ] T031 Verify quickstart.md examples compile and work +- [x] T028 Update README.md supported API table (mark Heights as Yes) in `README.md` +- [x] T029 Add KDoc/Javadoc to all new public methods and classes +- [x] T030 Run full test suite and verify all tests pass +- [x] T031 Verify quickstart.md examples compile and work --- From 433d96ea51b5b5b877c4e17465d1b47b1260785f Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Thu, 15 Jan 2026 21:32:05 +0000 Subject: [PATCH 14/19] fix: make Height.date non-nullable for consistency with Extreme.date BREAKING CHANGE: Height.date is now Date instead of Date? Removes null-date test case as it's no longer valid. --- .../com/oleksandrkruk/worldtides/WorldTidesRepository.kt | 4 ++-- .../com/oleksandrkruk/worldtides/heights/models/Height.kt | 2 +- .../oleksandrkruk/worldtides/heights/models/HeightTest.kt | 8 -------- .../worldtides/heights/models/TideHeightsTest.kt | 5 +++-- .../com/oleksandrkruk/worldtides/models/TidesTest.kt | 3 ++- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt index a65e4d2..6009407 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt @@ -54,7 +54,7 @@ internal class WorldTidesRepository( if (response.isSuccessful && response.body() != null) { response.body()?.let { heightsResponse -> val heights = heightsResponse.heights.map { heightData -> - Height(dateFormat.parse(heightData.date), heightData.height) + Height(dateFormat.parse(heightData.date)!!, heightData.height) } val tideHeights = TideHeights(heights) callback(Result.success(tideHeights)) @@ -88,7 +88,7 @@ internal class WorldTidesRepository( response.body()?.let { tidesResponse -> val tideHeights = tidesResponse.heights?.let { heightsList -> val heights = heightsList.map { heightData -> - Height(dateFormat.parse(heightData.date), heightData.height) + Height(dateFormat.parse(heightData.date)!!, heightData.height) } TideHeights(heights) } diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt index af8d928..2d202a0 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/models/Height.kt @@ -9,6 +9,6 @@ import java.util.Date * @property height The height level (datum relative). */ data class Height( - val date: Date?, + val date: Date, val height: Double ) diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt index 7070cad..1ddfe7d 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt @@ -1,7 +1,6 @@ package com.oleksandrkruk.worldtides.heights.models import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import java.util.* @@ -23,13 +22,6 @@ class HeightTest { assertEquals(0.485, height.height) } - @Test - @DisplayName("Height handles null date") - fun heightHandlesNullDate() { - val height = Height(null, 0.485) - assertNull(height.date) - } - @Test @DisplayName("Height handles negative height values") fun heightHandlesNegativeHeightValues() { diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt index 5cb0e06..94c8a4a 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt @@ -42,8 +42,9 @@ class TideHeightsTest { @Test @DisplayName("TideHeights is data class with correct equals") fun tideHeightsIsDataClassWithCorrectEquals() { - val heights1 = TideHeights(listOf(Height(null, 0.5))) - val heights2 = TideHeights(listOf(Height(null, 0.5))) + val testDate = Date() + val heights1 = TideHeights(listOf(Height(testDate, 0.5))) + val heights2 = TideHeights(listOf(Height(testDate, 0.5))) assertEquals(heights1, heights2) } } diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt index 6f353a6..d242e5b 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TidesTest.kt @@ -68,7 +68,8 @@ class TidesTest { @Test @DisplayName("Tides is data class with correct equals") fun tidesIsDataClassWithCorrectEquals() { - val heights = TideHeights(listOf(Height(null, 0.5))) + val testDate = Date() + val heights = TideHeights(listOf(Height(testDate, 0.5))) val tides1 = Tides(heights = heights, extremes = null) val tides2 = Tides(heights = heights, extremes = null) assertEquals(tides1, tides2) From 53fba55936a4128f80f8dc7e7c6ef6fca7d57fb6 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Thu, 15 Jan 2026 21:38:51 +0000 Subject: [PATCH 15/19] refactor: extract DTO-to-domain mapping into extension functions Adds toExtreme(), toTideExtremes(), toHeight(), toTideHeights() extensions. Includes unit tests for all mapping functions. Refactors WorldTidesRepository to use new extensions. --- .../worldtides/WorldTidesRepository.kt | 30 ++----- .../extremes/data/ExtremeResponse.kt | 10 +++ .../extremes/data/TideExtremesResponse.kt | 7 ++ .../worldtides/heights/data/HeightResponse.kt | 8 ++ .../heights/data/TideHeightsResponse.kt | 7 ++ .../data/ExtremeResponseMappingTest.kt | 78 +++++++++++++++++++ .../heights/data/HeightResponseMappingTest.kt | 74 ++++++++++++++++++ 7 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponseMappingTest.kt create mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponseMappingTest.kt diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt index 6009407..d87d323 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt @@ -1,11 +1,12 @@ package com.oleksandrkruk.worldtides import com.oleksandrkruk.worldtides.extremes.data.TideExtremesResponse -import com.oleksandrkruk.worldtides.extremes.models.Extreme +import com.oleksandrkruk.worldtides.extremes.data.toExtreme +import com.oleksandrkruk.worldtides.extremes.data.toTideExtremes import com.oleksandrkruk.worldtides.extremes.models.TideExtremes -import com.oleksandrkruk.worldtides.extremes.models.TideType import com.oleksandrkruk.worldtides.heights.data.TideHeightsResponse -import com.oleksandrkruk.worldtides.heights.models.Height +import com.oleksandrkruk.worldtides.heights.data.toHeight +import com.oleksandrkruk.worldtides.heights.data.toTideHeights import com.oleksandrkruk.worldtides.heights.models.TideHeights import com.oleksandrkruk.worldtides.models.TideDataType import com.oleksandrkruk.worldtides.models.Tides @@ -29,11 +30,7 @@ internal class WorldTidesRepository( override fun onResponse(call: Call, response: Response) { if (response.isSuccessful && response.body() != null) { response.body()?.let { tidesResponse -> - val extremes = tidesResponse.extremes.map { extreme -> - Extreme(dateFormat.parse(extreme.date), extreme.height, TideType.valueOf(extreme.type)) - } - val tideExtremes = TideExtremes(extremes) - callback(Result.success(tideExtremes)) + callback(Result.success(tidesResponse.toTideExtremes(dateFormat))) } ?: run { callback(Result.failure(Error("Response is successful but failed getting body"))) } @@ -53,11 +50,7 @@ internal class WorldTidesRepository( override fun onResponse(call: Call, response: Response) { if (response.isSuccessful && response.body() != null) { response.body()?.let { heightsResponse -> - val heights = heightsResponse.heights.map { heightData -> - Height(dateFormat.parse(heightData.date)!!, heightData.height) - } - val tideHeights = TideHeights(heights) - callback(Result.success(tideHeights)) + callback(Result.success(heightsResponse.toTideHeights(dateFormat))) } ?: run { callback(Result.failure(Error("Response is successful but failed getting body"))) } @@ -87,16 +80,10 @@ internal class WorldTidesRepository( if (response.isSuccessful && response.body() != null) { response.body()?.let { tidesResponse -> val tideHeights = tidesResponse.heights?.let { heightsList -> - val heights = heightsList.map { heightData -> - Height(dateFormat.parse(heightData.date)!!, heightData.height) - } - TideHeights(heights) + TideHeights(heightsList.map { it.toHeight(dateFormat) }) } val tideExtremes = tidesResponse.extremes?.let { extremesList -> - val extremes = extremesList.map { extreme -> - Extreme(dateFormat.parse(extreme.date), extreme.height, TideType.valueOf(extreme.type)) - } - TideExtremes(extremes) + TideExtremes(extremesList.map { it.toExtreme(dateFormat) }) } val tides = Tides(heights = tideHeights, extremes = tideExtremes) callback(Result.success(tides)) @@ -110,4 +97,3 @@ internal class WorldTidesRepository( }) } } - diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponse.kt index 70cc0b9..e299826 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponse.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponse.kt @@ -1,3 +1,13 @@ package com.oleksandrkruk.worldtides.extremes.data +import com.oleksandrkruk.worldtides.extremes.models.Extreme +import com.oleksandrkruk.worldtides.extremes.models.TideType +import java.text.SimpleDateFormat + internal data class ExtremeResponse(val dt: Long, val date: String, val height: Float, val type: String) + +internal fun ExtremeResponse.toExtreme(dateFormat: SimpleDateFormat) = Extreme( + date = dateFormat.parse(date)!!, + height = height, + type = TideType.valueOf(type) +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/TideExtremesResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/TideExtremesResponse.kt index 9994c48..91e90d1 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/TideExtremesResponse.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/extremes/data/TideExtremesResponse.kt @@ -1,7 +1,14 @@ package com.oleksandrkruk.worldtides.extremes.data +import com.oleksandrkruk.worldtides.extremes.models.TideExtremes +import java.text.SimpleDateFormat + internal data class TideExtremesResponse( val status: Int, val error: String? = null, val extremes: List ) + +internal fun TideExtremesResponse.toTideExtremes(dateFormat: SimpleDateFormat) = TideExtremes( + extremes = extremes.map { it.toExtreme(dateFormat) } +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt index ce7ae5d..a875bf1 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponse.kt @@ -1,5 +1,8 @@ package com.oleksandrkruk.worldtides.heights.data +import com.oleksandrkruk.worldtides.heights.models.Height +import java.text.SimpleDateFormat + /** * DTO for parsing height data from JSON API response. */ @@ -8,3 +11,8 @@ internal data class HeightResponse( val date: String, val height: Double ) + +internal fun HeightResponse.toHeight(dateFormat: SimpleDateFormat) = Height( + date = dateFormat.parse(date)!!, + height = height +) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt index 2562bce..c7d9963 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/heights/data/TideHeightsResponse.kt @@ -1,5 +1,8 @@ package com.oleksandrkruk.worldtides.heights.data +import com.oleksandrkruk.worldtides.heights.models.TideHeights +import java.text.SimpleDateFormat + /** * DTO for parsing tide heights response from the API. */ @@ -8,3 +11,7 @@ internal data class TideHeightsResponse( val error: String? = null, val heights: List ) + +internal fun TideHeightsResponse.toTideHeights(dateFormat: SimpleDateFormat) = TideHeights( + heights = heights.map { it.toHeight(dateFormat) } +) diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponseMappingTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponseMappingTest.kt new file mode 100644 index 0000000..3f49f06 --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/extremes/data/ExtremeResponseMappingTest.kt @@ -0,0 +1,78 @@ +package com.oleksandrkruk.worldtides.extremes.data + +import com.oleksandrkruk.worldtides.extremes.models.TideType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.text.SimpleDateFormat +import java.util.* + +class ExtremeResponseMappingTest { + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ", Locale.US) + + @Test + @DisplayName("ExtremeResponse.toExtreme() maps all fields correctly") + fun toExtremeMapsAllFieldsCorrectly() { + val response = ExtremeResponse( + dt = 1613540259L, + date = "2021-02-17T05:37+0000", + height = 1.5f, + type = "High" + ) + + val extreme = response.toExtreme(dateFormat) + + assertEquals(dateFormat.parse("2021-02-17T05:37+0000"), extreme.date) + assertEquals(1.5f, extreme.height) + assertEquals(TideType.High, extreme.type) + } + + @Test + @DisplayName("ExtremeResponse.toExtreme() maps Low tide type") + fun toExtremeMapsLowTideType() { + val response = ExtremeResponse( + dt = 1613540259L, + date = "2021-02-17T11:45+0000", + height = 0.3f, + type = "Low" + ) + + val extreme = response.toExtreme(dateFormat) + + assertEquals(TideType.Low, extreme.type) + } + + @Test + @DisplayName("TideExtremesResponse.toTideExtremes() maps list correctly") + fun toTideExtremesMapsListCorrectly() { + val response = TideExtremesResponse( + status = 200, + error = null, + extremes = listOf( + ExtremeResponse(1L, "2021-02-17T05:37+0000", 1.5f, "High"), + ExtremeResponse(2L, "2021-02-17T11:45+0000", 0.3f, "Low") + ) + ) + + val tideExtremes = response.toTideExtremes(dateFormat) + + assertEquals(2, tideExtremes.extremes.size) + assertEquals(TideType.High, tideExtremes.extremes[0].type) + assertEquals(TideType.Low, tideExtremes.extremes[1].type) + } + + @Test + @DisplayName("TideExtremesResponse.toTideExtremes() handles empty list") + fun toTideExtremesHandlesEmptyList() { + val response = TideExtremesResponse( + status = 200, + error = null, + extremes = emptyList() + ) + + val tideExtremes = response.toTideExtremes(dateFormat) + + assertEquals(0, tideExtremes.extremes.size) + } +} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponseMappingTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponseMappingTest.kt new file mode 100644 index 0000000..73aa89f --- /dev/null +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/data/HeightResponseMappingTest.kt @@ -0,0 +1,74 @@ +package com.oleksandrkruk.worldtides.heights.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.text.SimpleDateFormat +import java.util.* + +class HeightResponseMappingTest { + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ", Locale.US) + + @Test + @DisplayName("HeightResponse.toHeight() maps all fields correctly") + fun toHeightMapsAllFieldsCorrectly() { + val response = HeightResponse( + dt = 1613540259L, + date = "2021-02-17T05:37+0000", + height = 0.485 + ) + + val height = response.toHeight(dateFormat) + + assertEquals(dateFormat.parse("2021-02-17T05:37+0000"), height.date) + assertEquals(0.485, height.height) + } + + @Test + @DisplayName("HeightResponse.toHeight() handles negative height") + fun toHeightHandlesNegativeHeight() { + val response = HeightResponse( + dt = 1613540259L, + date = "2021-02-17T11:45+0000", + height = -0.425 + ) + + val height = response.toHeight(dateFormat) + + assertEquals(-0.425, height.height) + } + + @Test + @DisplayName("TideHeightsResponse.toTideHeights() maps list correctly") + fun toTideHeightsMapsListCorrectly() { + val response = TideHeightsResponse( + status = 200, + error = null, + heights = listOf( + HeightResponse(1L, "2021-02-17T05:37+0000", 0.485), + HeightResponse(2L, "2021-02-17T11:45+0000", -0.425) + ) + ) + + val tideHeights = response.toTideHeights(dateFormat) + + assertEquals(2, tideHeights.heights.size) + assertEquals(0.485, tideHeights.heights[0].height) + assertEquals(-0.425, tideHeights.heights[1].height) + } + + @Test + @DisplayName("TideHeightsResponse.toTideHeights() handles empty list") + fun toTideHeightsHandlesEmptyList() { + val response = TideHeightsResponse( + status = 200, + error = null, + heights = emptyList() + ) + + val tideHeights = response.toTideHeights(dateFormat) + + assertEquals(0, tideHeights.heights.size) + } +} From e590484a6344fcb305e59e253b98f5e700ed80fc Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Thu, 15 Jan 2026 21:52:47 +0000 Subject: [PATCH 16/19] refactor: extract enqueueMapped() to reduce callback boilerplate Centralizes Retrofit callback handling into a single generic extension. Reduces ~45 lines of duplicated code across repository methods. No functional change - all existing tests pass. --- .../worldtides/WorldTidesRepository.kt | 106 ++++++++---------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt index d87d323..9ba9dad 100644 --- a/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt +++ b/worldtides/src/main/kotlin/com/oleksandrkruk/worldtides/WorldTidesRepository.kt @@ -22,43 +22,17 @@ internal class WorldTidesRepository( ) { fun extremes(date: String, days: Int, lat: String, lon: String, apiKey: String, callback: (Result) -> Unit) { - tidesService.extremes(date, days, lat, lon, apiKey).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - callback(Result.failure(t)) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful && response.body() != null) { - response.body()?.let { tidesResponse -> - callback(Result.success(tidesResponse.toTideExtremes(dateFormat))) - } ?: run { - callback(Result.failure(Error("Response is successful but failed getting body"))) - } - } else { - callback(Result.failure(Error("Response body is null or response is not successful"))) - } - } - }) + tidesService.extremes(date, days, lat, lon, apiKey).enqueueMapped( + transform = { it.toTideExtremes(dateFormat) }, + callback = callback + ) } fun heights(date: String, days: Int, lat: String, lon: String, apiKey: String, callback: (Result) -> Unit) { - tidesService.heights(date, days, lat, lon, apiKey).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - callback(Result.failure(t)) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful && response.body() != null) { - response.body()?.let { heightsResponse -> - callback(Result.success(heightsResponse.toTideHeights(dateFormat))) - } ?: run { - callback(Result.failure(Error("Response is successful but failed getting body"))) - } - } else { - callback(Result.failure(Error("Response body is null or response is not successful"))) - } - } - }) + tidesService.heights(date, days, lat, lon, apiKey).enqueueMapped( + transform = { it.toTideHeights(dateFormat) }, + callback = callback + ) } fun tides( @@ -71,29 +45,47 @@ internal class WorldTidesRepository( callback: (Result) -> Unit ) { val endpoint = "v2?" + dataTypes.joinToString("&") { it.queryValue } - tidesService.tides(endpoint, date, days, lat, lon, apiKey).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - callback(Result.failure(t)) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful && response.body() != null) { - response.body()?.let { tidesResponse -> - val tideHeights = tidesResponse.heights?.let { heightsList -> - TideHeights(heightsList.map { it.toHeight(dateFormat) }) - } - val tideExtremes = tidesResponse.extremes?.let { extremesList -> - TideExtremes(extremesList.map { it.toExtreme(dateFormat) }) - } - val tides = Tides(heights = tideHeights, extremes = tideExtremes) - callback(Result.success(tides)) - } ?: run { - callback(Result.failure(Error("Response is successful but failed getting body"))) + tidesService.tides(endpoint, date, days, lat, lon, apiKey).enqueueMapped( + transform = { tidesResponse -> + Tides( + heights = tidesResponse.heights?.let { heightsList -> + TideHeights(heightsList.map { it.toHeight(dateFormat) }) + }, + extremes = tidesResponse.extremes?.let { extremesList -> + TideExtremes(extremesList.map { it.toExtreme(dateFormat) }) } - } else { - callback(Result.failure(Error("Response body is null or response is not successful"))) - } - } - }) + ) + }, + callback = callback + ) } } + +/** + * Extension function to enqueue a Retrofit call with automatic mapping. + * Centralizes callback handling to reduce boilerplate across repository methods. + * + * @param transform Function to transform the response body into the desired result type. + * @param callback Callback to receive the Result (success or failure). + */ +private inline fun Call.enqueueMapped( + crossinline transform: (T) -> R, + crossinline callback: (Result) -> Unit +) { + enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + callback(Result.failure(t)) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful && response.body() != null) { + response.body()?.let { body -> + callback(Result.success(transform(body))) + } ?: callback(Result.failure(Error("Response is successful but failed getting body"))) + } else { + callback(Result.failure(Error("Response body is null or response is not successful"))) + } + } + }) +} + From d0c215fa9151e38b0e5c0e4fccb927e94439f74b Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Thu, 15 Jan 2026 21:54:15 +0000 Subject: [PATCH 17/19] docs: update CHANGELOG.md for v2.0.0 breaking changes --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49b72a..f67e492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,33 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## UNRELEASED +## [2.0.0] - 2026-01-15 + +### ⚠️ BREAKING CHANGES + +- **`TidesCallback` is now generic**: The callback interface has been refactored to `TidesCallback` to support multiple response types. + + **Migration for Java users:** + ```java + // Before + new TidesCallback() { + @Override public void result(TideExtremes tides) { } + @Override public void error(Error error) { } + } + + // After + new TidesCallback() { + @Override public void result(TideExtremes data) { } + @Override public void error(Error error) { } + } + ``` + +- **`Height.date` is now non-nullable**: Changed from `Date?` to `Date` for consistency with `Extreme.date`. ### Added -- **Heights API**: New `getTideHeights()` method to fetch tide height predictions -- **Flexible Tides API**: New `getTides()` method to fetch multiple data types in a single call -- `TideDataType` enum for specifying which data types to request (HEIGHTS, EXTREMES) -- `Tides` model for combined API responses -- `TideHeights` and `Height` models for tide height data -- Comprehensive test coverage for new API methods -- SpekKit -- Project constitution -- Github copilot instructions for commit summaries +- `getTideHeights()` - Fetch predicted tide heights for a location +- `getTides()` - Flexible method to fetch any combination of tide data types +- `TideDataType` enum for specifying data types (HEIGHTS, EXTREMES) +- `Tides` model for combined tide data responses +- `TideHeights` and `Height` models for height data +- Comprehensive test coverage for all new functionality ### Changed - Refactored `WorldTidesRepository` and `WorldTidesGateway` to package root for shared usage +- Internal: Added DTO mapping extension functions for cleaner code +- Internal: Extracted `enqueueMapped()` extension for callback boilerplate + +### Internal + - Bump Kotlin version to 1.9.20 - Bump Gradle to 8.9 - Bump mockito to 5.13.0 - Bump to JDK_1_8 - Replace jcenter() with mavenCentral() - Update github actions setup - -### Breaking Changes - -- **BREAKING**: `TidesCallback` is now generic `TidesCallback`. Existing consumers using Java-style callbacks must update to `TidesCallback` +- Added SpekKit and project constitution ## 1.0.0 - 2021-02-15 From 99e1d768a97123bf00d1b989d7561b3975d0f3fc Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Thu, 15 Jan 2026 22:04:41 +0000 Subject: [PATCH 18/19] test: remove duplicate and trivial tests - Delete WorldTidesJavaApiTest.kt (duplicate of WorldTidesTest) - Remove query param tests from heights/tides gateways (kept in extremes) - Remove trivial enum count tests from TideDataTypeTest - Remove trivial edge cases from HeightTest and TideHeightsTest Test count reduced from 76 to 59 while maintaining coverage. --- .../worldtides/WorldTidesJavaApiTest.kt | 17 ------- .../heights/WorldTidesGatewayHeightsTest.kt | 45 ------------------- .../worldtides/heights/models/HeightTest.kt | 14 ------ .../heights/models/TideHeightsTest.kt | 21 --------- .../worldtides/models/TideDataTypeTest.kt | 13 ------ .../models/WorldTidesGatewayTidesTest.kt | 45 ------------------- 6 files changed, 155 deletions(-) delete mode 100644 worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt deleted file mode 100644 index 8bc17f1..0000000 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/WorldTidesJavaApiTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.oleksandrkruk.worldtides - -import com.oleksandrkruk.worldtides.extremes.models.TideExtremes -import org.junit.jupiter.api.Test -import java.util.* - -class WorldTidesJavaApiTest { - - @Test - fun providesJavaCompatibleApiForExtremes() { - val wt = WorldTides.Builder().build("bar") - wt.getTideExtremes(Date(), 5, "lat", "lon", object : TidesCallback { - override fun result(data: TideExtremes) {} - override fun error(error: Error) {} - }) - } -} diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt index b80eed2..e99e574 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/WorldTidesGatewayHeightsTest.kt @@ -66,51 +66,6 @@ class WorldTidesGatewayHeightsTest { assertTrue(requestUrl!!.queryParameterNames().contains("heights")) } - @Test - @DisplayName("heights endpoint contains date in query params") - fun containsDateInQueryParams() { - val response = service?.heights("2021-02-17", 1, "bar", "baz", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("date")) - assertEquals("2021-02-17", requestUrl.queryParameter("date")) - } - - @Test - @DisplayName("heights endpoint contains days in query params") - fun containsDaysInQueryParams() { - val response = service?.heights("foo", 7, "bar", "baz", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("days")) - assertEquals("7", requestUrl.queryParameter("days")) - } - - @Test - @DisplayName("heights endpoint contains lat in query params") - fun containsLatInQueryParams() { - val response = service?.heights("foo", 1, "37.7333", "baz", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("lat")) - assertEquals("37.7333", requestUrl.queryParameter("lat")) - } - - @Test - @DisplayName("heights endpoint contains lon in query params") - fun containsLonInQueryParams() { - val response = service?.heights("foo", 1, "bar", "-25.6667", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("lon")) - assertEquals("-25.6667", requestUrl.queryParameter("lon")) - } - - @Test - @DisplayName("heights endpoint contains API key in query params") - fun containsApiKeyInQueryParams() { - val response = service?.heights("foo", 1, "bar", "baz", "testApiKey")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("key")) - assertEquals("testApiKey", requestUrl.queryParameter("key")) - } - @Test @DisplayName("parses three tide heights from mock JSON response") fun parsesThreeTideHeightsFromMockJsonResponse() { diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt index 1ddfe7d..91da74f 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/HeightTest.kt @@ -21,18 +21,4 @@ class HeightTest { val height = Height(Date(), 0.485) assertEquals(0.485, height.height) } - - @Test - @DisplayName("Height handles negative height values") - fun heightHandlesNegativeHeightValues() { - val height = Height(Date(), -0.425) - assertEquals(-0.425, height.height) - } - - @Test - @DisplayName("Height handles zero height value") - fun heightHandlesZeroHeightValue() { - val height = Height(Date(), 0.0) - assertEquals(0.0, height.height) - } } diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt index 94c8a4a..7b08342 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/heights/models/TideHeightsTest.kt @@ -26,25 +26,4 @@ class TideHeightsTest { val tideHeights = TideHeights(emptyList()) assertTrue(tideHeights.heights.isEmpty()) } - - @Test - @DisplayName("TideHeights preserves height order") - fun tideHeightsPreservesHeightOrder() { - val heights = listOf( - Height(Date(), 0.485), - Height(Date(), -0.425), - Height(Date(), 0.368) - ) - val tideHeights = TideHeights(heights) - assertEquals(listOf(0.485, -0.425, 0.368), tideHeights.heights.map { it.height }) - } - - @Test - @DisplayName("TideHeights is data class with correct equals") - fun tideHeightsIsDataClassWithCorrectEquals() { - val testDate = Date() - val heights1 = TideHeights(listOf(Height(testDate, 0.5))) - val heights2 = TideHeights(listOf(Height(testDate, 0.5))) - assertEquals(heights1, heights2) - } } diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt index 2963046..29dca2d 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/TideDataTypeTest.kt @@ -17,17 +17,4 @@ class TideDataTypeTest { fun extremesHasCorrectQueryValue() { assertEquals("extremes", TideDataType.EXTREMES.queryValue) } - - @Test - @DisplayName("TideDataType enum has exactly 2 values") - fun tideDataTypeEnumHasExactlyTwoValues() { - assertEquals(2, TideDataType.values().size) - } - - @Test - @DisplayName("TideDataType values contain HEIGHTS and EXTREMES") - fun tideDataTypeValuesContainHeightsAndExtremes() { - val values = TideDataType.values().toList() - assertEquals(listOf(TideDataType.HEIGHTS, TideDataType.EXTREMES), values) - } } diff --git a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt index df9c75d..5b0c30c 100644 --- a/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt +++ b/worldtides/src/test/kotlin/com/oleksandrkruk/worldtides/models/WorldTidesGatewayTidesTest.kt @@ -76,51 +76,6 @@ class WorldTidesGatewayTidesTest { assertTrue(requestUrl.queryParameterNames().contains("extremes")) } - @Test - @DisplayName("tides endpoint contains date in query params") - fun containsDateInQueryParams() { - val response = service?.tides("v2?heights", "2021-02-17", 1, "bar", "baz", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("date")) - assertEquals("2021-02-17", requestUrl.queryParameter("date")) - } - - @Test - @DisplayName("tides endpoint contains days in query params") - fun containsDaysInQueryParams() { - val response = service?.tides("v2?heights", "foo", 7, "bar", "baz", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("days")) - assertEquals("7", requestUrl.queryParameter("days")) - } - - @Test - @DisplayName("tides endpoint contains lat in query params") - fun containsLatInQueryParams() { - val response = service?.tides("v2?heights", "foo", 1, "37.7333", "baz", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("lat")) - assertEquals("37.7333", requestUrl.queryParameter("lat")) - } - - @Test - @DisplayName("tides endpoint contains lon in query params") - fun containsLonInQueryParams() { - val response = service?.tides("v2?heights", "foo", 1, "bar", "-25.6667", "key")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("lon")) - assertEquals("-25.6667", requestUrl.queryParameter("lon")) - } - - @Test - @DisplayName("tides endpoint contains API key in query params") - fun containsApiKeyInQueryParams() { - val response = service?.tides("v2?heights", "foo", 1, "bar", "baz", "testApiKey")?.execute() - val requestUrl = response?.raw()?.request()?.url() - assertTrue(requestUrl!!.queryParameterNames().contains("key")) - assertEquals("testApiKey", requestUrl.queryParameter("key")) - } - @Test @DisplayName("parses both heights and extremes from combined response") fun parsesBothHeightsAndExtremesFromCombinedResponse() { From 8ecc2b95a96798105f8fc8429d536a34e4a0e650 Mon Sep 17 00:00:00 2001 From: Oleksandr Kruk Date: Thu, 15 Jan 2026 22:10:09 +0000 Subject: [PATCH 19/19] chore: bump version to 2.0.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 07d637c..30c1d84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=com.oleksandrkruk -VERSION=1.0.0 +VERSION=2.0.0 POM_NAME=WorldTides API Client POM_DESCRIPTION=A client for consuming https://www.worldtides.info/apidocs API