diff --git a/src/main/java/de/extio/game_engine/storage/StorageIndex.java b/src/main/java/de/extio/game_engine/storage/StorageIndex.java index b0eed13..459962f 100644 --- a/src/main/java/de/extio/game_engine/storage/StorageIndex.java +++ b/src/main/java/de/extio/game_engine/storage/StorageIndex.java @@ -18,8 +18,9 @@ import java.nio.file.PathMatcher; public class StorageIndex { private final Node root; + private final Map byId; - + public StorageIndex() { this.root = new Node(); this.byId = new HashMap<>(); @@ -33,6 +34,14 @@ public class StorageIndex { return this.byId.isEmpty(); } + public List listAll() { + List result = new ArrayList<>(this.byId.size()); + for (IndexedItem item : this.byId.values()) { + result.add(item.descriptor()); + } + return result; + } + public void clear() { this.root.clear(); this.byId.clear(); @@ -64,16 +73,29 @@ public class StorageIndex { return Optional.ofNullable(node.itemsByNormalizedName.get(normalizedName)).map(IndexedItem::descriptor); } - public List list(final List path) { + public List list(final List path, final boolean recursive) { final List normalizedPath = this.normalizePath(path); final Node node = this.getNode(normalizedPath); if (node == null) { return List.of(); } - if (node.itemsByNormalizedName.isEmpty()) { - return List.of(); + if (!recursive) { + if (node.itemsByNormalizedName.isEmpty()) { + return List.of(); + } + return node.itemsByNormalizedName.values().stream().map(IndexedItem::descriptor).toList(); } - return node.itemsByNormalizedName.values().stream().map(IndexedItem::descriptor).toList(); + final List items = new ArrayList<>(); + final Deque work = new ArrayDeque<>(); + work.addLast(node); + while (!work.isEmpty()) { + final Node n = work.removeFirst(); + for (final IndexedItem item : n.itemsByNormalizedName.values()) { + items.add(item.descriptor()); + } + work.addAll(n.children.values()); + } + return List.copyOf(items); } public List findByFilenamePattern(final List basePath, final String pattern, final boolean recursive) { @@ -256,7 +278,9 @@ public class StorageIndex { } private static final class Node { + private final Map children; + private final Map itemsByNormalizedName; private Node() { diff --git a/src/main/java/de/extio/game_engine/storage/StorageOutputStream.java b/src/main/java/de/extio/game_engine/storage/StorageOutputStream.java new file mode 100644 index 0000000..1064107 --- /dev/null +++ b/src/main/java/de/extio/game_engine/storage/StorageOutputStream.java @@ -0,0 +1,11 @@ +package de.extio.game_engine.storage; + +import java.io.OutputStream; +import java.util.UUID; + +/** + * Wrapper that provides access to an OutputStream for storing data while + * also returning the storage UUID associated with the data. + */ +public record StorageOutputStream(UUID id, OutputStream outputStream) { +} diff --git a/src/main/java/de/extio/game_engine/storage/StorageService.java b/src/main/java/de/extio/game_engine/storage/StorageService.java new file mode 100644 index 0000000..dab4d6a --- /dev/null +++ b/src/main/java/de/extio/game_engine/storage/StorageService.java @@ -0,0 +1,42 @@ +package de.extio.game_engine.storage; + +import java.io.InputStream; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface StorageService { + + boolean deleteById(UUID id); + + boolean deleteByPath(List path, String name); + + List listAll(); + + List listPath(List path, boolean recursive); + + Optional loadById(Class clazz, UUID id); + + Optional loadByPath(Class clazz, List path, String name); + + Optional loadStreamById(UUID id); + + Optional loadStreamByPath(List path, String name); + + boolean moveById(UUID id, List newPath, String newName); + + boolean moveByPath(List oldPath, String oldName, List newPath, String newName); + + Optional searchById(UUID id); + + Optional searchByPath(List path, String name); + + List searchByPattern(List basePath, String pattern, boolean recursive); + + UUID store(String name, List path, Object obj); + + UUID storeStream(String name, List path, InputStream inputStream); + + StorageOutputStream storeStream(String name, List path); + +} diff --git a/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java b/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java new file mode 100644 index 0000000..eb01707 --- /dev/null +++ b/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java @@ -0,0 +1,15 @@ +package de.extio.game_engine.storage; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnProperty(name = "game-engine.storage.enabled", havingValue = "true", matchIfMissing = true) +public class StorageServiceAutoConfiguration { + + @Bean + StorageService storageService() { + return new StorageServiceImpl(); + } +} diff --git a/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java b/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java index 263c4cf..2cc62e7 100644 --- a/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java +++ b/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java @@ -1,17 +1,25 @@ package de.extio.game_engine.storage; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import de.extio.game_engine.util.FastRandomUUID; import de.extio.game_engine.util.ObjectSerialization; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class StorageServiceImpl { +public class StorageServiceImpl implements StorageService { private static final Logger LOGGER = LoggerFactory.getLogger(StorageServiceImpl.class); @@ -83,7 +91,7 @@ public class StorageServiceImpl { final StorageItemDescriptor[] descriptors; try (final var in = Files.newInputStream(INDEX_LOCATION)) { - descriptors = ObjectSerialization.deserialize(StorageItemDescriptor[].class, in, false, false, null, null, null); + descriptors = ObjectSerialization.deserialize(StorageItemDescriptor[].class, in, true, false, null, null, null); } if (descriptors == null || descriptors.length == 0) { return; @@ -103,4 +111,295 @@ public class StorageServiceImpl { } } + private void saveIndex() { + try { + final StorageItemDescriptor[] descriptors = this.index.listAll().toArray(StorageItemDescriptor[]::new); + try (var outputStream = Files.newOutputStream(INDEX_LOCATION)) { + ObjectSerialization.serialize(descriptors, outputStream, true, false, false, null, null); + } + LOGGER.debug("Saved {} items to index", descriptors.length); + } + catch (final Exception e) { + LOGGER.error("Could not save index to " + INDEX_LOCATION.toString(), e); + } + } + + private Path getFilePath(final UUID id) { + return DATA_LOCATION.resolve(id.toString()); + } + + private void addToIndexAndSave(final UUID id, final String name, final List path) { + final StorageItemDescriptor descriptor = new StorageItemDescriptor(id, name, path == null ? List.of() : path); + this.index.add(descriptor); + this.saveIndex(); + } + + @Override + public UUID store(final String name, final List path, final Object obj) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(obj, "obj"); + + final Optional existing = this.index.find(path, name); + final UUID id = existing.map(StorageItemDescriptor::id).orElse(FastRandomUUID.create()); + + final Path filePath = getFilePath(id); + try { + try (var outputStream = Files.newOutputStream(filePath)) { + ObjectSerialization.serialize(obj, outputStream, true, false, false, null, null); + } + + addToIndexAndSave(id, name, path); + LOGGER.debug("Stored object with id {} at path {}/{}", id, path, name); + return id; + } + catch (final Exception e) { + LOGGER.error("Could not store object at " + filePath.toString(), e); + throw new RuntimeException("Failed to store object", e); + } + } + + @Override + public UUID storeStream(final String name, final List path, final InputStream inputStream) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(inputStream, "inputStream"); + + final Optional existing = this.index.find(path, name); + final UUID id = existing.map(StorageItemDescriptor::id).orElse(FastRandomUUID.create()); + + final Path filePath = getFilePath(id); + try { + Files.copy(inputStream, filePath, StandardCopyOption.REPLACE_EXISTING); + + addToIndexAndSave(id, name, path); + LOGGER.debug("Stored stream with id {} at path {}/{}", id, path, name); + return id; + } + catch (final Exception e) { + LOGGER.error("Could not store stream at " + filePath.toString(), e); + throw new RuntimeException("Failed to store stream", e); + } + } + + @Override + public boolean deleteById(final UUID id) { + Objects.requireNonNull(id, "id"); + + final boolean removed = this.index.remove(id); + if (!removed) { + return false; + } + + final Path filePath = DATA_LOCATION.resolve(id.toString()); + try { + Files.deleteIfExists(filePath); + this.saveIndex(); + LOGGER.debug("Deleted object with id {}", id); + return true; + } + catch (final IOException e) { + LOGGER.error("Could not delete file " + filePath.toString(), e); + return false; + } + } + + @Override + public boolean deleteByPath(final List path, final String name) { + Objects.requireNonNull(name, "name"); + + final Optional descriptor = this.index.find(path, name); + if (descriptor.isEmpty()) { + return false; + } + + return this.deleteById(descriptor.get().id()); + } + + @Override + public boolean moveById(final UUID id, final List newPath, final String newName) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(newName, "newName"); + + final Optional existing = this.index.getById(id); + if (existing.isEmpty()) { + return false; + } + + final StorageItemDescriptor newDescriptor = new StorageItemDescriptor(id, newName, newPath == null ? List.of() : newPath); + this.index.add(newDescriptor); + this.saveIndex(); + + LOGGER.debug("Moved object with id {} to path {}/{}", id, newPath, newName); + return true; + } + + @Override + public boolean moveByPath(final List oldPath, final String oldName, final List newPath, final String newName) { + Objects.requireNonNull(oldName, "oldName"); + Objects.requireNonNull(newName, "newName"); + + final Optional descriptor = this.index.find(oldPath, oldName); + if (descriptor.isEmpty()) { + return false; + } + + return this.moveById(descriptor.get().id(), newPath, newName); + } + + @Override + public Optional loadById(final Class clazz, final UUID id) { + Objects.requireNonNull(clazz, "clazz"); + Objects.requireNonNull(id, "id"); + + if (!this.index.getById(id).isPresent()) { + return Optional.empty(); + } + + final Path filePath = getFilePath(id); + if (!Files.exists(filePath)) { + LOGGER.warn("Index references non-existent file for id {}", id); + return Optional.empty(); + } + + try { + try (final var in = Files.newInputStream(filePath)) { + final T obj = ObjectSerialization.deserialize(clazz, in, true, false, null, null, null); + return Optional.ofNullable(obj); + } + } + catch (final Exception e) { + LOGGER.error("Could not load object from " + filePath.toString(), e); + return Optional.empty(); + } + } + + @Override + public Optional loadByPath(final Class clazz, final List path, final String name) { + Objects.requireNonNull(clazz, "clazz"); + Objects.requireNonNull(name, "name"); + + final Optional descriptor = this.index.find(path, name); + if (descriptor.isEmpty()) { + return Optional.empty(); + } + + return this.loadById(clazz, descriptor.get().id()); + } + + @Override + public Optional loadStreamById(final UUID id) { + Objects.requireNonNull(id, "id"); + + if (!this.index.getById(id).isPresent()) { + return Optional.empty(); + } + + final Path filePath = getFilePath(id); + if (!Files.exists(filePath)) { + LOGGER.warn("Index references non-existent file for id {}", id); + return Optional.empty(); + } + + try { + return Optional.of(Files.newInputStream(filePath)); + } + catch (final Exception e) { + LOGGER.error("Could not load stream from " + filePath.toString(), e); + return Optional.empty(); + } + } + + @Override + public Optional loadStreamByPath(final List path, final String name) { + Objects.requireNonNull(name, "name"); + + final Optional descriptor = this.index.find(path, name); + if (descriptor.isEmpty()) { + return Optional.empty(); + } + + return this.loadStreamById(descriptor.get().id()); + } + + @Override + public StorageOutputStream storeStream(final String name, final List path) { + Objects.requireNonNull(name, "name"); + + final Optional existing = this.index.find(path, name); + final UUID id = existing.map(StorageItemDescriptor::id).orElse(FastRandomUUID.create()); + + final Path filePath = getFilePath(id); + try { + final OutputStream outputStream = Files.newOutputStream(filePath); + + return new StorageOutputStream(id, new OutputStream() { + + private boolean closed = false; + + @Override + public void write(int b) throws IOException { + outputStream.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + outputStream.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + outputStream.write(b, off, len); + } + + @Override + public void flush() throws IOException { + outputStream.flush(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + outputStream.close(); + + addToIndexAndSave(id, name, path); + LOGGER.debug("Stored stream with id {} at path {}/{}", id, path, name); + } + }); + } + catch (final Exception e) { + LOGGER.error("Could not create output stream at " + filePath.toString(), e); + throw new RuntimeException("Failed to create output stream", e); + } + } + + @Override + public Optional searchById(final UUID id) { + Objects.requireNonNull(id, "id"); + return this.index.getById(id); + } + + @Override + public Optional searchByPath(final List path, final String name) { + Objects.requireNonNull(name, "name"); + return this.index.find(path, name); + } + + @Override + public List searchByPattern(final List basePath, final String pattern, final boolean recursive) { + Objects.requireNonNull(pattern, "pattern"); + return this.index.findByFilenamePattern(basePath, pattern, recursive); + } + + @Override + public List listPath(final List path, final boolean recursive) { + return this.index.list(path, recursive); + } + + @Override + public List listAll() { + return this.index.listAll(); + } + } diff --git a/src/main/java/de/extio/game_engine/util/ObjectSerialization.java b/src/main/java/de/extio/game_engine/util/ObjectSerialization.java index 03c2c06..f935c11 100644 --- a/src/main/java/de/extio/game_engine/util/ObjectSerialization.java +++ b/src/main/java/de/extio/game_engine/util/ObjectSerialization.java @@ -31,11 +31,16 @@ public final class ObjectSerialization { public static byte[] serialize(final Object o, final boolean compress, final boolean base64, final boolean digest, final byte[] digestSalt, final Consumer digestConsumer) { final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(1 << 16); + serialize(o, byteArrayOutputStream, compress, base64, digest, digestSalt, digestConsumer); + return byteArrayOutputStream.toByteArray(); + } + + public static void serialize(final Object o, final OutputStream outputStream, final boolean compress, final boolean base64, final boolean digest, final byte[] digestSalt, final Consumer digestConsumer) { OutputStream base64OutputStream = null; ZstdOutputStream zstdOutputStream = null; DigestOutputStream digestOutputStream = null; try { - OutputStream stream = byteArrayOutputStream; + OutputStream stream = outputStream; if (base64) { stream = base64OutputStream = Base64.getEncoder().wrap(stream); } @@ -77,13 +82,10 @@ public final class ObjectSerialization { } } } - - return byteArrayOutputStream.toByteArray(); } public static T deserialize(final Class clazz, final byte[] data, final boolean decompress, final boolean base64, final byte[] checkDigest, final byte[] digestSalt, final Consumer digestCheckConsumer) { - final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data); - return deserialize(clazz, byteArrayInputStream, decompress, base64, checkDigest, digestSalt, digestCheckConsumer); + return deserialize(clazz, new ByteArrayInputStream(data), decompress, base64, checkDigest, digestSalt, digestCheckConsumer); } public static T deserialize(final Class clazz, final InputStream inputStream, final boolean decompress, final boolean base64, final byte[] checkDigest, final byte[] digestSalt, final Consumer digestCheckConsumer) { diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 6812cf3..a7aea4b 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -2,3 +2,4 @@ de.extio.game_engine.event.EventsAutoConfiguration de.extio.game_engine.keyboard.KeycodeAutoConfiguration de.extio.game_engine.renderer.RendererAutoConfiguration de.extio.game_engine.renderer.g2d.G2DAutoConfiguration +de.extio.game_engine.storage.StorageServiceAutoConfiguration diff --git a/src/test/java/de/extio/game_engine/storage/StorageIndexTest.java b/src/test/java/de/extio/game_engine/storage/StorageIndexTest.java index 9cdb68b..d00c0c1 100644 --- a/src/test/java/de/extio/game_engine/storage/StorageIndexTest.java +++ b/src/test/java/de/extio/game_engine/storage/StorageIndexTest.java @@ -24,7 +24,7 @@ public class StorageIndexTest { } @Test - void listReturnsItemsOnlyAtThatDirectory() { + void listNonRecursiveReturnsItemsOnlyAtThatDirectory() { final StorageIndex index = new StorageIndex(); final StorageItemDescriptor rootItem = new StorageItemDescriptor(UUID.randomUUID(), "root", List.of()); final StorageItemDescriptor aItem = new StorageItemDescriptor(UUID.randomUUID(), "a1", List.of("a")); @@ -32,10 +32,10 @@ public class StorageIndexTest { index.add(rootItem); index.add(aItem); index.add(abItem); - assertThat(index.list(List.of())).containsExactlyInAnyOrder(rootItem); - assertThat(index.list(List.of("a"))).containsExactlyInAnyOrder(aItem); - assertThat(index.list(List.of("a", "b"))).containsExactlyInAnyOrder(abItem); - assertThat(index.list(List.of("missing"))).isEmpty(); + assertThat(index.list(List.of(), false)).containsExactlyInAnyOrder(rootItem); + assertThat(index.list(List.of("a"), false)).containsExactlyInAnyOrder(aItem); + assertThat(index.list(List.of("a", "b"), false)).containsExactlyInAnyOrder(abItem); + assertThat(index.list(List.of("missing"), false)).isEmpty(); } @Test @@ -45,7 +45,7 @@ public class StorageIndexTest { index.add(descriptor); assertThat(index.find(List.of("a", "b"), "x")).isPresent(); assertThat(index.find(List.of(" ", "A", "b "), "X")).isPresent(); - assertThat(index.list(List.of("a", "b"))).hasSize(1); + assertThat(index.list(List.of("a", "b"), false)).hasSize(1); } @Test @@ -90,8 +90,8 @@ public class StorageIndexTest { assertThat(index.remove(leaf.id())).isTrue(); assertThat(index.size()).isZero(); assertThat(index.find(List.of("a", "b"), "leaf")).isEmpty(); - assertThat(index.list(List.of("a", "b"))).isEmpty(); - assertThat(index.list(List.of("a"))).isEmpty(); + assertThat(index.list(List.of("a", "b"), false)).isEmpty(); + assertThat(index.list(List.of("a"), false)).isEmpty(); } @Test @@ -134,4 +134,75 @@ public class StorageIndexTest { final var matches = index.findByFilenamePattern(List.of("assets"), "glob:*.png", true); assertThat(matches).containsExactlyInAnyOrder(inBase, inChild2); } + + @Test + void listRecursiveReturnsAllItemsInDirectoryAndSubdirectories() { + final StorageIndex index = new StorageIndex(); + final StorageItemDescriptor rootItem = new StorageItemDescriptor(UUID.randomUUID(), "root", List.of()); + final StorageItemDescriptor aItem = new StorageItemDescriptor(UUID.randomUUID(), "a1", List.of("a")); + final StorageItemDescriptor abItem = new StorageItemDescriptor(UUID.randomUUID(), "ab1", List.of("a", "b")); + final StorageItemDescriptor abcItem = new StorageItemDescriptor(UUID.randomUUID(), "abc1", List.of("a", "b", "c")); + final StorageItemDescriptor otherItem = new StorageItemDescriptor(UUID.randomUUID(), "other", List.of("other")); + index.add(rootItem); + index.add(aItem); + index.add(abItem); + index.add(abcItem); + index.add(otherItem); + assertThat(index.list(List.of(), true)).containsExactlyInAnyOrder(rootItem, aItem, abItem, abcItem, otherItem); + assertThat(index.list(List.of("a"), true)).containsExactlyInAnyOrder(aItem, abItem, abcItem); + assertThat(index.list(List.of("a", "b"), true)).containsExactlyInAnyOrder(abItem, abcItem); + assertThat(index.list(List.of("a", "b", "c"), true)).containsExactlyInAnyOrder(abcItem); + assertThat(index.list(List.of("missing"), true)).isEmpty(); + } + + @Test + void listRecursiveNonExistentPathReturnsEmpty() { + final StorageIndex index = new StorageIndex(); + final StorageItemDescriptor item = new StorageItemDescriptor(UUID.randomUUID(), "file", List.of("a")); + index.add(item); + assertThat(index.list(List.of("nonexistent"), true)).isEmpty(); + } + + @Test + void listRecursiveAndNonRecursiveAreConsistent() { + final StorageIndex index = new StorageIndex(); + final StorageItemDescriptor aItem = new StorageItemDescriptor(UUID.randomUUID(), "a1", List.of("a")); + final StorageItemDescriptor abItem = new StorageItemDescriptor(UUID.randomUUID(), "ab1", List.of("a", "b")); + index.add(aItem); + index.add(abItem); + assertThat(index.list(List.of("a"), false)).containsExactly(aItem); + assertThat(index.list(List.of("a"), true)).containsExactlyInAnyOrder(aItem, abItem); + } + + @Test + void listAllReturnsAllItemsInIndex() { + final StorageIndex index = new StorageIndex(); + final StorageItemDescriptor root = new StorageItemDescriptor(UUID.randomUUID(), "root", List.of()); + final StorageItemDescriptor a = new StorageItemDescriptor(UUID.randomUUID(), "a", List.of("a")); + final StorageItemDescriptor ab = new StorageItemDescriptor(UUID.randomUUID(), "ab", List.of("a", "b")); + final StorageItemDescriptor other = new StorageItemDescriptor(UUID.randomUUID(), "other", List.of("x", "y")); + index.add(root); + index.add(a); + index.add(ab); + index.add(other); + assertThat(index.listAll()).containsExactlyInAnyOrder(root, a, ab, other); + } + + @Test + void listAllOnEmptyIndexReturnsEmpty() { + final StorageIndex index = new StorageIndex(); + assertThat(index.listAll()).isEmpty(); + } + + @Test + void listAllIsCopyOfInternalState() { + final StorageIndex index = new StorageIndex(); + final StorageItemDescriptor item = new StorageItemDescriptor(UUID.randomUUID(), "file", List.of("a")); + index.add(item); + final var list1 = index.listAll(); + index.add(new StorageItemDescriptor(UUID.randomUUID(), "file2", List.of("b"))); + final var list2 = index.listAll(); + assertThat(list1).hasSize(1); + assertThat(list2).hasSize(2); + } } diff --git a/src/test/java/de/extio/game_engine/storage/StorageServiceImplTest.java b/src/test/java/de/extio/game_engine/storage/StorageServiceImplTest.java index 8e291a7..6c2a38e 100644 --- a/src/test/java/de/extio/game_engine/storage/StorageServiceImplTest.java +++ b/src/test/java/de/extio/game_engine/storage/StorageServiceImplTest.java @@ -2,10 +2,15 @@ package de.extio.game_engine.storage; import static org.assertj.core.api.Assertions.assertThat; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.AfterAll; @@ -16,51 +21,468 @@ import org.junit.jupiter.api.io.TempDir; import de.extio.game_engine.util.ObjectSerialization; class StorageServiceImplTest { - + @TempDir static Path tempDir; - + @BeforeAll static void setUpStorageLocation() { System.setProperty("storagelocation", tempDir.resolve("storage").toString()); } - + @AfterAll static void clearStorageLocation() { System.clearProperty("storagelocation"); } - + @Test void loadIndexNoFileDoesNotFailAndKeepsIndexEmpty() throws Exception { Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); - final StorageServiceImpl service = new StorageServiceImpl(); + final StorageService service = new StorageServiceImpl(); final StorageIndex index = this.getIndex(service); assertThat(index.isEmpty()).isTrue(); assertThat(Files.exists(StorageServiceImpl.INDEX_LOCATION)).isFalse(); } - + @Test void loadIndexDeserializesIndexFileAndAddsAllItems() throws Exception { Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); Files.createDirectories(StorageServiceImpl.STORAGE_LOCATION); - + final StorageItemDescriptor d1 = new StorageItemDescriptor(UUID.randomUUID(), "File1.txt", List.of("A")); final StorageItemDescriptor d2 = new StorageItemDescriptor(UUID.randomUUID(), "File2.txt", List.of("A", "B")); final StorageItemDescriptor[] descriptors = new StorageItemDescriptor[] { d1, d2 }; - final byte[] bytes = ObjectSerialization.serialize(descriptors, false, false, false, new byte[0], ignored -> { + final byte[] bytes = ObjectSerialization.serialize(descriptors, true, false, false, new byte[0], ignored -> { }); Files.write(StorageServiceImpl.INDEX_LOCATION, bytes); - - final StorageServiceImpl service = new StorageServiceImpl(); + + final StorageService service = new StorageServiceImpl(); final StorageIndex index = this.getIndex(service); assertThat(index.size()).isEqualTo(2); assertThat(index.find(List.of("a"), "file1.txt")).contains(d1); assertThat(index.find(List.of("a", "b"), "file2.txt")).contains(d2); } - - private StorageIndex getIndex(final StorageServiceImpl service) throws Exception { + + @Test + void storeCreatesNewObjectAndReturnsUUID() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + final UUID id = service.store("myfile.dat", List.of("folder", "subfolder"), data); + + assertThat(id).isNotNull(); + assertThat(Files.exists(StorageServiceImpl.DATA_LOCATION.resolve(id.toString()))).isTrue(); + + final StorageIndex index = this.getIndex(service); + final Optional descriptor = index.find(List.of("folder", "subfolder"), "myfile.dat"); + assertThat(descriptor).isPresent(); + assertThat(descriptor.get().id()).isEqualTo(id); + assertThat(descriptor.get().name()).isEqualTo("myfile.dat"); + assertThat(descriptor.get().path()).containsExactly("folder", "subfolder"); + } + + @Test + void storeStreamCreatesNewObjectAndReturnsUUID() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final String content = "Hello World"; + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + final UUID id = service.storeStream("mystream.txt", List.of("streams"), inputStream); + + assertThat(id).isNotNull(); + assertThat(Files.exists(StorageServiceImpl.DATA_LOCATION.resolve(id.toString()))).isTrue(); + + final StorageIndex index = this.getIndex(service); + final Optional descriptor = index.find(List.of("streams"), "mystream.txt"); + assertThat(descriptor).isPresent(); + assertThat(descriptor.get().id()).isEqualTo(id); + } + + @Test + void loadStreamByIdReturnsInputStream() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final String content = "Stream Content"; + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + final UUID id = service.storeStream("mystream.txt", List.of("streams"), inputStream); + + final Optional loaded = service.loadStreamById(id); + assertThat(loaded).isPresent(); + try (InputStream in = loaded.get()) { + final String loadedContent = new String(in.readAllBytes(), StandardCharsets.UTF_8); + assertThat(loadedContent).isEqualTo(content); + } + } + + @Test + void loadStreamByPathReturnsInputStream() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final String content = "Path Stream Content"; + final InputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + service.storeStream("pathstream.txt", List.of("path", "streams"), inputStream); + + final Optional loaded = service.loadStreamByPath(List.of("path", "streams"), "pathstream.txt"); + assertThat(loaded).isPresent(); + try (InputStream in = loaded.get()) { + final String loadedContent = new String(in.readAllBytes(), StandardCharsets.UTF_8); + assertThat(loadedContent).isEqualTo(content); + } + } + + @Test + void storeStreamWithOutputStreamCreatesNewObjectAndReturnsUUIDAndOutputStream() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final StorageOutputStream storageOutput = service.storeStream("outstream.txt", List.of("output", "streams")); + assertThat(storageOutput.id()).isNotNull(); + assertThat(storageOutput.outputStream()).isNotNull(); + + final String content = "Output Stream Content"; + try (OutputStream out = storageOutput.outputStream()) { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } + + assertThat(Files.exists(StorageServiceImpl.DATA_LOCATION.resolve(storageOutput.id().toString()))).isTrue(); + + final StorageIndex index = this.getIndex(service); + final Optional descriptor = index.find(List.of("output", "streams"), "outstream.txt"); + assertThat(descriptor).isPresent(); + assertThat(descriptor.get().id()).isEqualTo(storageOutput.id()); + } + + @Test + void storeStreamWithOutputStreamCanBeReadBack() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final String content = "Output Stream Readback Content"; + final StorageOutputStream storageOutput = service.storeStream("readback.txt", List.of("readback")); + try (OutputStream out = storageOutput.outputStream()) { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } + + final Optional loaded = service.loadStreamById(storageOutput.id()); + assertThat(loaded).isPresent(); + try (InputStream in = loaded.get()) { + final String loadedContent = new String(in.readAllBytes(), StandardCharsets.UTF_8); + assertThat(loadedContent).isEqualTo(content); + } + } + + @Test + void storeReplacesExistingObjectWithSamePathAndName() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageServiceImpl service = new StorageServiceImpl(); + + final TestData data1 = new TestData("first", 100); + final UUID id1 = service.store("myfile.dat", List.of("folder"), data1); + + final TestData data2 = new TestData("second", 200); + final UUID id2 = service.store("myfile.dat", List.of("folder"), data2); + + assertThat(id1).isEqualTo(id2); + + final Optional loaded = service.loadById(TestData.class, id2); + assertThat(loaded).isPresent(); + assertThat(loaded.get().value()).isEqualTo("second"); + assertThat(loaded.get().number()).isEqualTo(200); + + final StorageIndex index = this.getIndex(service); + assertThat(index.size()).isEqualTo(1); + } + + @Test + void deleteByIdRemovesObjectAndFile() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + final UUID id = service.store("myfile.dat", List.of("folder"), data); + + final Path filePath = StorageServiceImpl.DATA_LOCATION.resolve(id.toString()); + assertThat(Files.exists(filePath)).isTrue(); + + final boolean deleted = service.deleteById(id); + assertThat(deleted).isTrue(); + assertThat(Files.exists(filePath)).isFalse(); + + final StorageIndex index = this.getIndex(service); + assertThat(index.getById(id)).isEmpty(); + } + + @Test + void deleteByIdReturnsFalseForNonExistentId() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final boolean deleted = service.deleteById(UUID.randomUUID()); + assertThat(deleted).isFalse(); + } + + @Test + void deleteByPathRemovesObject() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + final UUID id = service.store("myfile.dat", List.of("folder"), data); + + final boolean deleted = service.deleteByPath(List.of("folder"), "myfile.dat"); + assertThat(deleted).isTrue(); + + final StorageIndex index = this.getIndex(service); + assertThat(index.getById(id)).isEmpty(); + } + + @Test + void deleteByPathReturnsFalseForNonExistentPath() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final boolean deleted = service.deleteByPath(List.of("nonexistent"), "file.dat"); + assertThat(deleted).isFalse(); + } + + @Test + void moveByIdUpdatesPathAndName() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + final UUID id = service.store("oldfile.dat", List.of("old", "path"), data); + + final boolean moved = service.moveById(id, List.of("new", "path"), "newfile.dat"); + assertThat(moved).isTrue(); + + final StorageIndex index = this.getIndex(service); + final Optional oldLocation = index.find(List.of("old", "path"), "oldfile.dat"); + assertThat(oldLocation).isEmpty(); + + final Optional newLocation = index.find(List.of("new", "path"), "newfile.dat"); + assertThat(newLocation).isPresent(); + assertThat(newLocation.get().id()).isEqualTo(id); + } + + @Test + void moveByIdReturnsFalseForNonExistentId() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final boolean moved = service.moveById(UUID.randomUUID(), List.of("new"), "file.dat"); + assertThat(moved).isFalse(); + } + + @Test + void moveByPathUpdatesLocation() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + service.store("oldfile.dat", List.of("old"), data); + + final boolean moved = service.moveByPath(List.of("old"), "oldfile.dat", List.of("new"), "newfile.dat"); + assertThat(moved).isTrue(); + + final StorageIndex index = this.getIndex(service); + final Optional newLocation = index.find(List.of("new"), "newfile.dat"); + assertThat(newLocation).isPresent(); + } + + @Test + void loadByIdDeserializesObject() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageServiceImpl service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 456); + final UUID id = service.store("myfile.dat", List.of("folder"), data); + + final Optional loaded = service.loadById(TestData.class, id); + assertThat(loaded).isPresent(); + assertThat(loaded.get().value()).isEqualTo("test"); + assertThat(loaded.get().number()).isEqualTo(456); + } + + @Test + void loadByIdReturnsEmptyForNonExistentId() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageServiceImpl service = new StorageServiceImpl(); + + final Optional loaded = service.loadById(TestData.class, UUID.randomUUID()); + assertThat(loaded).isEmpty(); + } + + @Test + void loadByPathDeserializesObject() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageServiceImpl service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 789); + service.store("myfile.dat", List.of("folder", "sub"), data); + + final Optional loaded = service.loadByPath(TestData.class, List.of("folder", "sub"), "myfile.dat"); + assertThat(loaded).isPresent(); + assertThat(loaded.get().value()).isEqualTo("test"); + assertThat(loaded.get().number()).isEqualTo(789); + } + + @Test + void searchByIdFindsDescriptor() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + final UUID id = service.store("myfile.dat", List.of("folder"), data); + + final Optional descriptor = service.searchById(id); + assertThat(descriptor).isPresent(); + assertThat(descriptor.get().id()).isEqualTo(id); + assertThat(descriptor.get().name()).isEqualTo("myfile.dat"); + } + + @Test + void searchByPathFindsDescriptor() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final TestData data = new TestData("test", 123); + final UUID id = service.store("myfile.dat", List.of("folder"), data); + + final Optional descriptor = service.searchByPath(List.of("folder"), "myfile.dat"); + assertThat(descriptor).isPresent(); + assertThat(descriptor.get().id()).isEqualTo(id); + } + + @Test + void searchByPatternFindsMatchingFiles() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("file1.txt", List.of("docs"), new TestData("a", 1)); + service.store("file2.txt", List.of("docs"), new TestData("b", 2)); + service.store("data.dat", List.of("docs"), new TestData("c", 3)); + + final List matches = service.searchByPattern(List.of("docs"), "*.txt", false); + assertThat(matches).hasSize(2); + assertThat(matches.stream().map(StorageItemDescriptor::name)).containsExactlyInAnyOrder("file1.txt", "file2.txt"); + } + + @Test + void searchByPatternRecursiveSearchesSubdirectories() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("root.txt", List.of(), new TestData("a", 1)); + service.store("sub1.txt", List.of("level1"), new TestData("b", 2)); + service.store("sub2.txt", List.of("level1", "level2"), new TestData("c", 3)); + + final List matches = service.searchByPattern(List.of(), "*.txt", true); + assertThat(matches).hasSize(3); + } + + @Test + void listPathReturnsAllItemsInPath() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("file1.txt", List.of("docs"), new TestData("a", 1)); + service.store("file2.txt", List.of("docs"), new TestData("b", 2)); + service.store("other.txt", List.of("other"), new TestData("c", 3)); + + final List items = service.listPath(List.of("docs"), false); + assertThat(items).hasSize(2); + assertThat(items.stream().map(StorageItemDescriptor::name)).containsExactlyInAnyOrder("file1.txt", "file2.txt"); + } + + @Test + void listPathNonRecursiveReturnsOnlyDirectItems() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("root.txt", List.of("docs"), new TestData("a", 1)); + service.store("sub1.txt", List.of("docs", "subdir"), new TestData("b", 2)); + service.store("sub2.txt", List.of("docs", "subdir", "deeper"), new TestData("c", 3)); + + final List items = service.listPath(List.of("docs"), false); + assertThat(items).hasSize(1); + assertThat(items.get(0).name()).isEqualTo("root.txt"); + } + + @Test + void listPathRecursiveReturnsAllItemsInSubdirectories() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("root.txt", List.of("docs"), new TestData("a", 1)); + service.store("sub1.txt", List.of("docs", "subdir"), new TestData("b", 2)); + service.store("sub2.txt", List.of("docs", "subdir", "deeper"), new TestData("c", 3)); + + final List items = service.listPath(List.of("docs"), true); + assertThat(items).hasSize(3); + assertThat(items.stream().map(StorageItemDescriptor::name)).containsExactlyInAnyOrder("root.txt", "sub1.txt", "sub2.txt"); + } + + @Test + void listPathRecursiveFromRootReturnsAllItems() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("file1.txt", List.of(), new TestData("a", 1)); + service.store("file2.txt", List.of("level1"), new TestData("b", 2)); + service.store("file3.txt", List.of("level1", "level2"), new TestData("c", 3)); + service.store("file4.txt", List.of("other"), new TestData("d", 4)); + + final List items = service.listPath(List.of(), true); + assertThat(items).hasSize(4); + assertThat(items.stream().map(StorageItemDescriptor::name)).containsExactlyInAnyOrder("file1.txt", "file2.txt", "file3.txt", "file4.txt"); + } + + @Test + void listAllReturnsAllStoredItems() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + service.store("file1.txt", List.of("docs"), new TestData("a", 1)); + service.store("file2.txt", List.of("data"), new TestData("b", 2)); + service.store("file3.txt", List.of("docs", "sub"), new TestData("c", 3)); + + final List items = service.listAll(); + assertThat(items).hasSize(3); + assertThat(items.stream().map(StorageItemDescriptor::name)).containsExactlyInAnyOrder("file1.txt", "file2.txt", "file3.txt"); + } + + @Test + void listAllOnEmptyServiceReturnsEmpty() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final List items = service.listAll(); + assertThat(items).isEmpty(); + } + + @Test + void listAllAfterDeletionReflectsRemovedItems() throws Exception { + Files.deleteIfExists(StorageServiceImpl.INDEX_LOCATION); + final StorageService service = new StorageServiceImpl(); + + final UUID id = service.store("file.txt", List.of("docs"), new TestData("test", 1)); + assertThat(service.listAll()).hasSize(1); + + service.deleteById(id); + assertThat(service.listAll()).isEmpty(); + } + + private StorageIndex getIndex(final StorageService service) throws Exception { final Field field = StorageServiceImpl.class.getDeclaredField("index"); field.setAccessible(true); return (StorageIndex) field.get(service); } + + public static record TestData(String value, int number) { + } } diff --git a/src/test/java/de/extio/game_engine/util/ObjectSerializationTest.java b/src/test/java/de/extio/game_engine/util/ObjectSerializationTest.java index 3e26999..2ca1d5f 100644 --- a/src/test/java/de/extio/game_engine/util/ObjectSerializationTest.java +++ b/src/test/java/de/extio/game_engine/util/ObjectSerializationTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; +import java.io.ByteArrayOutputStream; import java.util.List; import java.util.Map; @@ -133,6 +134,112 @@ public class ObjectSerializationTest { assertEquals(original.value, deserialized.value); } + @Test + public void testSerializeOutputStreamBasic() { + var original = new TestObject("OutputStreamTest", 42, 3.14, List.of("tag1", "tag2"), List.of(new TestObject2("key", true)), Map.of("attr", 1)); + var outputStream = new ByteArrayOutputStream(); + + ObjectSerialization.serialize(original, outputStream, false, false, false, null, digest -> {}); + + var serialized = outputStream.toByteArray(); + assertNotNull(serialized); + assertTrue(serialized.length > 0); + + var deserialized = ObjectSerialization.deserialize(TestObject.class, serialized, false, false, null, null, result -> {}); + + assertNotNull(deserialized); + assertEquals(original.name, deserialized.name); + assertEquals(original.id, deserialized.id); + assertEquals(original.value, deserialized.value); + } + + @Test + public void testSerializeOutputStreamWithCompression() { + var original = new TestObject("CompressedStream", 100, 2.71, List.of("a", "b", "c"), List.of(), Map.of()); + var outputStream = new ByteArrayOutputStream(); + + ObjectSerialization.serialize(original, outputStream, true, false, false, null, digest -> {}); + + var serialized = outputStream.toByteArray(); + assertNotNull(serialized); + assertTrue(serialized.length > 0); + + var deserialized = ObjectSerialization.deserialize(TestObject.class, serialized, true, false, null, null, result -> {}); + + assertNotNull(deserialized); + assertEquals(original.name, deserialized.name); + assertEquals(original.id, deserialized.id); + assertEquals(original.value, deserialized.value); + } + + @Test + public void testSerializeOutputStreamWithBase64() { + var original = new TestObject("Base64Stream", 200, 1.41, List.of(), List.of(), Map.of()); + var outputStream = new ByteArrayOutputStream(); + + ObjectSerialization.serialize(original, outputStream, false, true, false, null, digest -> {}); + + var serialized = outputStream.toByteArray(); + assertNotNull(serialized); + assertTrue(serialized.length > 0); + assertTrue(new String(serialized).matches("[A-Za-z0-9+/=]+")); + + var deserialized = ObjectSerialization.deserialize(TestObject.class, serialized, false, true, null, null, result -> {}); + + assertNotNull(deserialized); + assertEquals(original.name, deserialized.name); + assertEquals(original.id, deserialized.id); + assertEquals(original.value, deserialized.value); + } + + @Test + public void testSerializeOutputStreamWithDigest() { + var original = new TestObject("DigestedStream", 300, 0.5, List.of("x"), List.of(), Map.of()); + var digestSalt = "salt".getBytes(); + var capturedDigest = new byte[32][]; + var outputStream = new ByteArrayOutputStream(); + + ObjectSerialization.serialize(original, outputStream, false, false, true, digestSalt, digest -> capturedDigest[0] = digest); + + var serialized = outputStream.toByteArray(); + assertNotNull(serialized); + assertNotNull(capturedDigest[0]); + assertTrue(capturedDigest[0].length == 32); + + var digestCheckPassed = new boolean[1]; + var deserialized = ObjectSerialization.deserialize(TestObject.class, serialized, false, false, capturedDigest[0], digestSalt, result -> digestCheckPassed[0] = result); + + assertNotNull(deserialized); + assertTrue(digestCheckPassed[0]); + assertEquals(original.name, deserialized.name); + assertEquals(original.id, deserialized.id); + assertEquals(original.value, deserialized.value); + } + + @Test + public void testSerializeOutputStreamWithAllOptions() { + var original = new TestObject("AllOptionsStream", 500, 9.99, List.of("all"), List.of(new TestObject2("a", false), new TestObject2("b", true)), Map.of("x", 1, "y", 2)); + var digestSalt = "salt123".getBytes(); + var capturedDigest = new byte[32][]; + var outputStream = new ByteArrayOutputStream(); + + ObjectSerialization.serialize(original, outputStream, true, true, true, digestSalt, digest -> capturedDigest[0] = digest); + + var serialized = outputStream.toByteArray(); + assertNotNull(serialized); + assertNotNull(capturedDigest[0]); + assertTrue(new String(serialized).matches("[A-Za-z0-9+/=]+")); + + var digestCheckPassed = new boolean[1]; + var deserialized = ObjectSerialization.deserialize(TestObject.class, serialized, true, true, capturedDigest[0], digestSalt, result -> digestCheckPassed[0] = result); + + assertNotNull(deserialized); + assertTrue(digestCheckPassed[0]); + assertEquals(original.name, deserialized.name); + assertEquals(original.id, deserialized.id); + assertEquals(original.value, deserialized.value); + } + public record TestObject(String name, int id, double value, List tags, List children, Map attributes) { }