diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f91f646
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat text eol=crlf
+
+# Binary files should be left untouched
+*.jar binary
+
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..34f2838
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @robert-mitchell-iii @masato-noda @akira-kawakatsu
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..f5e1e73
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,24 @@
+## Issue
+
+https://github.com/line/geojson-kt/issues
+
+## Summary
+
+- **What** does this PR do?
+- **Why** is it being opened?
+
+## Changes
+
+- **How** was this PR implemented?
+- Please list major changes/additions.
+
+## Notes / References
+
+- Any relevant notes, materials, etc. (optional)
+
+## Checklist
+
+- [ ] Link to issue specified
+- [ ] Implementation satisfies the stated objective(s)
+- [ ] Test(s) added as appropriate
+- [ ] Documentation created/updated
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..c847b28
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,56 @@
+name: Run tests and code checks
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ # Required by mikepenz/action-junit-report
+ checks: write
+ # Required by actions/checkout
+ contents: read
+ # Required by mi-kas/kover-report
+ pull-requests: write
+
+jobs:
+ test:
+ name: Test
+ runs-on:
+ - ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - uses: actions/setup-java@v3
+ with:
+ distribution: "temurin"
+ java-version: "17"
+ cache: "gradle"
+
+ - name: Run test
+ run: ./gradlew check --no-daemon
+
+ - name: Publish test reports
+ uses: mikepenz/action-junit-report@v3
+ if: always()
+ with:
+ report_paths: |
+ **/build/test-results/test/TEST-*.xml
+
+ - name: Create coverage report
+ if: ${{ github.event.pull_request }}
+ run: ./gradlew koverXmlReport
+
+ - name: Post coverage report as comment
+ if: ${{ github.event.pull_request }}
+ uses: mi-kas/kover-report@v1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ path: |
+ ${{ github.workspace }}/build/reports/kover/report.xml
+ title: Code Coverage
+ update-comment: true
+ min-coverage-overall: 80
+ min-coverage-changed-files: 80
+ coverage-counter-type: LINE
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4821f35
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# Ignore Gradle project-specific cache directory
+.gradle
+
+# Ignore Gradle build output directory
+build
+
+.DS_Store
+
+# IntelliJ
+## User-specific settings
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+## Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+.idea/misc.xml
+
+# Module information is generated from Gradle on import
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839-How-to-manage-projects-under-Version-Control-Systems
+
+.idea/modules.xml
+.idea/modules/
+.idea/libraries
diff --git a/.gitkeep b/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/detekt.xml b/.idea/detekt.xml
new file mode 100644
index 0000000..ee7289c
--- /dev/null
+++ b/.idea/detekt.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..1c3eae8
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 0000000..fdc392f
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..6d0ee1c
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..89ff13b
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[dl_oss_dev@linecorp.com](mailto:dl_oss_dev@linecorp.com).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..7f9b792
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,36 @@
+# Contributing
+
+First of all, thank you for considering contributing to `geojson-kt`!
+
+Before contributing, please review and agree to our [code of conduct](./CODE_OF_CONDUCT.md), if you have not already done so.
+
+## How Can I Contribute?
+
+You can contribute in a number of ways, including:
+
+- Finding and reporting bugs
+- Implementing missing functionality
+- Improving the documentation
+- etc.
+
+Every little bit helps!
+
+## Coding Conventions
+
+As general rule, we try to adhere to [Kotlin's coding conventions](https://kotlinlang.org/docs/coding-conventions.html)
+as much as possible.
+
+[Detekt](https://detekt.dev/) is used to enforce basic style rules and ensure that the code meets some basic criteria to
+keep it clean and easy-to-use.
+
+## Pull Requests
+
+Please use clear, concise titles for pull requests.
+
+PR titles and individual commit messages should adhere to the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format.
+
+- PRs will be squashed and merged, so it is important that PR titles and commit messages are in the same format.
+
+## Questions
+
+If you have any questions, please open an issue we will get back to you as quickly as possible.
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6d16549
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2024 LY Corporation
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b3a73cc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,71 @@
+# geojson-kt
+
+`geojson-kt` is a native Kotlin library for working with GeoJSON data that aims to fully support [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946).
+
+In addition, it includes functions for some common spatial calculations, e.g., calculating the centroid of a polygon.
+
+## Features
+
+- Almost full RFC 7946 support (see table below)
+ + All geometry types have been implemented.
+ + Validation is incomplete in some cases (e.g., when geometry crosses the antimeridian)
+- Serialization of all GeoJSON types via [Jackson](https://github.com/FasterXML/jackson).
+- Common spatial calculations:
+ + Polygon centroid calculation
+ + PIP (point-in-polygon) check
+ + Determining if two polygons intersect
+
+
+### RFC 7946 Support Matrix
+
+| RFC Section | Type / Feature | Supported? | Notes |
+|-------------|-----------------------------------------------------------|------------|-----------------------------------------------------------------------------------------|
+| 3.1 | Geometry Object | ✅ | Abstract Geometry class is provided. |
+| 3.1.1 | Position | ✅ | |
+| 3.1.2 | Point | ✅ | |
+| 3.1.3 | MultiPoint | ✅ | |
+| 3.1.4 | LineString | ✅ | |
+| 3.1.5 | MultiLineString | ✅ | |
+| 3.1.6 | Polygon | ✅ | |
+| 3.1.7 | MultiPolygon | ✅ | |
+| 3.1.8 | GeometryCollection | ✅ | |
+| 3.1.9 | Antimeridian Cutting | ❌ | Support should be added for all geometry types (not required). |
+| 3.1.10 | Uncertainty and Precision | ✅ | No assumptions about the certainty of coordinate positions are made based on precision. |
+| 3.2 | Feature Object | ✅ | |
+| 3.3 | Feature Collection | ✅ | |
+| 4 | Coordinate Reference System | ✅ | All coordinates are assumed to be in WGS 84 format. |
+| 5 | Bounding Box | ❗️ | Can be automatically calculated from geometry. Point-order validation is missing. |
+| 5.1 | The Connecting Lines | ✅ | |
+| 5.2 | The Antimeridian | ❌ | Validation needs to be added. |
+| 5.3 | The Poles | ❌ | Validation needs to be added. |
+| 5 | Bounding Box | ✅ | Can be automatically calculated from geometry. |
+| 6 | Extending GeoJSON | ✅ | |
+| 6.1 | Extending GeoJSON | ✅ | Additional properties are supported for Features only. |
+| 7 | GeoJSON Types Are Not Extensible | ✅ | |
+| 7.1 | Semantics of GeoJSON Members and Types Are Not Changeable | ✅ | |
+
+## Contributing
+
+To get started, please take a look at [CONTRIBUTING.md](./CONTRIBUTING.md).
+
+We ask that all contributors review and agree to adhere to our code of conduct prior to contributing.
+
+- Please see [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for details.
+
+## License
+
+```
+Copyright 2024 LY Corporation
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..20def6f
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,63 @@
+/*
+ * This file was generated by the Gradle 'init' task.
+ *
+ * This generated file contains a sample Kotlin library project to get you started.
+ * For more details on building Java & JVM projects, please refer to
+ * https://docs.gradle.org/8.10/userguide/building_java_projects.html in the Gradle documentation.
+ */
+
+plugins {
+ // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin.
+ alias(libs.plugins.kotlin.jvm)
+
+ // Apply the java-library plugin for API and implementation separation.
+ `java-library`
+
+ id("io.gitlab.arturbosch.detekt") version("1.23.6")
+
+ id("org.jetbrains.kotlinx.kover") version "0.9.0-RC"
+}
+
+repositories {
+ // Use Maven Central for resolving dependencies.
+ mavenCentral()
+}
+
+dependencies {
+ // Use JUnit Jupiter for testing.
+ testImplementation(libs.junit.jupiter)
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+
+ // This dependency is exported to consumers, that is to say found on their compile classpath.
+ api(libs.commons.math3)
+
+ // This dependency is used internally, and not exposed to consumers on their own compile classpath.
+ implementation(libs.guava)
+
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2")
+ detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.6")
+
+ testImplementation("io.kotest:kotest-property-jvm:5.9.1")
+ testImplementation("io.kotest:kotest-assertions-core-jvm:5.9.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+}
+
+// Apply a specific Java toolchain to ease working on different environments.
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+tasks.named("test") {
+ // Use JUnit Platform for unit tests.
+ useJUnitPlatform()
+}
+
+detekt{
+ config.setFrom("${rootProject.projectDir}/config/detekt/detekt.yml")
+ buildUponDefaultConfig = true
+ autoCorrect = true
+}
\ No newline at end of file
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
new file mode 100644
index 0000000..918f2d4
--- /dev/null
+++ b/config/detekt/detekt.yml
@@ -0,0 +1,88 @@
+config:
+ validation: true
+ warningsAsErrors: false
+ excludes: ""
+
+# Ref: https://detekt.dev/docs/rules/comments
+comments:
+ UndocumentedPublicClass:
+ active: true
+ UndocumentedPublicFunction:
+ active: true
+ UndocumentedPublicProperty:
+ active: true
+
+# Ref: https://detekt.dev/docs/rules/complexity
+complexity:
+ LargeClass:
+ active: true
+ threshold: 600
+ LongParameterList:
+ active: true
+ constructorThreshold: 6
+ functionThreshold: 6
+ LongMethod:
+ active: true
+ threshold: 60
+ excludes:
+ - '**/test/**'
+ CyclomaticComplexMethod:
+ active: true
+ threshold: 15
+ NestedBlockDepth:
+ active: true
+ threshold: 4
+ TooManyFunctions:
+ active: true
+ thresholdInFiles: 11
+ thresholdInClasses: 11
+ thresholdInInterfaces: 11
+ thresholdInObjects: 11
+ thresholdInEnums: 11
+
+# Ref: https://detekt.dev/docs/rules/exceptions
+exceptions:
+ TooGenericExceptionCaught:
+ active: false
+
+# Ref: https://detekt.dev/docs/rules/style
+style:
+ MaxLineLength:
+ maxLineLength: 112
+ ForbiddenComment:
+ active: false
+ ClassOrdering:
+ active: true
+ MagicNumber:
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts',"**/GeometryHelper.kt" ]
+
+# Ref: https://detekt.dev/docs/rules/formatting
+formatting:
+ autoCorrect: true
+ MaximumLineLength:
+ maxLineLength: 112
+ ContextReceiverMapping:
+ active: true
+ DiscouragedCommentLocation:
+ active: true
+ EnumWrapping:
+ active: true
+ FunctionName:
+ active: true
+ FunctionSignature:
+ maxLineLength: 112
+ active: true
+ IfElseBracing:
+ active: true
+ ParameterListSpacing:
+ active: true
+ TrailingCommaOnCallSite:
+ active: true
+ TrailingCommaOnDeclarationSite:
+ active: true
+ TryCatchFinallySpacing:
+ active: true
+ TypeArgumentListSpacing:
+ active: true
+ TypeParameterListSpacing:
+ active: true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..29d789c
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,15 @@
+# This file was generated by the Gradle 'init' task.
+# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
+
+[versions]
+commons-math3 = "3.6.1"
+guava = "33.2.1-jre"
+junit-jupiter = "5.10.3"
+
+[libraries]
+commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" }
+guava = { module = "com.google.guava:guava", version.ref = "guava" }
+junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
+
+[plugins]
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.0.0" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9355b41
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..6f28f95
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,14 @@
+/*
+ * This file was generated by the Gradle 'init' task.
+ *
+ * The settings file is used to specify which projects to include in your build.
+ * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.10/userguide/multi_project_builds.html in the Gradle documentation.
+ */
+
+plugins {
+ // Apply the foojay-resolver plugin to allow automatic download of JDKs
+ id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
+}
+
+rootProject.name = "geojson-kt"
+include("src")
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/BBox.kt b/src/main/kotlin/jp/co/lycorp/geojson/BBox.kt
new file mode 100644
index 0000000..083d1f6
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/BBox.kt
@@ -0,0 +1,63 @@
+package jp.co.lycorp.geojson
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import jp.co.lycorp.geojson.jackson.BBoxDeserializer
+import jp.co.lycorp.geojson.jackson.BBoxSerializer
+
+/**
+ * BBox class
+ *
+ * Class that stores GeoJSON BBox information
+ *
+ * @property minLng Longitude of the most southwesterly point
+ * @property minLat Latitude of the most southwesterly point
+ * @property maxLng Longitude of the most northeasterly point
+ * @property maxLat Latitude of the most northeasterly point
+ * @property minAlt Altitude of the most southwesterly point (optional)
+ * @property maxAlt Altitude of the most northeasterly point (optional)
+ */
+@JsonDeserialize(using = BBoxDeserializer::class)
+@JsonSerialize(using = BBoxSerializer::class)
+data class BBox(
+ val minLng: Double,
+ val minLat: Double,
+ val maxLng: Double,
+ val maxLat: Double,
+ val minAlt: Double? = null,
+ val maxAlt: Double? = null,
+) {
+ companion object {
+ /**
+ * Function to derive BBox from an array of Positions
+ */
+ fun from(coordinates: List): BBox {
+ require(coordinates.isNotEmpty()) { "The provided list is empty. Please provide a non-empty list." }
+
+ return coordinates.asSequence()
+ .map { BBox(it.lng, it.lat, it.lng, it.lat, it.alt, it.alt) }
+ .reduce { acc, bbox ->
+ val minAlt = when {
+ bbox.minAlt == null -> acc.minAlt
+ acc.minAlt != null -> minOf(bbox.minAlt, acc.minAlt)
+ else -> bbox.minAlt
+ }
+
+ val maxAlt = when {
+ bbox.maxAlt == null -> acc.maxAlt
+ acc.maxAlt != null -> maxOf(bbox.maxAlt, acc.maxAlt)
+ else -> bbox.maxAlt
+ }
+
+ BBox(
+ minOf(acc.minLng, bbox.minLng),
+ minOf(acc.minLat, bbox.minLat),
+ maxOf(acc.maxLng, bbox.maxLng),
+ maxOf(acc.maxLat, bbox.maxLat),
+ minAlt,
+ maxAlt,
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Edge.kt b/src/main/kotlin/jp/co/lycorp/geojson/Edge.kt
new file mode 100644
index 0000000..8e3e572
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Edge.kt
@@ -0,0 +1,59 @@
+package jp.co.lycorp.geojson
+
+import jp.co.lycorp.geojson.algorithm.calculateCrossProduct
+import jp.co.lycorp.geojson.algorithm.calculateDotProduct
+import jp.co.lycorp.geojson.algorithm.calculateSquareDist
+import jp.co.lycorp.geojson.algorithm.isBetween
+
+/**
+ * Edge class
+ *
+ * Edge with start and end points
+ *
+ * @property startPosition Edge start point
+ * @property endPosition Edge end point
+ */
+data class Edge(val startPosition: Position, val endPosition: Position) {
+ /**
+ * Determine if a given point is collinear with the points in this edge
+ *
+ * [Cross-product](https://en.wikipedia.org/wiki/Cross_product) is used
+ * to determine if the three points are on the same line.
+ */
+ fun isCollinear(point: Position): Boolean {
+ val (pointLng, pointLat) = point
+ val (startLng, startLat) = startPosition
+ val (endLng, endLat) = endPosition
+
+ val isCollinear =
+ (endLat - startLat) * (pointLng - startLng) == (endLng - startLng) * (pointLat - startLat)
+ return isCollinear &&
+ isBetween(pointLng, startLng, endLng) &&
+ isBetween(pointLat, startLat, endLat)
+ }
+
+ /**
+ * Determines if two line segments intersect.
+ */
+ fun isIntersected(otherEdge: Edge): Boolean {
+ val otherStartPosition = otherEdge.startPosition
+ val otherEndPosition = otherEdge.endPosition
+ val crossProduct1 = calculateCrossProduct(otherStartPosition, otherEndPosition, startPosition)
+ val crossProduct2 = calculateCrossProduct(otherStartPosition, otherEndPosition, endPosition)
+
+ // handle collinear edges
+ if (crossProduct1 == 0.0 && crossProduct2 == 0.0) {
+ var distanceToStart = calculateDotProduct(otherStartPosition, otherEndPosition, startPosition)
+ var distanceToEnd = calculateDotProduct(otherStartPosition, otherEndPosition, endPosition)
+ if (distanceToStart > distanceToEnd) {
+ distanceToStart = distanceToEnd.also { distanceToEnd = distanceToStart }
+ }
+ return 0 <= distanceToEnd &&
+ distanceToStart <= calculateSquareDist(otherStartPosition, otherEndPosition)
+ }
+
+ val crossProduct3 = calculateCrossProduct(startPosition, endPosition, otherStartPosition)
+ val crossProduct4 = calculateCrossProduct(startPosition, endPosition, otherEndPosition)
+ return crossProduct1 * crossProduct2 <= 0 && crossProduct3 * crossProduct4 <= 0
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Feature.kt b/src/main/kotlin/jp/co/lycorp/geojson/Feature.kt
new file mode 100644
index 0000000..342cac2
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Feature.kt
@@ -0,0 +1,30 @@
+package jp.co.lycorp.geojson
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import jp.co.lycorp.geojson.jackson.FeatureIdDeserializer
+import jp.co.lycorp.geojson.jackson.FeatureIdSerializer
+/**
+ * Feature class
+ *
+ * Class that stores GeoJSON Feature Object information
+ *
+ * @property geometry Geometry object
+ * @property properties Any JSON object
+ * @property id Identifier. Numeric or string.
+ */
+data class Feature(
+ val geometry: Geometry<*>,
+ override val bbox: BBox? = null,
+ val properties: Map? = null,
+ @JsonDeserialize(using = FeatureIdDeserializer::class)
+ @JsonSerialize(using = FeatureIdSerializer::class)
+ val id: FeatureId? = null,
+) : GeoJsonObject("Feature") {
+ /**
+ * Derive BBox from coordinates
+ */
+ fun calculateBBox(): BBox {
+ return geometry.calculateBBox()
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/FeatureCollection.kt b/src/main/kotlin/jp/co/lycorp/geojson/FeatureCollection.kt
new file mode 100644
index 0000000..ccd6b04
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/FeatureCollection.kt
@@ -0,0 +1,30 @@
+package jp.co.lycorp.geojson
+
+/**
+ * Feature collection class
+ *
+ * Class that stores GeoJSON FeatureCollection information
+ *
+ * @property features Array of Feature objects
+ */
+data class FeatureCollection(
+ val features: List,
+ override val bbox: BBox? = null,
+) : GeoJsonObject("FeatureCollection"), Iterable {
+ /**
+ * Returns an iterator for `Feature` based on the `features` property of this class.
+ */
+ override fun iterator(): Iterator = features.iterator()
+
+ /**
+ * Derive BBox from coordinates
+ */
+ fun calculateBBox(): BBox {
+ return BBox(
+ features.minOf { it.calculateBBox().minLng },
+ features.minOf { it.calculateBBox().minLat },
+ features.maxOf { it.calculateBBox().maxLng },
+ features.maxOf { it.calculateBBox().maxLat },
+ )
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/FeatureId.kt b/src/main/kotlin/jp/co/lycorp/geojson/FeatureId.kt
new file mode 100644
index 0000000..ca7c57f
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/FeatureId.kt
@@ -0,0 +1,45 @@
+package jp.co.lycorp.geojson
+
+/**
+ * Sealed class for feature ID.
+ * The value is either a JSON string or number.
+ */
+sealed class FeatureId {
+ companion object {
+ /**
+ * Generates and returns a class for the [FeatureId] corresponding to the specified value.
+ *
+ * @param value Raw value in JSON
+ * @return Valid FeatureId instance
+ */
+ fun of(value: Any): FeatureId {
+ return when (value) {
+ is String -> {
+ StringFeatureId(value)
+ }
+
+ is Number -> {
+ NumberFeatureId(value)
+ }
+
+ else -> {
+ throw IllegalArgumentException("invalid value as feature ID: $value")
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Feature ID whose value is a JSON string
+ *
+ * @property value Raw value
+ */
+data class StringFeatureId(val value: String) : FeatureId()
+
+/**
+ * Feature ID whose value is a JSON number
+ *
+ * @property value Raw value
+ */
+data class NumberFeatureId(val value: Number) : FeatureId()
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/GeoJsonObject.kt b/src/main/kotlin/jp/co/lycorp/geojson/GeoJsonObject.kt
new file mode 100644
index 0000000..1802df4
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/GeoJsonObject.kt
@@ -0,0 +1,31 @@
+package jp.co.lycorp.geojson
+
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonSubTypes
+import com.fasterxml.jackson.annotation.JsonTypeInfo
+
+/**
+ *
+ * GeoJsonObject class
+ *
+ * Base class
+ *
+ * @property type Type that GeoJSON has
+ * @property bbox Information on the coordinate range for its Geometries, Features, or FeatureCollections
+ */
+@JsonTypeInfo(property = "type", use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY)
+@JsonSubTypes(
+ JsonSubTypes.Type(Point::class, name = "Point"),
+ JsonSubTypes.Type(MultiPoint::class, name = "MultiPoint"),
+ JsonSubTypes.Type(LineString::class, name = "LineString"),
+ JsonSubTypes.Type(MultiLineString::class, name = "MultiLineString"),
+ JsonSubTypes.Type(Polygon::class, name = "Polygon"),
+ JsonSubTypes.Type(MultiPolygon::class, name = "MultiPolygon"),
+ JsonSubTypes.Type(Feature::class, name = "Feature"),
+ JsonSubTypes.Type(FeatureCollection::class, name = "FeatureCollection"),
+)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+abstract class GeoJsonObject(
+ val type: String,
+ open val bbox: BBox? = null,
+)
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Geometry.kt b/src/main/kotlin/jp/co/lycorp/geojson/Geometry.kt
new file mode 100644
index 0000000..ef90be9
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Geometry.kt
@@ -0,0 +1,22 @@
+package jp.co.lycorp.geojson
+
+/**
+ * Geometry class
+ *
+ * Base class for GeometryObject
+ *
+ * @param type Type member of GeometryObject
+ * @property coordinates Coordinates array
+ */
+abstract class Geometry(type: String) : GeoJsonObject(type) {
+ /**
+ * Coordinate array
+ * The structure of the elements in this array is determined by the type of geometry.
+ */
+ abstract val coordinates: T?
+
+ /**
+ * Derive BBox from coordinates
+ */
+ abstract fun calculateBBox(): BBox
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/GeometryCollection.kt b/src/main/kotlin/jp/co/lycorp/geojson/GeometryCollection.kt
new file mode 100644
index 0000000..e4c6153
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/GeometryCollection.kt
@@ -0,0 +1,18 @@
+package jp.co.lycorp.geojson
+
+/**
+ * GeometryCollection class
+ *
+ * Class that stores GeoJSON GeometryCollection information
+ *
+ * @property geometries Geometry array
+ */
+data class GeometryCollection(
+ val geometries: List>,
+ override val bbox: BBox? = null,
+) : GeoJsonObject("GeometryCollection"), Iterable> {
+ /**
+ * Returns an iterator for `Geometry<*>` based on the `geometries` property of this class.
+ */
+ override fun iterator(): Iterator> = geometries.iterator()
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/LineString.kt b/src/main/kotlin/jp/co/lycorp/geojson/LineString.kt
new file mode 100644
index 0000000..c13d742
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/LineString.kt
@@ -0,0 +1,25 @@
+package jp.co.lycorp.geojson
+
+import jp.co.lycorp.geojson.validator.LineStringValidator
+
+typealias LineStringCoordinates = List
+
+/**
+ * LineString class
+ *
+ * Class that stores GeoJSON LineString information
+ *
+ * @property coordinates An Array of Position
+ */
+data class LineString(
+ override val coordinates: LineStringCoordinates,
+ override val bbox: BBox? = null,
+) : Geometry("LineString") {
+ init {
+ LineStringValidator.validate(coordinates)
+ }
+
+ override fun calculateBBox(): BBox {
+ return BBox.from(coordinates)
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/LinearRing.kt b/src/main/kotlin/jp/co/lycorp/geojson/LinearRing.kt
new file mode 100644
index 0000000..ae050ad
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/LinearRing.kt
@@ -0,0 +1,56 @@
+package jp.co.lycorp.geojson
+
+import jp.co.lycorp.geojson.algorithm.calculatePolygonCentroid
+import jp.co.lycorp.geojson.algorithm.rayCasting
+
+typealias LinearRingCoordinates = List
+
+/**
+ * LineString class
+ *
+ * linear ring is a closed LineString with four or more positions.
+ *
+ * @property coordinates An Array of Position
+ */
+data class LinearRing(val coordinates: LinearRingCoordinates) {
+ init {
+ require(coordinates.isNotEmpty()) {
+ "LinearRing cannot be empty"
+ }
+ }
+
+ /**
+ * Determine if a given point is on an edge of this LinearRing
+ */
+ fun isOnEdge(position: Position): Boolean {
+ return coordinates.windowed(2).any {
+ (s, t) ->
+ Edge(s, t).isCollinear(position)
+ }
+ }
+
+ /**
+ * Determine if a given point is inside this linearRing
+ *
+ * Returns false if it is on an edge
+ */
+ fun isPointInLinearRing(position: Position): Boolean {
+ return rayCasting(position, coordinates) % 2 == 1
+ }
+
+ /**
+ * Determine if a given point is outside this linearRing
+ *
+ * Returns false if it is on an edge
+ */
+ fun isPointOutsideLinearRing(position: Position): Boolean {
+ return rayCasting(position, coordinates) % 2 == 0
+ }
+
+ /**
+ * Calculate polygon centroid
+ */
+ fun calculateCentroid(): Position {
+ return calculatePolygonCentroid(this)
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/MultiLineString.kt b/src/main/kotlin/jp/co/lycorp/geojson/MultiLineString.kt
new file mode 100644
index 0000000..38ebc76
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/MultiLineString.kt
@@ -0,0 +1,50 @@
+package jp.co.lycorp.geojson
+
+import jp.co.lycorp.geojson.validator.LineStringValidator
+
+typealias MultiLineStringCoordinates = List>
+
+/**
+ * MultiLineString class
+ *
+ * Class that stores GeoJSON MultiLineString information
+ *
+ * @property coordinates An Array of Position arrays
+ */
+data class MultiLineString(
+ override val coordinates: MultiLineStringCoordinates,
+ override val bbox: BBox? = null,
+) : Geometry("MultiLineString") {
+ init {
+ coordinates.forEach {
+ LineStringValidator.validate(it)
+ }
+ }
+ constructor(vararg lineStrings: LineString) :
+ this(
+ lineStrings.map { it.coordinates },
+ BBox.from(lineStrings.map { it.coordinates }.flatten()),
+ )
+
+ /**
+ * Split MultiLineString into LineString lists.
+ */
+ fun split(): List {
+ return coordinates.map {
+ LineString(it, BBox.from(it))
+ }
+ }
+
+ /**
+ * Returns a new MultiLineString with the passed LineString appended.
+ */
+ fun added(vararg lineStrings: LineString): MultiLineString {
+ val newCoordinates: List> = coordinates + lineStrings.map { it.coordinates }
+ val newBBox = BBox.from(newCoordinates.flatten())
+ return MultiLineString(newCoordinates, newBBox)
+ }
+
+ override fun calculateBBox(): BBox {
+ return BBox.from(coordinates.flatten())
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/MultiPoint.kt b/src/main/kotlin/jp/co/lycorp/geojson/MultiPoint.kt
new file mode 100644
index 0000000..c4f6e64
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/MultiPoint.kt
@@ -0,0 +1,40 @@
+package jp.co.lycorp.geojson
+
+typealias MultiPointCoordinates = List
+
+/**
+ * MultiPoint class
+ *
+ * Class that stores GeoJSON MultiPoint information
+ *
+ * @property coordinates An Array of Position
+ */
+data class MultiPoint(
+ override val coordinates: MultiPointCoordinates,
+ override val bbox: BBox? = null,
+) : Geometry("MultiPoint") {
+ constructor(vararg points: Point) :
+ this(points.map { it.coordinates }, BBox.from(points.map { it.coordinates }))
+
+ /**
+ * Split MultiPoint into Point lists.
+ */
+ fun split(): List {
+ return coordinates.map {
+ Point(it)
+ }
+ }
+
+ /**
+ * Returns a new MultiPoint with the passed Point appended.
+ */
+ fun added(vararg points: Point): MultiPoint {
+ val newCoordinates = coordinates + points.map { it.coordinates }
+ val newBBox = BBox.from(newCoordinates)
+ return MultiPoint(newCoordinates, newBBox)
+ }
+
+ override fun calculateBBox(): BBox {
+ return BBox.from(coordinates)
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/MultiPolygon.kt b/src/main/kotlin/jp/co/lycorp/geojson/MultiPolygon.kt
new file mode 100644
index 0000000..ae6de50
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/MultiPolygon.kt
@@ -0,0 +1,56 @@
+package jp.co.lycorp.geojson
+
+import jp.co.lycorp.geojson.validator.PolygonValidator
+
+typealias MultiPolygonCoordinates = List>>
+
+/**
+ * MultiPolygon class
+ *
+ * Class that stores GeoJSON MultiPolygon information
+ *
+ * @property coordinates An array of arrays of Position arrays
+ */
+data class MultiPolygon(
+ override val coordinates: MultiPolygonCoordinates,
+ override val bbox: BBox? = null,
+) : Geometry("MultiPolygon"), Surface {
+ init {
+ coordinates.forEach {
+ PolygonValidator.validate(it)
+ }
+ }
+ constructor(vararg polygons: Polygon) :
+ this(
+ polygons.map { it.coordinates },
+ BBox.from(polygons.flatMap { it.coordinates.first().dropLast(1) }),
+ )
+
+ /**
+ * Split MultiPolygon into Polygon lists.
+ */
+ fun split(): List {
+ return coordinates.map {
+ Polygon(it, BBox.from(it.first()))
+ }
+ }
+
+ /**
+ * Returns a new MultiPolygon with the passed Polygon appended.
+ */
+ fun added(vararg polygons: Polygon): MultiPolygon {
+ val newCoordinates: List>> = coordinates + polygons.map { it.coordinates }
+ val newBBox = BBox.from(newCoordinates.flatMap { it.first().dropLast(1) })
+ return MultiPolygon(newCoordinates, newBBox)
+ }
+
+ override fun calculateBBox(): BBox {
+ return BBox.from(coordinates.flatMap { it.first().dropLast(1) })
+ }
+
+ override fun contains(point: Point, allowOnEdge: Boolean): Boolean {
+ return this.split().any {
+ it.contains(point, allowOnEdge)
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Point.kt b/src/main/kotlin/jp/co/lycorp/geojson/Point.kt
new file mode 100644
index 0000000..573974f
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Point.kt
@@ -0,0 +1,33 @@
+package jp.co.lycorp.geojson
+
+typealias PointCoordinates = Position
+
+/**
+ * Point class
+ *
+ * Class that stores GeoJSON Point information
+ *
+ * @property coordinates It is single position
+ */
+data class Point(
+ override val coordinates: PointCoordinates,
+ override val bbox: BBox? = null,
+) : Geometry("Point") {
+
+ /**
+ * Determines whether the point is inside the boundaries of a surface.
+ * If it is on an edge of the surface, it is considered to be inside when `allowOnEdge` is true.
+ *
+ * Note that altitude is ignored; this is a purely 2D calculation.
+ */
+ fun isIn(surface: Surface, allowOnEdge: Boolean = true): Boolean = surface.contains(this, allowOnEdge)
+
+ /**
+ * Determines whether the point is on the specified edge.
+ */
+ fun isOn(edge: Edge): Boolean = edge.isCollinear(this.coordinates)
+
+ override fun calculateBBox(): BBox {
+ return BBox.from(listOf(coordinates))
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Polygon.kt b/src/main/kotlin/jp/co/lycorp/geojson/Polygon.kt
new file mode 100644
index 0000000..0afc31f
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Polygon.kt
@@ -0,0 +1,54 @@
+package jp.co.lycorp.geojson
+
+import jp.co.lycorp.geojson.validator.PolygonValidator
+
+typealias PolygonCoordinates = List>
+
+/**
+ * Polygon class
+ *
+ * Class that stores GeoJSON Polygon information
+ *
+ * @property coordinates An array of Position arrays
+ */
+data class Polygon(
+ override val coordinates: PolygonCoordinates,
+ override val bbox: BBox? = null,
+) : Geometry("Polygon"), Surface {
+ init {
+ PolygonValidator.validate(coordinates)
+ }
+
+ /**
+ * Determines whether a given position is inside the polygon
+ * If it is on an edge of the polygon, it is considered to be inside when `allowOnEdge` is true.
+ */
+ fun contains(position: Position, allowOnEdge: Boolean = true): Boolean {
+ val isPointInExteriorRing = LinearRing(coordinates[0]).isPointInLinearRing(position)
+ val isPointOutsideInteriorRing = coordinates.drop(1).all {
+ LinearRing(it).isPointOutsideLinearRing(position)
+ }
+ return if (allowOnEdge) {
+ val isOnEdge = coordinates.any {
+ LinearRing(it).isOnEdge(position)
+ }
+ (isPointInExteriorRing && isPointOutsideInteriorRing) || isOnEdge
+ } else {
+ isPointInExteriorRing && isPointOutsideInteriorRing
+ }
+ }
+
+ /**
+ * Calculate Exterior Linear Ring centroid
+ */
+ fun calculateCentroid(): Position {
+ return LinearRing(coordinates[0]).calculateCentroid()
+ }
+
+ override fun calculateBBox(): BBox {
+ return BBox.from(coordinates.first().dropLast(1))
+ }
+
+ override fun contains(point: Point, allowOnEdge: Boolean): Boolean =
+ contains(point.coordinates, allowOnEdge)
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Position.kt b/src/main/kotlin/jp/co/lycorp/geojson/Position.kt
new file mode 100644
index 0000000..0eac6d5
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Position.kt
@@ -0,0 +1,28 @@
+package jp.co.lycorp.geojson
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import jp.co.lycorp.geojson.jackson.PositionDeserializer
+import jp.co.lycorp.geojson.jackson.PositionSerializer
+import jp.co.lycorp.geojson.validator.PositionValidator
+
+/**
+ * Position class
+ *
+ * Class that stores GeoJSON Position information
+ *
+ * @property lng Longitude
+ * @property lat Latitude
+ * @property alt Altitude (optional)
+ */
+@JsonDeserialize(using = PositionDeserializer::class)
+@JsonSerialize(using = PositionSerializer::class)
+data class Position(
+ val lng: Double,
+ val lat: Double,
+ val alt: Double? = null,
+) {
+ init {
+ PositionValidator.validate(this)
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/Surface.kt b/src/main/kotlin/jp/co/lycorp/geojson/Surface.kt
new file mode 100644
index 0000000..634f6ff
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/Surface.kt
@@ -0,0 +1,18 @@
+package jp.co.lycorp.geojson
+
+/**
+ * A flat, two-dimensional figure (that may be curved).
+ * [Reference](https://en.wikipedia.org/wiki/Surface_(mathematics))
+ *
+ * Polygons and MultiPolygons can be considered types of surfaces.
+ * [RFC7946](https://tex2e.github.io/rfc-translater/html/rfc7946.html#1--Introduction)
+ */
+interface Surface {
+ /**
+ * Determines whether a given point is inside the surface's boundaries.
+ * If it is on an edge of the surface, it is considered to be inside when `allowOnEdge` is true.
+ *
+ * Note that altitude is ignored; this is a purely 2D calculation.
+ */
+ fun contains(point: Point, allowOnEdge: Boolean = true): Boolean
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/algorithm/GeometryHelper.kt b/src/main/kotlin/jp/co/lycorp/geojson/algorithm/GeometryHelper.kt
new file mode 100644
index 0000000..bed1a9c
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/algorithm/GeometryHelper.kt
@@ -0,0 +1,166 @@
+package jp.co.lycorp.geojson.algorithm
+
+import jp.co.lycorp.geojson.Edge
+import jp.co.lycorp.geojson.LinearRing
+import jp.co.lycorp.geojson.Position
+
+/**
+ * Checks whether a given value lies within the range defined by two bounds, inclusive.
+ */
+fun isBetween(value: Double, bound1: Double, bound2: Double): Boolean {
+ return (minOf(bound1, bound2)) <= value && value <= maxOf(bound1, bound2)
+}
+
+/**
+ * Function implementing the ray casting algorithm
+ *
+ * The idea is to cast a ray from the point towards infinity
+ * and count how many times it intersects with the edges of the polygon.
+ * [wikipedia](https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm)
+ *
+ * Return -1 if there is a point on the edge
+ *
+ * [Referenced code](https://medium.com/@girishajmera/exploring-algorithms-to-determine-points-inside-or-outside-a-polygon-038952946f87)
+ *
+ */
+
+fun rayCasting(point: Position, polygon: List): Int {
+ val numVertices = polygon.size
+ val (lng, lat) = point
+ var crossings = 0
+ for (i in 0 until numVertices) {
+ val (lng1, lat1) = polygon[i]
+ val (lng2, lat2) = polygon[(i + 1) % numVertices]
+ if (Edge(Position(lng1, lat1), Position(lng2, lat2)).isCollinear(point)) return -1
+ if ((minOf(lat1, lat2) < lat && lat < maxOf(lat1, lat2)) &&
+ (lng <= maxOf(lng1, lng2))
+ ) {
+ val intersectionX = (lat - lat1) * (lng2 - lng1) / (lat2 - lat1) + lng1
+ if (lng1 == lng2 || lng <= intersectionX) {
+ crossings++
+ }
+ }
+ }
+ return crossings
+}
+
+/**
+ * Calculate polygon centroid
+ *
+ * [Reference site](https://mathworld.wolfram.com/PolygonCentroid.html)
+ */
+fun calculatePolygonCentroid(linearRing: LinearRing): Position {
+ val vertices = linearRing.coordinates
+ var signedArea = 0.0
+ var centroidX = 0.0
+ var centroidY = 0.0
+
+ for (i in 0 until vertices.size - 1) {
+ val x0 = vertices[i].lng
+ val y0 = vertices[i].lat
+ val x1 = vertices[i + 1].lng
+ val y1 = vertices[i + 1].lat
+
+ val area = x0 * y1 - x1 * y0
+ signedArea += area
+ centroidX += (x0 + x1) * area
+ centroidY += (y0 + y1) * area
+ }
+
+ signedArea *= 0.5
+ require(signedArea > 0.0) {
+ "The area of the polygon is zero."
+ }
+
+ centroidX /= (6.0 * signedArea)
+ centroidY /= (6.0 * signedArea)
+
+ return Position(centroidX, centroidY)
+}
+
+/**
+ * Determines if two convex polygons intersect.
+ *
+ * [Referenced code](https://tjkendev.github.io/procon-library/python/geometry/convex_polygons_intersection.html)
+ *
+ */
+fun convexPolygonsIntersection(linearRing1: List, linearRing2: List): Boolean {
+ val vertices1 = linearRing1.dropLast(1)
+ val vertices2 = linearRing2.dropLast(1)
+ val vertexCount1 = vertices1.size
+ val vertexCount2 = vertices2.size
+
+ var index1 = 0
+ var index2 = 0
+
+ while (index1 < vertexCount1 && index2 < vertexCount2) {
+ val previousVertex1 = vertices1[(index1 - 1 + vertexCount1) % vertexCount1]
+ val currentVertex1 = vertices1[index1]
+ val previousVertex2 = vertices2[(index2 - 1 + vertexCount2) % vertexCount2]
+ val currentVertex2 = vertices2[index2]
+
+ if (Edge(previousVertex1, currentVertex1).isIntersected(Edge(previousVertex2, currentVertex2))) {
+ return true
+ }
+
+ val direction = determineDirection(previousVertex1, currentVertex1, previousVertex2, currentVertex2)
+ if (direction.first == 0 && direction.second == 0) {
+ break
+ }
+ index1 += direction.first
+ index2 += direction.second
+ }
+
+ return false
+}
+
+private fun determineDirection(
+ previousVertex1: Position,
+ currentVertex1: Position,
+ previousVertex2: Position,
+ currentVertex2: Position,
+): Pair {
+ val deltaX1 = currentVertex1.lng - previousVertex1.lng
+ val deltaY1 = currentVertex1.lat - previousVertex1.lat
+ val deltaX2 = currentVertex2.lng - previousVertex2.lng
+ val deltaY2 = currentVertex2.lat - previousVertex2.lat
+ val crossProduct = deltaX1 * deltaY2 - deltaX2 * deltaY1
+ val crossVa = calculateCrossProduct(previousVertex2, currentVertex2, currentVertex1)
+ val crossVb = calculateCrossProduct(previousVertex1, currentVertex1, currentVertex2)
+
+ return when {
+ crossProduct == 0.0 && crossVa < 0 && crossVb < 0 -> Pair(0, 0) // parallel and outside
+ crossProduct == 0.0 && crossVa == 0.0 && crossVb == 0.0 -> Pair(1, 0) // on the same line
+ crossProduct >= 0 -> if (crossVb > 0) Pair(1, 0) else Pair(0, 1) // intersection direction
+ else -> if (crossVa > 0) Pair(0, 1) else Pair(1, 0) // intersection direction
+ }
+}
+
+/**
+* Calculates the dot product relative to the origin.
+*/
+fun calculateDotProduct(origin: Position, pointA: Position, pointB: Position): Double {
+ val (ox, oy) = origin
+ val (ax, ay) = pointA
+ val (bx, by) = pointB
+ return (ax - ox) * (bx - ox) + (ay - oy) * (by - oy)
+}
+
+/**
+ * Calculates the cross product relative to the origin.
+ */
+fun calculateCrossProduct(origin: Position, pointA: Position, pointB: Position): Double {
+ val (ox, oy) = origin
+ val (ax, ay) = pointA
+ val (bx, by) = pointB
+ return (ax - ox) * (by - oy) - (bx - ox) * (ay - oy)
+}
+
+/**
+ * Calculates the squared distance between two points.
+ */
+fun calculateSquareDist(pointA: Position, pointB: Position): Double {
+ val (ax, ay) = pointA
+ val (bx, by) = pointB
+ return (ax - bx) * (ax - bx) + (ay - by) * (ay - by)
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/jackson/BBoxDeserializer.kt b/src/main/kotlin/jp/co/lycorp/geojson/jackson/BBoxDeserializer.kt
new file mode 100644
index 0000000..b28664e
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/jackson/BBoxDeserializer.kt
@@ -0,0 +1,53 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import jp.co.lycorp.geojson.BBox
+
+internal class BBoxDeserializer : StdDeserializer(
+ BBox::class.java,
+) {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): BBox {
+ if (!p.isExpectedStartArrayToken) {
+ ctxt.handleUnexpectedToken(
+ BBox::class.java,
+ p.currentToken,
+ p,
+ "Unable to deserialize bbox: no array found",
+ )
+ }
+
+ val coords: List = p.readValueAs(object : TypeReference>() {})
+ return when (coords.size) {
+ COORDINATES_SIZE_2D -> BBox(
+ minLng = coords[0],
+ minLat = coords[1],
+ maxLng = coords[2],
+ maxLat = coords[3],
+ )
+ COORDINATES_SIZE_3D -> BBox(
+ minLng = coords[0],
+ minLat = coords[1],
+ minAlt = coords[2],
+ maxLng = coords[3],
+ maxLat = coords[4],
+ maxAlt = coords[5],
+ )
+ else -> {
+ ctxt.handleUnexpectedToken(
+ BBox::class.java,
+ p.currentToken,
+ p,
+ "Unexpected coordinate array size: ${coords.size}",
+ )
+ error("coordinate array size is ${coords.size}")
+ }
+ }
+ }
+ companion object {
+ const val COORDINATES_SIZE_2D = 4
+ const val COORDINATES_SIZE_3D = 6
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/jackson/BBoxSerializer.kt b/src/main/kotlin/jp/co/lycorp/geojson/jackson/BBoxSerializer.kt
new file mode 100644
index 0000000..27fdf7b
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/jackson/BBoxSerializer.kt
@@ -0,0 +1,21 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.ser.std.StdSerializer
+import jp.co.lycorp.geojson.BBox
+
+internal class BBoxSerializer : StdSerializer(
+ BBox::class.java,
+) {
+ override fun serialize(value: BBox, gen: JsonGenerator, serializers: SerializerProvider) {
+ gen.writeStartArray()
+ gen.writeNumber(value.minLng)
+ gen.writeNumber(value.minLat)
+ value.minAlt?.let { gen.writeNumber(it) }
+ gen.writeNumber(value.maxLng)
+ gen.writeNumber(value.maxLat)
+ value.maxAlt?.let { gen.writeNumber(it) }
+ gen.writeEndArray()
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/jackson/FeatureIdDeserializer.kt b/src/main/kotlin/jp/co/lycorp/geojson/jackson/FeatureIdDeserializer.kt
new file mode 100644
index 0000000..9268693
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/jackson/FeatureIdDeserializer.kt
@@ -0,0 +1,21 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import jp.co.lycorp.geojson.FeatureId
+
+/**
+ * FeatureIdDeserializer class
+ *
+ * Custom deserializer for feature id.
+ */
+internal class FeatureIdDeserializer : StdDeserializer(FeatureId::class.java) {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): FeatureId {
+ return when {
+ p.currentToken.isNumeric -> FeatureId.of(p.numberValue)
+ !p.currentToken.isBoolean && p.currentToken.isScalarValue -> FeatureId.of(p.text)
+ else -> throw IllegalArgumentException("Only String or Number is allowed in the id field")
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/jackson/FeatureIdSerializer.kt b/src/main/kotlin/jp/co/lycorp/geojson/jackson/FeatureIdSerializer.kt
new file mode 100644
index 0000000..4aecce9
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/jackson/FeatureIdSerializer.kt
@@ -0,0 +1,22 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.ser.std.StdSerializer
+import jp.co.lycorp.geojson.FeatureId
+import jp.co.lycorp.geojson.NumberFeatureId
+import jp.co.lycorp.geojson.StringFeatureId
+
+/**
+ * FeatureIdSerializer class
+ *
+ * Custom serializer for feature id
+ */
+internal class FeatureIdSerializer : StdSerializer(FeatureId::class.java) {
+ override fun serialize(value: FeatureId, gen: JsonGenerator, serializer: SerializerProvider) {
+ when (value) {
+ is StringFeatureId -> gen.writeString(value.value)
+ is NumberFeatureId -> gen.writeNumber(value.value.toString())
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/jackson/PositionDeserializer.kt b/src/main/kotlin/jp/co/lycorp/geojson/jackson/PositionDeserializer.kt
new file mode 100644
index 0000000..a35b1b4
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/jackson/PositionDeserializer.kt
@@ -0,0 +1,48 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import jp.co.lycorp.geojson.Position
+
+internal class PositionDeserializer : StdDeserializer(Position::class.java) {
+
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Position {
+ if (!p.isExpectedStartArrayToken) {
+ ctxt.handleUnexpectedToken(
+ Position::class.java,
+ p.currentToken,
+ p,
+ "Unable to deserialize Position: no array found",
+ )
+ }
+
+ val coords: List = p.readValueAs(object : TypeReference>() {})
+ return when (coords.size) {
+ COORDINATES_SIZE_2D -> Position(
+ lng = coords[0],
+ lat = coords[1],
+ )
+ COORDINATES_SIZE_3D -> Position(
+ lng = coords[0],
+ lat = coords[1],
+ alt = coords[2],
+ )
+ else -> {
+ ctxt.handleUnexpectedToken(
+ Position::class.java,
+ p.currentToken,
+ p,
+ "Unexpected coordinate array size: ${coords.size}",
+ )
+ error("coordinate array size is ${coords.size}")
+ }
+ }
+ }
+
+ companion object {
+ const val COORDINATES_SIZE_2D = 2
+ const val COORDINATES_SIZE_3D = 3
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/jackson/PositionSerializer.kt b/src/main/kotlin/jp/co/lycorp/geojson/jackson/PositionSerializer.kt
new file mode 100644
index 0000000..7811eb0
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/jackson/PositionSerializer.kt
@@ -0,0 +1,16 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.ser.std.StdSerializer
+import jp.co.lycorp.geojson.Position
+
+internal class PositionSerializer : StdSerializer(Position::class.java) {
+ override fun serialize(value: Position, gen: JsonGenerator, serializers: SerializerProvider) {
+ gen.writeStartArray()
+ gen.writeNumber(value.lng)
+ gen.writeNumber(value.lat)
+ value.alt?.let { gen.writeNumber(it) }
+ gen.writeEndArray()
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/validator/LineStringValidator.kt b/src/main/kotlin/jp/co/lycorp/geojson/validator/LineStringValidator.kt
new file mode 100644
index 0000000..9722e77
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/validator/LineStringValidator.kt
@@ -0,0 +1,18 @@
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.LineStringCoordinates
+
+/**
+ * LineStringValidator class
+ *
+ * Validates whether a given LineString is valid
+ */
+internal object LineStringValidator {
+ private const val MINIMUM_SIZE_OF_THE_COORDINATE_ARRAY = 2
+
+ fun validate(coordinates: LineStringCoordinates) {
+ require(coordinates.size >= MINIMUM_SIZE_OF_THE_COORDINATE_ARRAY) {
+ "LineString coordinates must have at least 2 Positions"
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/validator/LinearRingValidator.kt b/src/main/kotlin/jp/co/lycorp/geojson/validator/LinearRingValidator.kt
new file mode 100644
index 0000000..a0361b6
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/validator/LinearRingValidator.kt
@@ -0,0 +1,26 @@
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.LinearRingCoordinates
+
+/**
+ * LinearRingValidator class
+ *
+ * Validates whether a given LinearRing is valid
+ *
+ */
+internal object LinearRingValidator {
+ private const val MINIMUM_SIZE_OF_THE_COORDINATE_ARRAY = 4
+ private const val MINIMUM_NUMBER_OF_UNIQUE_NODES = 3
+
+ fun validate(coordinates: LinearRingCoordinates) {
+ require(coordinates.size >= MINIMUM_SIZE_OF_THE_COORDINATE_ARRAY) {
+ "Polygon linear ring must have at least 4 coordinates"
+ }
+ require(coordinates.first() == coordinates.last()) {
+ "Polygon linear ring first element must be equal to last element"
+ }
+ require(coordinates.distinct().size >= MINIMUM_NUMBER_OF_UNIQUE_NODES) {
+ "Polygon linear ring must have at least 3 unique coordinates"
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/validator/PolygonValidator.kt b/src/main/kotlin/jp/co/lycorp/geojson/validator/PolygonValidator.kt
new file mode 100644
index 0000000..ddd8db9
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/validator/PolygonValidator.kt
@@ -0,0 +1,20 @@
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.PolygonCoordinates
+
+/**
+ * PolygonValidator class
+ *
+ * Validates whether a given Polygon is valid
+ *
+ */
+internal object PolygonValidator {
+ fun validate(coordinates: PolygonCoordinates) {
+ require(coordinates.isNotEmpty()) { "Polygon coordinates must have at least one linear ring" }
+
+ coordinates.forEach { LinearRingValidator.validate(it) }
+ if (coordinates.size >= 2) {
+ RingIntersectionValidator.validateRings(coordinates)
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/validator/PositionValidator.kt b/src/main/kotlin/jp/co/lycorp/geojson/validator/PositionValidator.kt
new file mode 100644
index 0000000..249f5d2
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/validator/PositionValidator.kt
@@ -0,0 +1,23 @@
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.Position
+
+/**
+ * LineStringValidator class
+ *
+ * Validates whether a given Position is valid
+ */
+internal object PositionValidator {
+ private const val MINIMUM_LONGITUDE = -180.0
+ private const val MAXIMUM_LONGITUDE = 180.0
+ private const val MINIMUM_LATITUDE = -90.0
+ private const val MAXIMUM_LATITUDE = 90.0
+ fun validate(position: Position) {
+ require(position.lng in MINIMUM_LONGITUDE..MAXIMUM_LONGITUDE) {
+ "Longitude must be between -180 and 180 degrees"
+ }
+ require(position.lat in MINIMUM_LATITUDE..MAXIMUM_LATITUDE) {
+ "Latitude must be between -90 and 90 degrees"
+ }
+ }
+}
diff --git a/src/main/kotlin/jp/co/lycorp/geojson/validator/RingIntersectionValidator.kt b/src/main/kotlin/jp/co/lycorp/geojson/validator/RingIntersectionValidator.kt
new file mode 100644
index 0000000..b5afadb
--- /dev/null
+++ b/src/main/kotlin/jp/co/lycorp/geojson/validator/RingIntersectionValidator.kt
@@ -0,0 +1,52 @@
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.LinearRing
+import jp.co.lycorp.geojson.PolygonCoordinates
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.algorithm.convexPolygonsIntersection
+
+/**
+ * PolygonValidator class
+ *
+ * Validates that the given rings are in proper alignment with each other.
+ */
+internal object RingIntersectionValidator {
+ fun validateRings(coordinates: PolygonCoordinates) {
+ val exteriorRing = coordinates[0]
+
+ // validate all interior rings
+ coordinates.drop(1).forEach {
+ interiorRing ->
+ validateInteriorRingAgainstExterior(interiorRing, exteriorRing)
+ validateInteriorRingsAgainstEachOther(interiorRing, coordinates.drop(1))
+ }
+ }
+
+ private fun validateInteriorRingAgainstExterior(
+ interiorRing: List,
+ exteriorRing: List,
+ ) {
+ require(interiorRing.dropLast(1).all { LinearRing(exteriorRing).isPointInLinearRing(it) }) {
+ "The interior ring of a Polygon must not intersect or cross the exterior ring."
+ }
+ }
+
+ private fun validateInteriorRingsAgainstEachOther(
+ targetInteriorRing: List,
+ interiorRings: PolygonCoordinates,
+ ) {
+ interiorRings.forEach {
+ nextInteriorRing ->
+ if (targetInteriorRing != nextInteriorRing) {
+ require(
+ !convexPolygonsIntersection(targetInteriorRing, nextInteriorRing) &&
+ nextInteriorRing.dropLast(1).all {
+ LinearRing(targetInteriorRing).isPointOutsideLinearRing(it)
+ },
+ ) {
+ "No two interior rings may intersect, cross, or encompass each other"
+ }
+ }
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/BBoxTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/BBoxTest.kt
new file mode 100644
index 0000000..d8af957
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/BBoxTest.kt
@@ -0,0 +1,53 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.assertions.throwables.shouldThrow
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class BBoxTest {
+ @Test
+ fun `should derive BBox when an array of 2D Positions is provided`() {
+ val positionList = listOf(
+ Position(-8.9, 1.0),
+ Position(2.9, 0.0),
+ Position(11.9, -43.0),
+ Position(8.9, 1.0),
+ )
+ val actual = BBox.from(positionList)
+ val expected = BBox(-8.9, -43.0, 11.9, 1.0)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should derive BBox when an array of 3D Positions is provided`() {
+ val positionList = listOf(
+ Position(-8.9, 1.0, -7.1),
+ Position(2.9, 0.0, 0.0),
+ Position(11.9, -43.0, 1.0),
+ Position(8.9, 1.0, 7.2),
+ )
+ val actual = BBox.from(positionList)
+ val expected = BBox(-8.9, -43.0, 11.9, 1.0, -7.1, 7.2)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should derive BBox when an array of 3D Positions (with alt partially set) is provided`() {
+ val positionList = listOf(
+ Position(-8.9, 1.0, null),
+ Position(2.9, 0.0, 7.2),
+ Position(11.9, -43.0, null),
+ Position(8.9, 1.0, -7.1),
+ )
+ val actual = BBox.from(positionList)
+ val expected = BBox(-8.9, -43.0, 11.9, 1.0, -7.1, 7.2)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `from should throw error when empty list is provided`() {
+ shouldThrow {
+ BBox.from(listOf())
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/EdgeTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/EdgeTest.kt
new file mode 100644
index 0000000..be9b880
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/EdgeTest.kt
@@ -0,0 +1,88 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.matchers.shouldBe
+import io.kotest.property.Arb
+import io.kotest.property.Exhaustive
+import io.kotest.property.arbitrary.GeoLocation
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.checkAll
+import io.kotest.property.exhaustive.collection
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class EdgeTest {
+ @Test
+ fun `should return true when point is on this edge`() = runTest {
+ val edge = Edge(
+ Position(10.0, 10.0),
+ Position(0.0, 0.0),
+ )
+ checkAll(
+ Arb.double(0.0, 10.0),
+ ) {
+ double: Double ->
+ val actual = edge.isCollinear(Position(double, double))
+ assertTrue(actual)
+ }
+ }
+
+ @Test
+ fun `should return false when point is not on this edge`() = runTest {
+ val edge = Edge(
+ Position(10.0, 10.0),
+ Position(0.0, 0.0),
+ )
+ checkAll(
+ Arb.geoLocation(),
+ ) {
+ geoLocation: GeoLocation ->
+ if (geoLocation.longitude != geoLocation.latitude) {
+ val actual = edge.isCollinear(Position(geoLocation.longitude, geoLocation.latitude))
+ assertFalse(actual)
+ }
+ }
+ }
+
+ @Test
+ fun `should return true when collinear edges touch`() {
+ val edge = Edge(
+ Position(5.0, 0.0),
+ Position(5.0, 1.0),
+ )
+
+ val otherEdge = Edge(
+ Position(5.0, 1.0),
+ Position(5.0, 2.0),
+ )
+
+ edge.isIntersected(otherEdge).shouldBe(true)
+ }
+
+ @Test
+ fun `should return false when collinear edges do not touch`() = runTest {
+ val edge = Edge(
+ Position(5.0, 0.0),
+ Position(5.0, 3.0),
+ )
+
+ val otherEdges = Exhaustive.collection(
+ listOf(
+ Edge(
+ Position(5.0, 4.0),
+ Position(5.0, 5.0),
+ ),
+ Edge(
+ Position(5.0, 5.0),
+ Position(5.0, 4.0),
+ ),
+ ),
+ )
+
+ checkAll(otherEdges) {
+ edge.isIntersected(it).shouldBe(false)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/FeatureCollectionTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/FeatureCollectionTest.kt
new file mode 100644
index 0000000..46c4b90
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/FeatureCollectionTest.kt
@@ -0,0 +1,69 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.common.DelicateKotest
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.bind
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.distinct
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class FeatureCollectionTest {
+ @Test
+ @OptIn(DelicateKotest::class)
+ fun `should derive from FeatureCollection coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+
+ checkAll(
+ lineStringArb,
+ lineStringArb,
+ lineStringArb,
+ ) {
+ firstLineStringCoords: List,
+ secondLineStringCoords: List,
+ thirdLineStringCoords: List,
+ ->
+ val lineString = LineString(firstLineStringCoords)
+ val multiLineString = MultiLineString(listOf(secondLineStringCoords, thirdLineStringCoords))
+ val feature1 = Feature(lineString)
+ val feature2 = Feature(multiLineString)
+ val featureCollection = FeatureCollection(listOf(feature1, feature2))
+ val actual = featureCollection.calculateBBox()
+ val expected = BBox(
+ minOf(
+ firstLineStringCoords.minOf { it.lng },
+ secondLineStringCoords.minOf { it.lng },
+ thirdLineStringCoords.minOf { it.lng },
+ ),
+ minOf(
+ firstLineStringCoords.minOf { it.lat },
+ secondLineStringCoords.minOf { it.lat },
+ thirdLineStringCoords.minOf { it.lat },
+ ),
+ maxOf(
+ firstLineStringCoords.maxOf { it.lng },
+ secondLineStringCoords.maxOf { it.lng },
+ thirdLineStringCoords.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLineStringCoords.maxOf { it.lat },
+ secondLineStringCoords.maxOf { it.lat },
+ thirdLineStringCoords.maxOf { it.lat },
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/FeatureTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/FeatureTest.kt
new file mode 100644
index 0000000..90db831
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/FeatureTest.kt
@@ -0,0 +1,55 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.common.DelicateKotest
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.bind
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.distinct
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class FeatureTest {
+ @Test
+ fun `should fail when creating Feature with an invalid id`() {
+ val err = assertThrows {
+ Feature(geometry = Point(Position(1.0, 1.0)), id = FeatureId.of(mapOf("test" to "1")))
+ }
+ val expected = "invalid value as feature ID: {test=1}"
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ @OptIn(DelicateKotest::class)
+ fun `should derive from Feature geometry coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+ checkAll(
+ lineStringArb,
+ ) {
+ lineStringCoords: List,
+ ->
+ val lineString = LineString(lineStringCoords)
+ val feature = Feature(lineString)
+ val actual = feature.calculateBBox()
+ val expected = BBox(
+ lineStringCoords.minOf { it.lng },
+ lineStringCoords.minOf { it.lat },
+ lineStringCoords.maxOf { it.lng },
+ lineStringCoords.maxOf { it.lat },
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/LineStringTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/LineStringTest.kt
new file mode 100644
index 0000000..93e3644
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/LineStringTest.kt
@@ -0,0 +1,43 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.common.DelicateKotest
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.bind
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.distinct
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class LineStringTest {
+ @Test
+ @OptIn(DelicateKotest::class)
+ fun `should derive from LineString coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+ checkAll(
+ lineStringArb,
+ ) {
+ lineStringCoords: List,
+ ->
+ val lineString = LineString(lineStringCoords)
+ val actual = lineString.calculateBBox()
+ val expected = BBox(
+ lineStringCoords.minOf { it.lng },
+ lineStringCoords.minOf { it.lat },
+ lineStringCoords.maxOf { it.lng },
+ lineStringCoords.maxOf { it.lat },
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/LinearRingTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/LinearRingTest.kt
new file mode 100644
index 0000000..4be7c68
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/LinearRingTest.kt
@@ -0,0 +1,142 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.string.shouldContain
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.constant
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.filter
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class LinearRingTest {
+ @Test
+ fun `should return true when point is on the linear ring's edge`() = runTest {
+ val linearRing = LinearRing(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ )
+ checkAll(
+ Arb.double(0.0, 1.0),
+ Arb.choice(Arb.constant(0.0), Arb.constant(1.0)),
+ ) {
+ lng: Double, lat: Double ->
+ val actual = linearRing.isOnEdge(Position(lng, lat))
+ assertTrue(actual)
+ }
+ }
+
+ @Test
+ fun `should return false when point is not on the linear ring's edge`() = runTest {
+ val linearRing = LinearRing(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ )
+ checkAll(
+ Arb.geoLocation().filter {
+ it.longitude != 0.0 && it.longitude != 1.0
+ }.map {
+ Position(it.longitude, it.latitude)
+ },
+ ) {
+ geoLocation: Position ->
+ val actual = linearRing.isOnEdge(Position(geoLocation.lng, geoLocation.lat))
+ assertFalse(actual)
+ }
+ }
+
+ @Test
+ fun `should return true when point in linear ring is provided`() = runTest {
+ val linearRing = LinearRing(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ )
+ checkAll(
+ Arb.double(0.0, 1.0).filter { it != 0.0 && it != 1.0 },
+ Arb.double(0.0, 1.0).filter { it != 0.0 && it != 1.0 },
+ ) {
+ lng: Double, lat: Double ->
+ val pointInLinearLing = Position(lng, lat)
+ val actual1 = linearRing.isPointInLinearRing(pointInLinearLing)
+ assertTrue(actual1)
+ val actual2 = linearRing.isPointOutsideLinearRing(pointInLinearLing)
+ assertFalse(actual2)
+ }
+ }
+
+ @Test
+ fun `should return false when point outside linear ring is provided`() = runTest {
+ val linearRing = LinearRing(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ )
+ checkAll(
+ Arb.geoLocation().filter {
+ it.longitude !in 0.0..1.0 && it.latitude !in 0.0..1.0
+ }.map {
+ Position(it.longitude, it.latitude)
+ },
+ ) {
+ pointOutsideLinearLing: Position ->
+ val actual1 = linearRing.isPointOutsideLinearRing(pointOutsideLinearLing)
+ assertTrue(actual1)
+ val actual2 = linearRing.isPointInLinearRing(pointOutsideLinearLing)
+ assertFalse(actual2)
+ }
+ }
+
+ @Test
+ fun `should fail when empty list is passed`() = runTest {
+ val expected = "LinearRing cannot be empty"
+ val err = assertThrows { LinearRing(emptyList()) }
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `calculateCentroid should throw error when area is zero`() {
+ val linearRing = LinearRing(
+ listOf(
+ Position(1.0, 1.0),
+ Position(1.0, 1.0),
+ Position(1.0, 1.0),
+ Position(1.0, 1.0),
+ Position(1.0, 1.0),
+ ),
+ )
+
+ val exception = shouldThrow {
+ linearRing.calculateCentroid()
+ }
+
+ exception.message.shouldContain("The area of the polygon is zero.")
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/MultiLineStringTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/MultiLineStringTest.kt
new file mode 100644
index 0000000..ff2e4c1
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/MultiLineStringTest.kt
@@ -0,0 +1,197 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.common.DelicateKotest
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.bind
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.distinct
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class MultiLineStringTest {
+ @Test
+ fun `should split MultiLineString into LineString`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+ checkAll(
+ lineStringArb,
+ lineStringArb,
+ ) {
+ firstLineString: List,
+ secondLineString: List,
+ ->
+ val multiLineString = MultiLineString(listOf(firstLineString, secondLineString))
+ val actual = multiLineString.split()
+ val expectedList1 = LineString(
+ firstLineString,
+ BBox(
+ firstLineString.minOf { it.lng },
+ firstLineString.minOf { it.lat },
+ firstLineString.maxOf { it.lng },
+ firstLineString.maxOf { it.lat },
+ ),
+ )
+ val expectedList2 = LineString(
+ secondLineString,
+ BBox(
+ secondLineString.minOf { it.lng },
+ secondLineString.minOf { it.lat },
+ secondLineString.maxOf { it.lng },
+ secondLineString.maxOf { it.lat },
+ ),
+ )
+ val expected = listOf(expectedList1, expectedList2)
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should be able to add a LineString when a valid LineString is provided`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+ checkAll(
+ lineStringArb,
+ lineStringArb,
+ lineStringArb,
+ ) {
+ firstLineString: List,
+ secondLineString: List,
+ thirdLineString: List,
+ ->
+ val multiLineString = MultiLineString(listOf(firstLineString, secondLineString))
+ val addedLineString = LineString(thirdLineString)
+ val actual = multiLineString.added(addedLineString)
+ val expected = MultiLineString(
+ listOf(firstLineString, secondLineString, thirdLineString),
+ BBox(
+ minOf(
+ firstLineString.minOf { it.lng },
+ secondLineString.minOf { it.lng },
+ thirdLineString.minOf { it.lng },
+ ),
+ minOf(
+ firstLineString.minOf { it.lat },
+ secondLineString.minOf { it.lat },
+ thirdLineString.minOf { it.lat },
+ ),
+ maxOf(
+ firstLineString.maxOf { it.lng },
+ secondLineString.maxOf { it.lng },
+ thirdLineString.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLineString.maxOf { it.lat },
+ secondLineString.maxOf { it.lat },
+ thirdLineString.maxOf { it.lat },
+ ),
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should create a MultiLineString instance when variable length arguments are provided`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+ checkAll(
+ lineStringArb,
+ lineStringArb,
+ ) {
+ firstLineStringCoordinates: List,
+ secondLineStringCoordinates: List,
+ ->
+ val firstLineString = LineString(firstLineStringCoordinates)
+ val secondLineString = LineString(secondLineStringCoordinates)
+ val actual = MultiLineString(firstLineString, secondLineString)
+ val expected = MultiLineString(
+ listOf(firstLineStringCoordinates, secondLineStringCoordinates),
+ BBox(
+ minOf(
+ firstLineStringCoordinates.minOf { it.lng },
+ secondLineStringCoordinates.minOf { it.lng },
+ ),
+ minOf(
+ firstLineStringCoordinates.minOf { it.lat },
+ secondLineStringCoordinates.minOf { it.lat },
+ ),
+ maxOf(
+ firstLineStringCoordinates.maxOf { it.lng },
+ secondLineStringCoordinates.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLineStringCoordinates.maxOf { it.lat },
+ secondLineStringCoordinates.maxOf { it.lat },
+ ),
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ @OptIn(DelicateKotest::class)
+ fun `should derive from MultiLineString coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val lineStringArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third -> listOf(first, second, third) }
+ checkAll(
+ lineStringArb,
+ lineStringArb,
+ ) {
+ firstLineStringCoordinates: List,
+ secondLineStringCoordinates: List,
+ ->
+ val firstLineString = LineString(firstLineStringCoordinates)
+ val secondLineString = LineString(secondLineStringCoordinates)
+ val multiLineString = MultiLineString(firstLineString, secondLineString)
+ val actual = multiLineString.calculateBBox()
+ val expected = BBox(
+ minOf(
+ firstLineStringCoordinates.minOf { it.lng },
+ secondLineStringCoordinates.minOf { it.lng },
+ ),
+ minOf(
+ firstLineStringCoordinates.minOf { it.lat },
+ secondLineStringCoordinates.minOf { it.lat },
+ ),
+ maxOf(
+ firstLineStringCoordinates.maxOf { it.lng },
+ secondLineStringCoordinates.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLineStringCoordinates.maxOf { it.lat },
+ secondLineStringCoordinates.maxOf { it.lat },
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/MultiPointTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/MultiPointTest.kt
new file mode 100644
index 0000000..f82e490
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/MultiPointTest.kt
@@ -0,0 +1,137 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class MultiPointTest {
+ @Test
+ fun `should split MultiPoint into Point`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ checkAll(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) {
+ position1: Position,
+ position2: Position,
+ position3: Position,
+ ->
+ val multiPoint = MultiPoint(
+ listOf(
+ position1,
+ position2,
+ position3,
+ ),
+ )
+ val actual = multiPoint.split()
+ val expected = listOf(Point(position1), Point(position2), Point(position3))
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should be able to add to MultiPoint when a valid Point is provided`() = runTest {
+ val multiPoint = MultiPoint(
+ listOf(Position(1.0, 1.0), Position(1.0, 2.0)),
+ )
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ checkAll(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) {
+ position1: Position,
+ position2: Position,
+ ->
+ val addedPoint1 = Point(position1)
+ val addedPoint2 = Point(position2)
+ val actual = multiPoint.added(addedPoint1, addedPoint2)
+ val expected = MultiPoint(
+ listOf(
+ Position(1.0, 1.0),
+ Position(1.0, 2.0),
+ position1,
+ position2,
+ ),
+ bbox = BBox(
+ minOf(1.0, position1.lng, position2.lng),
+ minOf(1.0, position1.lat, position2.lat),
+ maxOf(1.0, position1.lng, position2.lng),
+ maxOf(2.0, position1.lat, position2.lat),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should create a MultiPoint instance when variable length arguments are provided`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ checkAll(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) {
+ position1: Position,
+ position2: Position,
+ position3: Position,
+ ->
+ val point1 = Point(position1)
+ val point2 = Point(position2)
+ val point3 = Point(position3)
+ val actual = MultiPoint(point1, point2, point3)
+ val expected = MultiPoint(
+ listOf(position1, position2, position3),
+ BBox(
+ minOf(position1.lng, position2.lng, position3.lng),
+ minOf(position1.lat, position2.lat, position3.lat),
+ maxOf(position1.lng, position2.lng, position3.lng),
+ maxOf(position1.lat, position2.lat, position3.lat),
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should derive from MultiPoint coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }
+ checkAll(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) {
+ position1: Position,
+ position2: Position,
+ position3: Position,
+ ->
+ val point1 = Point(position1)
+ val point2 = Point(position2)
+ val point3 = Point(position3)
+ val multiPoint = MultiPoint(point1, point2, point3)
+ val actual = multiPoint.calculateBBox()
+ val expected =
+ BBox(
+ minOf(position1.lng, position2.lng, position3.lng),
+ minOf(position1.lat, position2.lat, position3.lat),
+ maxOf(position1.lng, position2.lng, position3.lng),
+ maxOf(position1.lat, position2.lat, position3.lat),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/MultiPolygonTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/MultiPolygonTest.kt
new file mode 100644
index 0000000..044632a
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/MultiPolygonTest.kt
@@ -0,0 +1,297 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.common.DelicateKotest
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.bind
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.constant
+import io.kotest.property.arbitrary.distinct
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.filter
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class MultiPolygonTest {
+ @Test
+ fun `contains should return true when a point in the MultiPolygon is passed`() = runTest {
+ val multiPolygon = MultiPolygon(
+ Polygon(
+ listOf(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ ),
+ ),
+ ),
+ Polygon(
+ listOf(
+ listOf(
+ Position(5.0, 5.0),
+ Position(6.0, 5.0),
+ Position(6.0, 6.0),
+ Position(5.0, 6.0),
+ Position(5.0, 5.0),
+ ),
+ ),
+ ),
+ )
+
+ checkAll(
+ Arb.double(0.0, 1.0).filter { it !in 2.0..8.0 },
+ Arb.double(0.0, 1.0).filter { it !in 0.2..8.0 },
+ ) {
+ lng: Double, lat: Double ->
+ val inPoint = Point(Position(lng, lat))
+ assertTrue(multiPolygon.contains(inPoint))
+ }
+ }
+
+ @Test
+ fun `contains should return true when a point on one of the MultiPolygon's edges is passed`() = runTest {
+ val multiPolygon = MultiPolygon(
+ Polygon(
+ listOf(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ ),
+ ),
+ ),
+ Polygon(
+ listOf(
+ listOf(
+ Position(5.0, 5.0),
+ Position(6.0, 5.0),
+ Position(6.0, 6.0),
+ Position(5.0, 6.0),
+ Position(5.0, 5.0),
+ ),
+ ),
+ ),
+ )
+
+ checkAll(
+ Arb.double(0.0, 1.0),
+ Arb.choice(Arb.constant(0.0), Arb.constant(1.0)),
+ ) {
+ lng: Double, lat: Double ->
+ val inPoint = Point(Position(lng, lat))
+ assertTrue(multiPolygon.contains(inPoint))
+ }
+ }
+
+ @OptIn(DelicateKotest::class)
+ @Test
+ fun `should split MultiPolygon into Polygon`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val polygonArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third, fourth -> listOf(first, second, third, fourth, first) }
+ checkAll(
+ polygonArb,
+ polygonArb,
+ ) {
+ firstLinearRing: List,
+ secondLinearRing: List,
+ ->
+ val multiPolygon = MultiPolygon(listOf(listOf(firstLinearRing), listOf(secondLinearRing)))
+ val actual = multiPolygon.split()
+ val expectedPolygon1 = Polygon(
+ listOf(firstLinearRing),
+ BBox(
+ firstLinearRing.minOf { it.lng },
+ firstLinearRing.minOf { it.lat },
+ firstLinearRing.maxOf { it.lng },
+ firstLinearRing.maxOf { it.lat },
+ ),
+ )
+ val expectedPolygon2 = Polygon(
+ listOf(secondLinearRing),
+ BBox(
+ secondLinearRing.minOf { it.lng },
+ secondLinearRing.minOf { it.lat },
+ secondLinearRing.maxOf { it.lng },
+ secondLinearRing.maxOf { it.lat },
+ ),
+ )
+ val expected = listOf(expectedPolygon1, expectedPolygon2)
+ assertEquals(expected, actual)
+ }
+ }
+
+ @OptIn(DelicateKotest::class)
+ @Test
+ fun `should be able to add to MultiPolygon when a valid Polygon is provided`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val polygonArb: Arb> = Arb.bind(
+ positionArb,
+ positionArb,
+ positionArb,
+ positionArb,
+ ) { first, second, third, fourth -> listOf(first, second, third, fourth, first) }
+ checkAll(
+ polygonArb,
+ polygonArb,
+ polygonArb,
+ ) {
+ firstLinearRing: List,
+ secondLinearRing: List,
+ thirdLinearRing: List,
+ ->
+ val multiPolygon = MultiPolygon(listOf(listOf(firstLinearRing), listOf(secondLinearRing)))
+ val addedPolygon = Polygon(listOf(thirdLinearRing))
+ val actual = multiPolygon.added(addedPolygon)
+ val expected = MultiPolygon(
+ listOf(listOf(firstLinearRing), listOf(secondLinearRing), listOf(thirdLinearRing)),
+ BBox(
+ minOf(
+ firstLinearRing.minOf { it.lng },
+ secondLinearRing.minOf { it.lng },
+ thirdLinearRing.minOf { it.lng },
+ ),
+ minOf(
+ firstLinearRing.minOf { it.lat },
+ secondLinearRing.minOf { it.lat },
+ thirdLinearRing.minOf { it.lat },
+ ),
+ maxOf(
+ firstLinearRing.maxOf { it.lng },
+ secondLinearRing.maxOf { it.lng },
+ thirdLinearRing.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLinearRing.maxOf { it.lat },
+ secondLinearRing.maxOf { it.lat },
+ thirdLinearRing.maxOf { it.lat },
+ ),
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+
+ @OptIn(DelicateKotest::class)
+ @Test
+ fun `should create a MultiPolygon instance when variable length arguments are provided`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val polygonArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third, fourth -> listOf(first, second, third, fourth, first) }
+ checkAll(
+ polygonArb,
+ polygonArb,
+ ) {
+ firstLinearRing: List,
+ secondLinearRing: List,
+ ->
+ val firstPolygon = Polygon(listOf(firstLinearRing))
+ val secondPolygon = Polygon(listOf(secondLinearRing))
+ val actual = MultiPolygon(firstPolygon, secondPolygon)
+ val expected = MultiPolygon(
+ listOf(listOf(firstLinearRing), listOf(secondLinearRing)),
+ BBox(
+ minOf(
+ firstLinearRing.minOf { it.lng },
+ secondLinearRing.minOf { it.lng },
+ ),
+ minOf(
+ firstLinearRing.minOf { it.lat },
+ secondLinearRing.minOf { it.lat },
+ ),
+ maxOf(
+ firstLinearRing.maxOf { it.lng },
+ secondLinearRing.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLinearRing.maxOf { it.lat },
+ secondLinearRing.maxOf { it.lat },
+ ),
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+
+ @OptIn(DelicateKotest::class)
+ @Test
+ fun `should derive from MultiPolygon coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val polygonArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third, fourth -> listOf(first, second, third, fourth, first) }
+ checkAll(
+ polygonArb,
+ polygonArb,
+ ) {
+ firstLinearRing: List,
+ secondLinearRing: List,
+ ->
+ val firstPolygon = Polygon(listOf(firstLinearRing))
+ val secondPolygon = Polygon(listOf(secondLinearRing))
+ val multiPolygon = MultiPolygon(firstPolygon, secondPolygon)
+ val actual = multiPolygon.calculateBBox()
+ val expected =
+ BBox(
+ minOf(
+ firstLinearRing.minOf { it.lng },
+ secondLinearRing.minOf { it.lng },
+ ),
+ minOf(
+ firstLinearRing.minOf { it.lat },
+ secondLinearRing.minOf { it.lat },
+ ),
+ maxOf(
+ firstLinearRing.maxOf { it.lng },
+ secondLinearRing.maxOf { it.lng },
+ ),
+ maxOf(
+ firstLinearRing.maxOf { it.lat },
+ secondLinearRing.maxOf { it.lat },
+ ),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/PointTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/PointTest.kt
new file mode 100644
index 0000000..56ab766
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/PointTest.kt
@@ -0,0 +1,160 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.matchers.shouldBe
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.GeoLocation
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.constant
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.filter
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class PointTest {
+ @Test
+ fun `should return true when a point in the polygon is passed`() = runTest {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole = listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ )
+ val polygon = Polygon(listOf(ring, hole))
+ checkAll(
+ Arb.double(0.0, 1.0).filter { it !in 2.0..8.0 },
+ Arb.double(0.0, 1.0).filter { it !in 0.2..8.0 },
+ ) {
+ lng: Double, lat: Double ->
+ val inPoint = Point(Position(lng, lat))
+ assertTrue(inPoint.isIn(polygon))
+ }
+ }
+
+ @Test
+ fun `should return false when a point on the polygon's edge is passed`() = runTest {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole = listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ )
+ val polygon = Polygon(listOf(ring, hole))
+ checkAll(
+ Arb.double(0.0, 1.0),
+ Arb.choice(Arb.constant(0.0), Arb.constant(1.0)),
+ ) {
+ lng: Double, lat: Double ->
+ val onEdgePoint = Point(Position(lng, lat))
+ val actual = onEdgePoint.isIn(polygon, allowOnEdge = false)
+ assertFalse(actual)
+ }
+ }
+
+ @Test
+ fun `should return true when a point in the MultiPolygon is passed`() = runTest {
+ val multiPolygon = MultiPolygon(
+ Polygon(
+ listOf(
+ listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ ),
+ listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ ),
+ ),
+ ),
+ Polygon(
+ listOf(
+ listOf(
+ Position(5.0, 5.0),
+ Position(6.0, 5.0),
+ Position(6.0, 6.0),
+ Position(5.0, 6.0),
+ Position(5.0, 5.0),
+ ),
+ ),
+ ),
+ )
+
+ checkAll(
+ Arb.double(0.0, 1.0).filter { it !in 2.0..8.0 },
+ Arb.double(0.0, 1.0).filter { it !in 0.2..8.0 },
+ ) {
+ lng: Double, lat: Double ->
+ val inPoint = Point(Position(lng, lat))
+ assertTrue(inPoint.isIn(multiPolygon))
+ }
+ }
+
+ @Test
+ fun `bounding box should be equivalent to the point`() {
+ val point = Point(Position(0.0, 1.1, 2.2))
+ val expected = BBox(
+ point.coordinates.lng,
+ point.coordinates.lat,
+ point.coordinates.lng,
+ point.coordinates.lat,
+ point.coordinates.alt,
+ point.coordinates.alt,
+ )
+
+ point.calculateBBox().shouldBe(expected)
+ }
+
+ @Test
+ fun `should return true when point is on an edge`() = runTest {
+ val edge = Edge(
+ Position(10.0, 10.0),
+ Position(0.0, 0.0),
+ )
+ checkAll(
+ Arb.double(0.0, 10.0),
+ ) { double: Double ->
+ Point(Position(double, double)).isOn(edge)
+ }
+ }
+
+ @Test
+ fun `should return false when point is not on an edge`() = runTest {
+ val edge = Edge(
+ Position(10.0, 10.0),
+ Position(0.0, 0.0),
+ )
+ checkAll(
+ Arb.geoLocation(),
+ ) {
+ geoLocation: GeoLocation ->
+ if (geoLocation.longitude != geoLocation.latitude) {
+ Point(Position(geoLocation.longitude, geoLocation.latitude)).isOn(edge).shouldBe(false)
+ }
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/PolygonTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/PolygonTest.kt
new file mode 100644
index 0000000..968c701
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/PolygonTest.kt
@@ -0,0 +1,144 @@
+package jp.co.lycorp.geojson
+
+import io.kotest.common.DelicateKotest
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.bind
+import io.kotest.property.arbitrary.choice
+import io.kotest.property.arbitrary.constant
+import io.kotest.property.arbitrary.distinct
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.filter
+import io.kotest.property.arbitrary.geoLocation
+import io.kotest.property.arbitrary.map
+import io.kotest.property.checkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+class PolygonTest {
+ @Test
+ fun `contains should return true when a point in the polygon is passed`() = runTest {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole = listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ )
+ val polygon = Polygon(listOf(ring, hole))
+ checkAll(
+ Arb.double(0.0, 1.0).filter { it !in 2.0..8.0 },
+ Arb.double(0.0, 1.0).filter { it !in 0.2..8.0 },
+ ) {
+ lng: Double, lat: Double ->
+ val inPoint = Point(Position(lng, lat))
+ assertTrue(polygon.contains(inPoint))
+ }
+ }
+
+ @Test
+ fun `contains should return false when allowEdge = false and point on the polygon's edge is passed`() =
+ runTest {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole = listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ )
+ val polygon = Polygon(listOf(ring, hole))
+ checkAll(
+ Arb.double(0.0, 1.0),
+ Arb.choice(Arb.constant(0.0), Arb.constant(1.0)),
+ ) {
+ lng: Double, lat: Double ->
+ val onEdgePoint = Point(Position(lng, lat))
+ val actual = polygon.contains(onEdgePoint, allowOnEdge = false)
+ assertFalse(actual)
+ }
+ }
+
+ @Test
+ fun `should be able to calculate centroid`() {
+ val square = Polygon(
+ listOf(
+ listOf(
+ Position(1.0, 1.0),
+ Position(-1.0, 1.0),
+ Position(-1.0, -1.0),
+ Position(1.0, -1.0),
+ Position(1.0, 1.0),
+ ),
+ ),
+ )
+ val regularHexagon = Polygon(
+ listOf(
+ listOf(
+ Position(1.0, 0.0),
+ Position(0.5, sqrt(3.0) / 2),
+ Position(-0.5, sqrt(3.0) / 2),
+ Position(-1.0, 0.0),
+ Position(-0.5, -sqrt(3.0) / 2),
+ Position(0.5, -sqrt(3.0) / 2),
+ Position(1.0, 0.0),
+ ),
+ ),
+ )
+
+ val actual1 = square.calculateCentroid()
+ val expected1 = Position(0.0, 0.0)
+ assertEquals(expected1, actual1)
+ val actual2 = regularHexagon.calculateCentroid()
+ val expected2 = Position(0.0, 0.0)
+ assertEquals(expected2.lng, actual2.lng, 10.0.pow(18.0))
+ assertEquals(expected2.lat, actual2.lat, 10.0.pow(18.0))
+ }
+
+ @OptIn(DelicateKotest::class)
+ @Test
+ fun `should derive from Polygon coordinates`() = runTest {
+ val positionArb: Arb = Arb.geoLocation().map {
+ Position(it.longitude, it.latitude)
+ }.distinct()
+ val polygonArb: Arb> = Arb.bind(
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ Arb.choice(positionArb),
+ ) { first, second, third, fourth -> listOf(first, second, third, fourth, first) }
+ checkAll(
+ polygonArb,
+ ) {
+ linearRing: List,
+ ->
+ val polygon = Polygon(listOf(linearRing))
+ val actual = polygon.calculateBBox()
+ val expected =
+ BBox(
+ minOf(linearRing.minOf { it.lng }),
+ minOf(linearRing.minOf { it.lat }),
+ maxOf(linearRing.maxOf { it.lng }),
+ maxOf(linearRing.maxOf { it.lat }),
+ )
+ assertEquals(expected, actual)
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/extensions/JsonUtils.kt b/src/test/kotlin/jp/co/lycorp/geojson/extensions/JsonUtils.kt
new file mode 100644
index 0000000..951adb2
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/extensions/JsonUtils.kt
@@ -0,0 +1,16 @@
+package jp.co.lycorp.geojson.extensions
+
+/**
+ * Class containing a collection of utility functions for Json
+ */
+internal object JsonUtils {
+ /**
+ * Converts the string to a compacted JSON format.
+ *
+ * This function removes indentation and whitespace
+ * from the string to produce a compact JSON representation.
+ */
+ fun String.toCompactedJson(): String {
+ return this.replace("\\s".toRegex(), "")
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/BBoxDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/BBoxDeserializationTest.kt
new file mode 100644
index 0000000..75d603d
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/BBoxDeserializationTest.kt
@@ -0,0 +1,62 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.string.shouldContain
+import io.kotest.property.Exhaustive
+import io.kotest.property.checkAll
+import io.kotest.property.exhaustive.collection
+import jp.co.lycorp.geojson.BBox
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.io.IOException
+
+class BBoxDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to BBox when valid GeoJSON is provided()`() {
+ val geoJson = "[0.0, 0.0, 1.0, 1.0]".replace("\\s".toRegex(), "")
+ val actual = mapper.readValue(geoJson, BBox::class.java)
+ val expected = BBox(0.0, 0.0, 1.0, 1.0)
+ assertTrue(actual is BBox)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to BBox when valid GeoJSON with alt is provided`() {
+ val geoJson = "[0.0, 0.0, -44.0, 1.0, 1.0, 44.0]".replace("\\s".toRegex(), "")
+ val actual = mapper.readValue(geoJson, BBox::class.java)
+ val expected = BBox(0.0, 0.0, 1.0, 1.0, -44.0, 44.0)
+ assertTrue(actual is BBox)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should throw error when not array`() = runTest {
+ val testCases = Exhaustive.collection(listOf("{}", "\"foobar\"", "17"))
+
+ checkAll(testCases) {
+ val exception = shouldThrow {
+ mapper.readValue(it, BBox::class.java)
+ }
+
+ exception.message.shouldContain("Unable to deserialize bbox: no array found")
+ }
+ }
+
+ @Test
+ fun `should throw error when array size is invalid`() = runTest {
+ val testCases = Exhaustive.collection(listOf("[]", "[0.0, 0.1]", "[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]"))
+
+ checkAll(testCases) {
+ val exception = shouldThrow {
+ mapper.readValue(it, BBox::class.java)
+ }
+
+ exception.message.shouldContain("Unexpected coordinate array size:")
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/BBoxSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/BBoxSerializationTest.kt
new file mode 100644
index 0000000..f3419f6
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/BBoxSerializationTest.kt
@@ -0,0 +1,26 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class BBoxSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize BBox when valid BBox is provided`() {
+ val bbox = BBox(0.0, 0.0, 1.0, 1.0)
+ val actual = mapper.writeValueAsString(bbox)
+ val expected = "[0.0, 0.0, 1.0, 1.0]".replace("\\s".toRegex(), "")
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize BBox when valid BBox with alt is provided`() {
+ val bbox = BBox(0.0, 0.0, 1.0, 1.0, -44.0, 44.0)
+ val actual = mapper.writeValueAsString(bbox)
+ val expected = "[0.0, 0.0, -44.0, 1.0, 1.0, 44.0]".replace("\\s".toRegex(), "")
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureCollectionDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureCollectionDeserializationTest.kt
new file mode 100644
index 0000000..8ac2b6e
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureCollectionDeserializationTest.kt
@@ -0,0 +1,124 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Feature
+import jp.co.lycorp.geojson.FeatureCollection
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class FeatureCollectionDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to FeatureCollection when valid GeoJSON with id is provided`() {
+ val geoJson = """{
+ "type": "FeatureCollection",
+ "features": [{
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [2.0, 0.5]
+ },
+ "properties": {
+ "prop0": "value0"
+ }
+ }, {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [2.0, 0.0],
+ [3.0, 1.0],
+ [4.0, 0.0],
+ [5.0, 1.0]
+ ]
+ },
+ "properties": {
+ "prop0": "value0",
+ "prop1": 0.0
+ }
+ }]
+ }
+ """.toCompactedJson()
+
+ val actual = mapper.readValue(geoJson, FeatureCollection::class.java)
+ val feature1 = Feature(
+ geometry = Point(Position(2.0, 0.5)),
+ properties = mapOf("prop0" to "value0"),
+ )
+ val feature2 = Feature(
+ geometry = LineString(
+ listOf(
+ Position(2.0, 0.0),
+ Position(3.0, 1.0),
+ Position(4.0, 0.0),
+ Position(5.0, 1.0),
+ ),
+ ),
+ properties = mapOf("prop0" to "value0", "prop1" to 0.0),
+ )
+ val expected = FeatureCollection(listOf(feature1, feature2))
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to FeatureCollection when valid GeoJSON with bbox is provided`() {
+ val geoJson = """{
+ "type": "FeatureCollection",
+ "bbox":[0.0, 0.0, 5.0, 1.0],
+ "features": [{
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [2.0, 0.5]
+ },
+ "properties": {
+ "prop0": "value0"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [2.0, 0.0],
+ [3.0, 1.0],
+ [4.0, 0.0],
+ [5.0, 1.0]
+ ]},
+ "properties": {
+ "prop0": "value0",
+ "prop1": 0.0
+ }
+ }]
+ }
+ """.toCompactedJson()
+
+ val actual = mapper.readValue(geoJson, FeatureCollection::class.java)
+ val feature1 = Feature(
+ geometry = Point(Position(2.0, 0.5)),
+ properties = mapOf("prop0" to "value0"),
+ )
+ val feature2 = Feature(
+ geometry = LineString(
+ listOf(
+ Position(2.0, 0.0),
+ Position(3.0, 1.0),
+ Position(4.0, 0.0),
+ Position(5.0, 1.0),
+ ),
+ ),
+ properties = mapOf("prop0" to "value0", "prop1" to 0.0),
+ )
+ val expected = FeatureCollection(
+ listOf(feature1, feature2),
+ bbox = BBox(0.0, 0.0, 5.0, 1.0),
+ )
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureCollectionSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureCollectionSerializationTest.kt
new file mode 100644
index 0000000..be32c2e
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureCollectionSerializationTest.kt
@@ -0,0 +1,123 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Feature
+import jp.co.lycorp.geojson.FeatureCollection
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class FeatureCollectionSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize FeatureCollection when valid feature collection with id is provided`() {
+ val feature1 = Feature(
+ geometry = Point(Position(2.0, 0.5)),
+ properties = mapOf("prop0" to "value0"),
+ )
+ val feature2 = Feature(
+ geometry = LineString(
+ listOf(
+ Position(2.0, 0.0),
+ Position(3.0, 1.0),
+ Position(4.0, 0.0),
+ Position(5.0, 1.0),
+ ),
+ ),
+ properties = mapOf("prop0" to "value0", "prop1" to 0.0),
+ )
+ val featureCollection = FeatureCollection(listOf(feature1, feature2))
+ val actual = mapper.writeValueAsString(featureCollection)
+ val expected = """{
+ "features": [{
+ "geometry": {
+ "coordinates": [2.0, 0.5],
+ "type": "Point"
+ },
+ "properties": {
+ "prop0": "value0"
+ },
+ "type": "Feature"
+ }, {
+ "geometry": {
+ "coordinates": [
+ [2.0, 0.0],
+ [3.0, 1.0],
+ [4.0, 0.0],
+ [5.0, 1.0]
+ ],
+ "type": "LineString"
+ },
+ "properties": {
+ "prop0": "value0",
+ "prop1": 0.0
+ },
+ "type": "Feature"
+ }],
+ "type": "FeatureCollection"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize FeatureCollection when valid feature collection with BBox is provided`() {
+ val feature1 = Feature(
+ geometry = Point(Position(2.0, 0.5)),
+ properties = mapOf("prop0" to "value0"),
+ )
+ val feature2 = Feature(
+ geometry = LineString(
+ listOf(
+ Position(2.0, 0.0),
+ Position(3.0, 1.0),
+ Position(4.0, 0.0),
+ Position(5.0, 1.0),
+ ),
+ ),
+ properties = mapOf("prop0" to "value0", "prop1" to 0.0),
+ )
+ val featureCollection = FeatureCollection(
+ listOf(feature1, feature2),
+ bbox = BBox(0.0, 0.0, 5.0, 1.0),
+ )
+ val actual = mapper.writeValueAsString(featureCollection)
+ val expected = """{
+ "features": [{
+ "geometry": {
+ "coordinates": [2.0, 0.5],
+ "type": "Point"
+ },
+ "properties": {
+ "prop0": "value0"
+ },
+ "type": "Feature"
+ },
+ {
+ "geometry": {
+ "coordinates": [
+ [2.0, 0.0],
+ [3.0, 1.0],
+ [4.0, 0.0],
+ [5.0, 1.0]
+ ],
+ "type": "LineString"
+ },
+ "properties": {
+ "prop0": "value0",
+ "prop1": 0.0
+ },
+ "type": "Feature"
+ }],
+ "bbox":[0.0, 0.0, 5.0, 1.0],
+ "type": "FeatureCollection"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureDeserializationTest.kt
new file mode 100644
index 0000000..a396a45
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureDeserializationTest.kt
@@ -0,0 +1,142 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.string.shouldContain
+import io.kotest.property.Exhaustive
+import io.kotest.property.checkAll
+import io.kotest.property.exhaustive.collection
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Feature
+import jp.co.lycorp.geojson.FeatureId
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Polygon
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.io.IOException
+
+class FeatureDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to Feature when valid GeoJSON with id of a string is provided`() {
+ val geoJson = """{
+ "type": "Feature",
+ "id": "21",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [10.0, 0.0]
+ }
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Feature::class.java)
+ val expected = Feature(id = FeatureId.of("21"), geometry = Point(Position(10.0, 0.0)))
+ assertEquals(expected, actual)
+ assertEquals(FeatureId.of("21"), actual.id)
+ assertTrue(actual.properties == null)
+ assertTrue(actual.geometry is Point)
+ }
+
+ @Test
+ fun `should deserialize to Feature when valid GeoJSON with numeric id is provided`() {
+ val geoJson = """{
+ "type": "Feature",
+ "id": 21,
+ "geometry": {
+ "type": "Point",
+ "coordinates": [10.0, 0.0]
+ }
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Feature::class.java)
+ val expected = Feature(id = FeatureId.of(21), geometry = Point(Position(10.0, 0.0)))
+ assertEquals(expected, actual)
+ assertEquals(FeatureId.of(21), actual.id)
+ assertTrue(actual.properties == null)
+ assertTrue(actual.geometry is Point)
+ }
+
+ @Test
+ fun `should deserialize Feature when valid Feature with BBox is provided`() {
+ val geoJson = """{
+ "geometry": {
+ "coordinates": [
+ [
+ [-10.0, -10.0],
+ [10.0, -10.0],
+ [10.0, 10.0],
+ [-10.0, -10.0]
+ ]
+ ],
+ "type": "Polygon"
+ },
+ "bbox": [-10.0, -10.0, 10.0, 10.0],
+ "type": "Feature"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Feature::class.java)
+ val expected = Feature(
+ geometry = Polygon(
+ listOf(
+ listOf(
+ Position(-10.0, -10.0),
+ Position(10.0, -10.0),
+ Position(10.0, 10.0),
+ Position(-10.0, -10.0),
+ ),
+ ),
+ ),
+ bbox = BBox(-10.0, -10.0, 10.0, 10.0),
+ )
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should throw error when id type is invalid`() = runTest {
+ val testCases = Exhaustive.collection(
+ listOf(
+ """
+ {
+ "type": "Feature",
+ "id": {},
+ "geometry": {
+ "type": "Point",
+ "coordinates": [10.0, 0.0]
+ }
+ }
+ """.toCompactedJson(),
+ """
+ {
+ "type": "Feature",
+ "id": []
+ "geometry": {
+ "type": "Point",
+ "coordinates": [10.0, 0.0]
+ }
+ }
+ """.toCompactedJson(),
+ """
+ {
+ "type": "Feature",
+ "id": true,
+ "geometry": {
+ "type": "Point",
+ "coordinates": [10.0, 0.0]
+ }
+ }
+ """.toCompactedJson(),
+ ),
+ )
+
+ checkAll(testCases) {
+ val exception = shouldThrow {
+ mapper.readValue(it, Feature::class.java)
+ }
+ exception.message.shouldContain("Only String or Number is allowed in the id field")
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureSerializationTest.kt
new file mode 100644
index 0000000..644da9c
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/FeatureSerializationTest.kt
@@ -0,0 +1,67 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Feature
+import jp.co.lycorp.geojson.FeatureId
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Polygon
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class FeatureSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize Feature when valid Feature with id of a string is provided`() {
+ val feature = Feature(id = FeatureId.of(1), geometry = Point(Position(10.0, 0.0)))
+ val actual = mapper.writeValueAsString(feature)
+ val expected = """{
+ "geometry": {
+ "coordinates": [10.0, 0.0],
+ "type": "Point"
+ },
+ "id": 1,
+ "type": "Feature"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize Feature when valid Feature with BBox is provided`() {
+ val feature = Feature(
+ geometry = Polygon(
+ listOf(
+ listOf(
+ Position(-10.0, -10.0),
+ Position(10.0, -10.0),
+ Position(10.0, 10.0),
+ Position(-10.0, -10.0),
+ ),
+ ),
+ ),
+ bbox = BBox(-10.0, -10.0, 10.0, 10.0),
+ )
+ val actual = mapper.writeValueAsString(feature)
+ val expected = """{
+ "geometry": {
+ "coordinates": [
+ [
+ [-10.0, -10.0],
+ [10.0, -10.0],
+ [10.0, 10.0],
+ [-10.0, -10.0]
+ ]
+ ],
+ "type": "Polygon"
+ },
+ "bbox": [-10.0, -10.0, 10.0, 10.0],
+ "type": "Feature"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/GeometryCollectionDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/GeometryCollectionDeserializationTest.kt
new file mode 100644
index 0000000..c5f6235
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/GeometryCollectionDeserializationTest.kt
@@ -0,0 +1,48 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.GeometryCollection
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class GeometryCollectionDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to GeometryCollection when valid GeoJSON is provided`() {
+ val geoJson = """
+ {
+ "geometries": [
+ {
+ "type": "Point",
+ "coordinates": [10.0, 0.0]
+ },
+ {
+ "type": "LineString",
+ "coordinates": [
+ [1.0, 0.0],
+ [2.0, 1.0]
+ ]
+ }
+ ],
+ "type": "GeometryCollection"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, GeometryCollection::class.java)
+ val point = Point(Position(10.0, 0.0))
+ val lineString = LineString(
+ listOf(
+ Position(1.0, 0.0),
+ Position(2.0, 1.0),
+ ),
+ )
+ val expected = GeometryCollection(listOf(point, lineString))
+ assertTrue(actual is GeometryCollection)
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/GeometryCollectionSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/GeometryCollectionSerializationTest.kt
new file mode 100644
index 0000000..ba680d9
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/GeometryCollectionSerializationTest.kt
@@ -0,0 +1,46 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.GeometryCollection
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class GeometryCollectionSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize GeometryCollection when valid GeometryCollection is provided`() {
+ val point = Point(Position(10.0, 0.0))
+ val lineString = LineString(
+ listOf(
+ Position(1.0, 0.0),
+ Position(2.0, 1.0),
+ ),
+ )
+ val geometryCollection = GeometryCollection(listOf(point, lineString))
+ val actual = mapper.writeValueAsString(geometryCollection)
+ val expected = """
+ {
+ "geometries": [
+ {
+ "coordinates": [10.0, 0.0],
+ "type": "Point"
+ },
+ {
+ "coordinates": [
+ [1.0, 0.0],
+ [2.0, 1.0]
+ ],
+ "type": "LineString"
+ }
+ ],
+ "type": "GeometryCollection"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/LineStringDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/LineStringDeserializationTest.kt
new file mode 100644
index 0000000..82ef0d7
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/LineStringDeserializationTest.kt
@@ -0,0 +1,60 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class LineStringDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to LineString when valid GeoJSON is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "type": "LineString"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, LineString::class.java)
+ val expected = LineString(listOf(Position(1.0, 1.0), Position(1.0, 2.0)))
+ assertTrue(actual is LineString)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to LineString when valid GeoJSON with alt is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [[1.0, 1.0, 0.0], [1.0, 2.0, 1.0]],
+ "type": "LineString"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, LineString::class.java)
+ val expected = LineString(listOf(Position(1.0, 1.0, 0.0), Position(1.0, 2.0, 1.0)))
+ assertTrue(actual is LineString)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to LineString when valid GeoJSON with bbox is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "bbox": [0.0, 0.0, 1.0, 1.0],
+ "type": "LineString"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, LineString::class.java)
+ val expected = LineString(
+ listOf(Position(1.0, 1.0), Position(1.0, 2.0)),
+ bbox = BBox(0.0, 0.0, 1.0, 1.0),
+ )
+ assertTrue(actual is LineString)
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/LineStringSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/LineStringSerializationTest.kt
new file mode 100644
index 0000000..e399fb2
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/LineStringSerializationTest.kt
@@ -0,0 +1,59 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class LineStringSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize LineString when valid LineString is provided`() {
+ val lineString = LineString(listOf(Position(1.0, 1.0), Position(1.0, 2.0)))
+ val actual = mapper.writeValueAsString(lineString)
+ val expected = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "type": "LineString"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize LineString when valid LineString with alt is provided`() {
+ val lineString = LineString(listOf(Position(1.0, 1.0, 0.0), Position(1.0, 2.0, 1.0)))
+ val actual = mapper.writeValueAsString(lineString)
+ val expected = """
+ {
+ "coordinates": [[1.0, 1.0,0.0], [1.0, 2.0,1.0]],
+ "type": "LineString"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize LineString when valid LineString with BBox is provided`() {
+ val lineString = LineString(
+ listOf(
+ Position(1.0, 1.0),
+ Position(1.0, 2.0),
+ ),
+ bbox = BBox(0.0, 0.0, 1.0, 1.0),
+ )
+ val actual = mapper.writeValueAsString(lineString)
+ val expected = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "bbox": [0.0, 0.0, 1.0, 1.0],
+ "type": "LineString"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiLineStringDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiLineStringDeserializationTest.kt
new file mode 100644
index 0000000..69d58a4
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiLineStringDeserializationTest.kt
@@ -0,0 +1,80 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.MultiLineString
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class MultiLineStringDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to MultiLineString when valid GeoJSON is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [
+ [
+ [10.0, 0.0],
+ [1.0, 1.0]
+ ],
+ [
+ [2.0, 2.0],
+ [3.0, 3.0]
+ ]
+ ],
+ "type": "MultiLineString"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, MultiLineString::class.java)
+ val lineString1 = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 1.0),
+ )
+ val lineString2 = listOf(
+ Position(2.0, 2.0),
+ Position(3.0, 3.0),
+ )
+ val expected = MultiLineString(listOf(lineString1, lineString2))
+ assertTrue(actual is MultiLineString)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to MultiLineString when valid GeoJSON with bbox is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [
+ [
+ [10.0, 0.0],
+ [1.0, 1.0]
+ ],
+ [
+ [2.0, 2.0],
+ [3.0, 3.0]
+ ]
+ ],
+ "bbox":[10.0, 0.0, 3.0, 3.0],
+ "type": "MultiLineString"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, MultiLineString::class.java)
+ val lineString1 = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 1.0),
+ )
+ val lineString2 = listOf(
+ Position(2.0, 2.0),
+ Position(3.0, 3.0),
+ )
+ val expected = MultiLineString(
+ listOf(lineString1, lineString2),
+ bbox = BBox(10.0, 0.0, 3.0, 3.0),
+ )
+ assertTrue(actual is MultiLineString)
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiLineStringSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiLineStringSerializationTest.kt
new file mode 100644
index 0000000..d759d2b
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiLineStringSerializationTest.kt
@@ -0,0 +1,77 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.MultiLineString
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class MultiLineStringSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize MultiLineString when valid GeoJSON is provided`() {
+ val lineString1 = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 1.0),
+ )
+ val lineString2 = listOf(
+ Position(2.0, 2.0),
+ Position(3.0, 3.0),
+ )
+ val multiLineString = MultiLineString(listOf(lineString1, lineString2))
+ val actual = mapper.writeValueAsString(multiLineString)
+ val expected = """
+ {
+ "coordinates": [
+ [
+ [10.0, 0.0],
+ [1.0, 1.0]
+ ],
+ [
+ [2.0, 2.0],
+ [3.0, 3.0]
+ ]
+ ],
+ "type": "MultiLineString"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize MultiLineString when valid MultiLineString with BBox is provided`() {
+ val lineString1 = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 1.0),
+ )
+ val lineString2 = listOf(
+ Position(2.0, 2.0),
+ Position(3.0, 3.0),
+ )
+ val multiLineString = MultiLineString(
+ listOf(lineString1, lineString2),
+ bbox = BBox(10.0, 0.0, 3.0, 3.0),
+ )
+ val actual = mapper.writeValueAsString(multiLineString)
+ val expected = """
+ {
+ "coordinates": [
+ [
+ [10.0, 0.0],
+ [1.0, 1.0]
+ ],
+ [
+ [2.0, 2.0],
+ [3.0, 3.0]
+ ]
+ ],
+ "bbox":[10.0, 0.0, 3.0, 3.0],
+ "type": "MultiLineString"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPointDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPointDeserializationTest.kt
new file mode 100644
index 0000000..04642fb
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPointDeserializationTest.kt
@@ -0,0 +1,60 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.MultiPoint
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class MultiPointDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to MultiPoint when valid GeoJSON is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "type": "MultiPoint"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, MultiPoint::class.java)
+ val expected = MultiPoint(listOf(Position(1.0, 1.0), Position(1.0, 2.0)))
+ assertTrue(actual is MultiPoint)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to MultiPoint when valid GeoJSON with alt is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [[1.0, 1.0, 0.0], [1.0, 2.0, 1.0]],
+ "type": "MultiPoint"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, MultiPoint::class.java)
+ val expected = MultiPoint(listOf(Position(1.0, 1.0, 0.0), Position(1.0, 2.0, 1.0)))
+ assertTrue(actual is MultiPoint)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to MultiPoint when valid GeoJSON with bbox is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "bbox": [1.0, 1.0, 1.0, 2.0],
+ "type": "MultiPoint"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, MultiPoint::class.java)
+ val expected = MultiPoint(
+ listOf(Position(1.0, 1.0), Position(1.0, 2.0)),
+ bbox = BBox(1.0, 1.0, 1.0, 2.0),
+ )
+ assertTrue(actual is MultiPoint)
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPointSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPointSerializationTest.kt
new file mode 100644
index 0000000..1c0c1e2
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPointSerializationTest.kt
@@ -0,0 +1,61 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.MultiPoint
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class MultiPointSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize to MultiPoint when valid MultiPoint is provided`() {
+ val multiPoint = MultiPoint(listOf(Position(1.0, 1.0), Position(1.0, 2.0)))
+ val actual = mapper.writeValueAsString(multiPoint)
+ val expected = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "type": "MultiPoint"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize to MultiPoint when valid MultiPoint with alt is provided`() {
+ val multiPoint = MultiPoint(
+ listOf(
+ Position(1.0, 1.0, 0.0),
+ Position(1.0, 2.0, 1.0),
+ ),
+ )
+ val actual = mapper.writeValueAsString(multiPoint)
+ val expected = """
+ {
+ "coordinates": [[1.0, 1.0, 0.0], [1.0, 2.0, 1.0]],
+ "type": "MultiPoint"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize to MultiPoint when valid MultiPoint with BBox is provided`() {
+ val multiPoint = MultiPoint(
+ listOf(Position(1.0, 1.0), Position(1.0, 2.0)),
+ bbox = BBox(1.0, 1.0, 1.0, 2.0),
+ )
+ val actual = mapper.writeValueAsString(multiPoint)
+ val expected = """
+ {
+ "coordinates": [[1.0, 1.0], [1.0, 2.0]],
+ "bbox": [1.0, 1.0, 1.0, 2.0],
+ "type": "MultiPoint"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPolygonDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPolygonDeserializationTest.kt
new file mode 100644
index 0000000..24aa1a1
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPolygonDeserializationTest.kt
@@ -0,0 +1,74 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.MultiPolygon
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class MultiPolygonDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to MultiPolygon when valid GeoJSON is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [
+ [
+ [
+ [2.0, 2.0],
+ [3.0, 2.0],
+ [3.0, 3.0],
+ [2.0, 3.0],
+ [2.0, 2.0]
+ ]
+ ],
+ [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ],
+ [
+ [0.2, 0.2],
+ [0.2, 0.8],
+ [0.8, 0.8],
+ [0.8, 0.2],
+ [0.2, 0.2]
+ ]
+ ]
+ ],
+ "type": "MultiPolygon"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, MultiPolygon::class.java)
+ val polygon1 = listOf(
+ Position(2.0, 2.0),
+ Position(3.0, 2.0),
+ Position(3.0, 3.0),
+ Position(2.0, 3.0),
+ Position(2.0, 2.0),
+ )
+ val polygon2Ring1 = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val polygon2Ring2 = listOf(
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ )
+ val expected = MultiPolygon(listOf(listOf(polygon1), listOf(polygon2Ring1, polygon2Ring2)))
+ assertTrue(actual is MultiPolygon)
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPolygonSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPolygonSerializationTest.kt
new file mode 100644
index 0000000..1822d68
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/MultiPolygonSerializationTest.kt
@@ -0,0 +1,72 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.MultiPolygon
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class MultiPolygonSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize MultiPolygon when valid MultiPolygon is provided`() {
+ val polygon1 = listOf(
+ Position(2.0, 2.0),
+ Position(3.0, 2.0),
+ Position(3.0, 3.0),
+ Position(2.0, 3.0),
+ Position(2.0, 2.0),
+ )
+ val polygon2Ring1 = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val polygon2Ring2 = listOf(
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ )
+ val multiPolygon = MultiPolygon(listOf(listOf(polygon1), listOf(polygon2Ring1, polygon2Ring2)))
+ val actual = mapper.writeValueAsString(multiPolygon)
+ val expected = """
+ {
+ "coordinates": [
+ [
+ [
+ [2.0, 2.0],
+ [3.0, 2.0],
+ [3.0, 3.0],
+ [2.0, 3.0],
+ [2.0, 2.0]
+ ]
+ ],
+ [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ],
+ [
+ [0.2, 0.2],
+ [0.2, 0.8],
+ [0.8, 0.8],
+ [0.8, 0.2],
+ [0.2, 0.2]
+ ]
+ ]
+ ],
+ "type": "MultiPolygon"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/PointDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PointDeserializationTest.kt
new file mode 100644
index 0000000..3983976
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PointDeserializationTest.kt
@@ -0,0 +1,56 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class PointDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to Point when valid GeoJSON is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [1.0, 1.0],
+ "type": "Point"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Point::class.java)
+ val expected = Point(Position(1.0, 1.0))
+ assertTrue(actual is Point)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to Point when valid GeoJSON with alt is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [1.0, 1.0, 44.0],
+ "type": "Point"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Point::class.java)
+ val expected = Point(Position(1.0, 1.0, 44.0))
+ assertTrue(actual is Point)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to Point when valid GeoJSON with bbox is provided`() {
+ val point = Point(Position(1.0, 1.0), bbox = BBox(0.0, 0.0, 1.0, 1.0))
+ val actual = mapper.writeValueAsString(point)
+ val expected = """
+ {
+ "coordinates": [1.0, 1.0],
+ "bbox": [0.0, 0.0, 1.0, 1.0],
+ "type": "Point"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/PointSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PointSerializationTest.kt
new file mode 100644
index 0000000..f5da82a
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PointSerializationTest.kt
@@ -0,0 +1,38 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.Point
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class PointSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize Point when valid Point is provided`() {
+ val point = Point(Position(1.0, 1.0))
+ val actual = mapper.writeValueAsString(point)
+ val expected = """
+ {
+ "coordinates": [1.0, 1.0],
+ "type": "Point"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize Point when valid Point with alt is provided`() {
+ val point = Point(Position(1.0, 1.0, 44.0))
+ val actual = mapper.writeValueAsString(point)
+ val expected = """
+ {
+ "coordinates": [1.0, 1.0, 44.0],
+ "type": "Point"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/PolygonDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PolygonDeserializationTest.kt
new file mode 100644
index 0000000..b1c64c4
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PolygonDeserializationTest.kt
@@ -0,0 +1,116 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Polygon
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class PolygonDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to Polygon when valid GeoJSON without holes is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ]
+ ],
+ "type": "Polygon"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Polygon::class.java)
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val expected = Polygon(listOf(ring))
+ assertTrue(actual is Polygon)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to Polygon when valid GeoJSON with holes is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ],
+ [
+ [0.8, 0.8],
+ [0.8, 0.2],
+ [0.2, 0.2],
+ [0.2, 0.8],
+ [0.8, 0.8]
+ ]
+ ],
+ "type": "Polygon"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Polygon::class.java)
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole = listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ )
+ val expected = Polygon(listOf(ring, hole))
+ assertTrue(actual is Polygon)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to Polygon when valid GeoJSON with bbox is provided`() {
+ val geoJson = """
+ {
+ "coordinates": [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ]
+ ],
+ "bbox":[0.0, 0.0, 1.0, 1.0],
+ "type": "Polygon"
+ }
+ """.toCompactedJson()
+ val actual = mapper.readValue(geoJson, Polygon::class.java)
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val expected = Polygon(listOf(ring), bbox = BBox(0.0, 0.0, 1.0, 1.0))
+ assertTrue(actual is Polygon)
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/PolygonSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PolygonSerializationTest.kt
new file mode 100644
index 0000000..66e3f5a
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PolygonSerializationTest.kt
@@ -0,0 +1,112 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.BBox
+import jp.co.lycorp.geojson.Polygon
+import jp.co.lycorp.geojson.Position
+import jp.co.lycorp.geojson.extensions.JsonUtils.toCompactedJson
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class PolygonSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize Polygon when valid Polygon without holes is provided`() {
+ val ring = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(10.0, 1.0),
+ Position(10.0, 0.0),
+ )
+ val polygon = Polygon(listOf(ring))
+ val actual = mapper.writeValueAsString(polygon)
+ val expected = """
+ {
+ "coordinates": [
+ [
+ [10.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [10.0, 1.0],
+ [10.0, 0.0]
+ ]
+ ],
+ "type": "Polygon"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize Polygon when valid Polygon with holes is provided`() {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole = listOf(
+ Position(0.8, 0.8),
+ Position(0.8, 0.2),
+ Position(0.2, 0.2),
+ Position(0.2, 0.8),
+ Position(0.8, 0.8),
+ )
+ val polygon = Polygon(listOf(ring, hole))
+ val actual = mapper.writeValueAsString(polygon)
+ val expected = """
+ {
+ "coordinates": [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ],
+ [
+ [0.8, 0.8],
+ [0.8, 0.2],
+ [0.2, 0.2],
+ [0.2, 0.8],
+ [0.8, 0.8]
+ ]
+ ],
+ "type": "Polygon"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize Polygon when valid Polygon with BBox is provided`() {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val polygon = Polygon(listOf(ring), bbox = BBox(0.0, 0.0, 1.0, 1.0))
+ val actual = mapper.writeValueAsString(polygon)
+ val expected = """
+ {
+ "coordinates": [
+ [
+ [0.0, 0.0],
+ [1.0, 0.0],
+ [1.0, 1.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ]
+ ],
+ "bbox":[0.0, 0.0, 1.0, 1.0],
+ "type": "Polygon"
+ }
+ """.toCompactedJson()
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/PositionDeserializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PositionDeserializationTest.kt
new file mode 100644
index 0000000..652ab89
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PositionDeserializationTest.kt
@@ -0,0 +1,62 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.string.shouldContain
+import io.kotest.property.Exhaustive
+import io.kotest.property.checkAll
+import io.kotest.property.exhaustive.collection
+import jp.co.lycorp.geojson.Position
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.io.IOException
+
+class PositionDeserializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should deserialize to Position when valid GeoJSON is provided`() {
+ val geoJson = "[0.0, 1.1]".replace("\\s".toRegex(), "")
+ val actual = mapper.readValue(geoJson, Position::class.java)
+ val expected = Position(0.0, 1.1)
+ assertTrue(actual is Position)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should deserialize to Position when valid GeoJSON with alt is provided`() {
+ val geoJson = "[0.0, 1.1, 2.3]".replace("\\s".toRegex(), "")
+ val actual = mapper.readValue(geoJson, Position::class.java)
+ val expected = Position(0.0, 1.1, 2.3)
+ assertTrue(actual is Position)
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should throw error when not array`() = runTest {
+ val testCases = Exhaustive.collection(listOf("{}", "\"foobar\"", "17"))
+
+ checkAll(testCases) {
+ val exception = shouldThrow {
+ mapper.readValue(it, Position::class.java)
+ }
+
+ exception.message.shouldContain("Unable to deserialize Position: no array found")
+ }
+ }
+
+ @Test
+ fun `should throw error when array size is invalid`() = runTest {
+ val testCases = Exhaustive.collection(listOf("[]", "[0.0]", "[0.0, 0.1, 0.2, 0.3]"))
+
+ checkAll(testCases) {
+ val exception = shouldThrow {
+ mapper.readValue(it, Position::class.java)
+ }
+
+ exception.message.shouldContain("Unexpected coordinate array size:")
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/jackson/PositionSerializationTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PositionSerializationTest.kt
new file mode 100644
index 0000000..5037498
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/jackson/PositionSerializationTest.kt
@@ -0,0 +1,26 @@
+package jp.co.lycorp.geojson.jackson
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jp.co.lycorp.geojson.Position
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class PositionSerializationTest {
+ private val mapper = jacksonObjectMapper()
+
+ @Test
+ fun `should serialize Position when valid BBox is provided`() {
+ val position = Position(0.0, 1.1)
+ val actual = mapper.writeValueAsString(position)
+ val expected = "[0.0, 1.1]".replace("\\s".toRegex(), "")
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should serialize Position when valid BBox with alt is provided`() {
+ val position = Position(0.0, 1.1, 2.3)
+ val actual = mapper.writeValueAsString(position)
+ val expected = "[0.0, 1.1, 2.3]".replace("\\s".toRegex(), "")
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/validator/LineStringValidatorTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/validator/LineStringValidatorTest.kt
new file mode 100644
index 0000000..3c94d11
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/validator/LineStringValidatorTest.kt
@@ -0,0 +1,33 @@
+/*
+ * This source file was generated by the Gradle 'init' task
+ */
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.LineString
+import jp.co.lycorp.geojson.Position
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.api.assertThrows
+
+class LineStringValidatorTest {
+ @Test
+ fun `should fail when coordinates size is less than 2`() {
+ val lineString = listOf(
+ Position(10.0, 0.0),
+ )
+ val err = assertThrows { LineString(lineString) }
+ val expected = "LineString coordinates must have at least 2 Positions"
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should pass validation check when coordinates size is more than 2`() {
+ val lineString = listOf(
+ Position(10.0, 0.0),
+ Position(10.0, 1.0),
+ )
+ assertDoesNotThrow { LineString(lineString) }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/validator/PolygonValidatorTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/validator/PolygonValidatorTest.kt
new file mode 100644
index 0000000..bf4bb76
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/validator/PolygonValidatorTest.kt
@@ -0,0 +1,54 @@
+/*
+ * This source file was generated by the Gradle 'init' task
+ */
+package jp.co.lycorp.geojson.validator
+
+import jp.co.lycorp.geojson.Polygon
+import jp.co.lycorp.geojson.Position
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class PolygonValidatorTest {
+ @Test
+ fun `should fail when linear ring size is less than 4`() {
+ val unclosedRing = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 0.0),
+ Position(10.0, 0.0),
+ )
+ val err = assertThrows { Polygon(listOf(unclosedRing)) }
+ val expected = "Polygon linear ring must have at least 4 coordinates"
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should fail when linear ring is not closed`() {
+ val unclosedRing = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(10.0, 1.0),
+ )
+ val err = assertThrows { Polygon(listOf(unclosedRing)) }
+ val expected = "Polygon linear ring first element must be equal to last element"
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should fail when linear ring does not have 3 unique coordinates`() {
+ val unclosedRing = listOf(
+ Position(10.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 0.0),
+ Position(10.0, 0.0),
+ )
+ val err = assertThrows { Polygon(listOf(unclosedRing)) }
+ val expected = "Polygon linear ring must have at least 3 unique coordinates"
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/validator/PositionValidatorTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/validator/PositionValidatorTest.kt
new file mode 100644
index 0000000..f33e4ce
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/validator/PositionValidatorTest.kt
@@ -0,0 +1,48 @@
+/*
+ * This source file was generated by the Gradle 'init' task
+ */
+package jp.co.lycorp.geojson.validator
+
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.filter
+import io.kotest.property.checkAll
+import jp.co.lycorp.geojson.Position
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.api.assertThrows
+
+class PositionValidatorTest {
+ @Test
+ fun `should fail when invalid latitude is passed`() = runTest {
+ val expected = "Longitude must be between -180 and 180 degrees"
+
+ checkAll(Arb.double().filter { it !in -180.0..180.0 }, Arb.double(-90.0, 90.0)) {
+ lng: Double, lat: Double ->
+ val err = assertThrows { Position(lng, lat) }
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should fail when invalid longitude is passed`() = runTest {
+ val expected = "Latitude must be between -90 and 90 degrees"
+ checkAll(Arb.double(-180.0, 180.0), Arb.double().filter { it !in -90.0..90.0 }) {
+ lng: Double, lat: Double ->
+ val err = assertThrows { Position(lng, lat) }
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should pass validation check when valid position is passed`() = runTest {
+ checkAll(Arb.double(-180.0, 180.0), Arb.double(-90.0, 90.0)) {
+ lng: Double, lat: Double ->
+ assertDoesNotThrow { Position(lng, lat) }
+ }
+ }
+}
diff --git a/src/test/kotlin/jp/co/lycorp/geojson/validator/RingIntersectionValidatorTest.kt b/src/test/kotlin/jp/co/lycorp/geojson/validator/RingIntersectionValidatorTest.kt
new file mode 100644
index 0000000..7975ca8
--- /dev/null
+++ b/src/test/kotlin/jp/co/lycorp/geojson/validator/RingIntersectionValidatorTest.kt
@@ -0,0 +1,130 @@
+/*
+ * This source file was generated by the Gradle 'init' task
+ */
+package jp.co.lycorp.geojson.validator
+
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.filter
+import io.kotest.property.checkAll
+import jp.co.lycorp.geojson.Polygon
+import jp.co.lycorp.geojson.Position
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class RingIntersectionValidatorTest {
+
+ @Test
+ fun `should fail when the interior ring of a Polygon intersects or crosses the exterior ring`() = runTest {
+ val border = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val expected = "The interior ring of a Polygon must not intersect or cross the exterior ring."
+ checkAll(
+ Arb.double(-180.0, 180.0).filter { it !in 0.0..1.0 },
+ Arb.double(-90.0, 90.0),
+ ) {
+ lng: Double, lat: Double ->
+ val invalidHole = listOf(
+ Position(0.2, 0.2),
+ Position(lng, lat),
+ Position(1.8, 1.8),
+ Position(0.2, 0.8),
+ Position(0.2, 0.2),
+ )
+ val err = assertThrows { Polygon(listOf(border, invalidHole)) }
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+ }
+
+ @Test
+ fun `should fail when one interior ring encompasses another interior ring`() = runTest {
+ val border = listOf(
+ Position(-10.0, -10.0),
+ Position(10.0, -10.0),
+ Position(10.0, 10.0),
+ Position(-10.0, 10.0),
+ Position(-10.0, -10.0),
+ )
+ val hole1 = listOf(
+ Position(-5.0, -5.0),
+ Position(5.0, -5.0),
+ Position(5.0, 5.0),
+ Position(-5.0, 5.0),
+ Position(-5.0, -5.0),
+ )
+ val hole2 = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val expected = "No two interior rings may intersect, cross, or encompass each other"
+ val err = assertThrows { Polygon(listOf(border, hole1, hole2)) }
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should fail when two interior rings intersect each other`() {
+ val border = listOf(
+ Position(-20.0, -20.0),
+ Position(20.0, -20.0),
+ Position(20.0, 20.0),
+ Position(-20.0, 20.0),
+ Position(-20.0, -20.0),
+ )
+ val hole1 = listOf(
+ Position(10.0, 0.0),
+ Position(0.0, 10.0),
+ Position(-10.0, 0.0),
+ Position(0.0, -10.0),
+ Position(10.0, 0.0),
+ )
+ val hole2 = listOf(
+ Position(8.0, 8.0),
+ Position(-10.0, -3.0),
+ Position(8.0, -8.0),
+ Position(8.0, 8.0),
+ )
+ val expected = "No two interior rings may intersect, cross, or encompass each other"
+ val err = assertThrows { Polygon(listOf(border, hole1, hole2)) }
+ val actual = err.message
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should pass validation check when valid polygon is passed`() {
+ val ring = listOf(
+ Position(0.0, 0.0),
+ Position(1.0, 0.0),
+ Position(1.0, 1.0),
+ Position(0.0, 1.0),
+ Position(0.0, 0.0),
+ )
+ val hole1 = listOf(
+ Position(0.2, 0.2),
+ Position(0.5, 0.2),
+ Position(0.5, 0.5),
+ Position(0.2, 0.5),
+ Position(0.2, 0.2),
+ )
+ val hole2 = listOf(
+ Position(0.6, 0.6),
+ Position(0.8, 0.5),
+ Position(0.8, 0.8),
+ Position(0.5, 0.8),
+ Position(0.6, 0.6),
+ )
+ assertDoesNotThrow { Polygon(listOf(ring, hole1, hole2)) }
+ }
+}