diff --git a/.coverage_history b/.coverage_history
index f19bf996..6b0bd0e3 100644
--- a/.coverage_history
+++ b/.coverage_history
@@ -1 +1 @@
-0.9846341092347483
+0.9849253731343284
diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index b0712222..113fede8 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/main/java/dev/sorn/fmp4j/exceptions/FmpInvalidPartException.java b/src/main/java/dev/sorn/fmp4j/exceptions/FmpInvalidPartException.java
new file mode 100644
index 00000000..cd6001ab
--- /dev/null
+++ b/src/main/java/dev/sorn/fmp4j/exceptions/FmpInvalidPartException.java
@@ -0,0 +1,7 @@
+package dev.sorn.fmp4j.exceptions;
+
+public class FmpInvalidPartException extends FmpException {
+ public FmpInvalidPartException(String message, Object... args) {
+ super(message, args);
+ }
+}
diff --git a/src/main/java/dev/sorn/fmp4j/types/FmpPart.java b/src/main/java/dev/sorn/fmp4j/types/FmpPart.java
new file mode 100644
index 00000000..4c72f532
--- /dev/null
+++ b/src/main/java/dev/sorn/fmp4j/types/FmpPart.java
@@ -0,0 +1,77 @@
+package dev.sorn.fmp4j.types;
+
+import static java.lang.Integer.parseInt;
+import static java.lang.String.valueOf;
+import static java.util.Objects.compare;
+
+import dev.sorn.fmp4j.exceptions.FmpInvalidPartException;
+import java.io.Serial;
+
+public final class FmpPart implements Comparable, FmpValueObject {
+ public static final int MIN_VALUE = 0;
+ public static final int MAX_VALUE = 1000;
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final int value;
+
+ private FmpPart(int value) {
+ this.value = value;
+ }
+
+ public static FmpPart part(String value) {
+ try {
+ return part(parseInt(value));
+ } catch (NumberFormatException e) {
+ throw new FmpInvalidPartException("[%s] is not a valid integer value", value);
+ }
+ }
+
+ public static FmpPart part(int value) {
+ if (value < MIN_VALUE) {
+ throw new FmpInvalidPartException("[%d] is below the minimum allowed value [%d]", value, MIN_VALUE);
+ }
+ if (value > MAX_VALUE) {
+ throw new FmpInvalidPartException("[%d] exceeds the maximum allowed value [%d]", value, MAX_VALUE);
+ }
+ return new FmpPart(value);
+ }
+
+ @Override
+ public Integer value() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return valueOf(value);
+ }
+
+ @Override
+ public int hashCode() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (!(obj instanceof FmpPart that)) {
+ return false;
+ }
+ return this.value == that.value;
+ }
+
+ @Override
+ public int compareTo(FmpPart that) {
+ if (that == null) {
+ throw new FmpInvalidPartException("'that.value' is required");
+ }
+ return compare(this.value, that.value, Integer::compareTo);
+ }
+}
diff --git a/src/test/java/dev/sorn/fmp4j/types/FmpPartTest.java b/src/test/java/dev/sorn/fmp4j/types/FmpPartTest.java
new file mode 100644
index 00000000..f70466d4
--- /dev/null
+++ b/src/test/java/dev/sorn/fmp4j/types/FmpPartTest.java
@@ -0,0 +1,190 @@
+package dev.sorn.fmp4j.types;
+
+import static dev.sorn.fmp4j.types.FmpPart.MAX_VALUE;
+import static dev.sorn.fmp4j.types.FmpPart.MIN_VALUE;
+import static dev.sorn.fmp4j.types.FmpPart.part;
+import static java.lang.String.format;
+import static java.lang.String.valueOf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.sorn.fmp4j.exceptions.FmpInvalidPartException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class FmpPartTest {
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1000, 954, 241, 741, 2, 15})
+ void valid_part(int part) {
+ // given // when
+ var p = part(part);
+
+ // then
+ assertEquals(part, p.value());
+ }
+
+ @Test
+ void below_minimum_part() {
+ // given
+ var p = -1;
+
+ // when // then
+ var e = assertThrows(FmpInvalidPartException.class, () -> part(p));
+ assertEquals(format("[%d] is below the minimum allowed value [%d]", p, MIN_VALUE), e.getMessage());
+ }
+
+ @Test
+ void exceeds_maximum_year() {
+ // given
+ var p = 1001;
+
+ // when // then
+ var e = assertThrows(FmpInvalidPartException.class, () -> part(p));
+ assertEquals(format("[%d] exceeds the maximum allowed value [%d]", p, MAX_VALUE), e.getMessage());
+ }
+
+ @Test
+ void string_value_not_int() {
+ // given
+ var p = "199X";
+
+ // when // then
+ var e = assertThrows(FmpInvalidPartException.class, () -> part(p));
+ assertEquals(format("[%s] is not a valid integer value", p), e.getMessage());
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1000, 999, 998, 10, 9})
+ void toString_returns_value(int part) {
+ // given // when
+ var p = part(part);
+
+ // then
+ assertEquals(p.toString(), valueOf(part));
+ }
+
+ @Test
+ void hashCode_value() {
+ // given
+ var part = 999;
+ var p = part(part);
+
+ // when
+ var hc = p.hashCode();
+
+ // then
+ assertEquals(part, hc);
+ }
+
+ @Test
+ void equals_same_true() {
+ // given
+ var p = part(1);
+
+ // when
+ var eq = p.equals(p);
+
+ assertTrue(eq);
+ }
+
+ @Test
+ void equals_identical_true() {
+ // given
+ var p1 = part(1000);
+ var p2 = part(1000);
+
+ // when
+ var eq = p1.equals(p2);
+
+ assertTrue(eq);
+ }
+
+ @Test
+ void equals_null_false() {
+ // given
+ var p1 = part(1000);
+ var p2 = (FmpPart) null;
+
+ // when
+ var eq = p1.equals(p2);
+
+ assertFalse(eq);
+ }
+
+ @Test
+ void equals_different_false() {
+ // given
+ var p1 = part(1000);
+ var p2 = part(999);
+
+ // when
+ var eq = p1.equals(p2);
+
+ assertFalse(eq);
+ }
+
+ @Test
+ void equals_wrong_instance_false() {
+ // given
+ var p1 = part(999);
+ var p2 = 999;
+
+ // when
+ var eq = p1.equals(p2);
+
+ assertFalse(eq);
+ }
+
+ @Test
+ void compareTo_null_throws() {
+ // given
+ var p1 = part("999");
+ var p2 = (FmpPart) null;
+
+ // when // then
+ var e = assertThrows(FmpInvalidPartException.class, () -> p1.compareTo(p2));
+ assertEquals("'that.value' is required", e.getMessage());
+ }
+
+ @Test
+ void compareTo_less_than() {
+ // given
+ var p1 = part("1");
+ var p2 = part("999");
+
+ // when
+ int cmp = p1.compareTo(p2);
+
+ // then
+ assertEquals(-1, cmp);
+ }
+
+ @Test
+ void compareTo_greater_than() {
+ // given
+ var p1 = part("999");
+ var p2 = part("1");
+
+ // when
+ int cmp = p1.compareTo(p2);
+
+ // then
+ assertEquals(1, cmp);
+ }
+
+ @Test
+ void compareTo_equal() {
+ // given
+ var p1 = part("1");
+ var p2 = part("1");
+
+ // when
+ int cmp = p1.compareTo(p2);
+
+ // then
+ assertEquals(0, cmp);
+ }
+}