From 129cbbe11d8ab5b4941de43ed4819e9852ee96c2 Mon Sep 17 00:00:00 2001 From: Sam Carlberg Date: Sat, 30 Aug 2025 23:15:22 -0400 Subject: [PATCH] [epilogue] Optimize time and memory usage of epilogue backends (#8190) --- .../epilogue/logging/ClassSpecificLogger.java | 17 +- .../first/epilogue/logging/FileBackend.java | 35 +++- .../first/epilogue/logging/LazyBackend.java | 8 +- .../first/epilogue/logging/MultiBackend.java | 8 +- .../epilogue/logging/NTEpilogueBackend.java | 108 +++++++---- .../first/epilogue/logging/NestedBackend.java | 67 +++++-- .../epilogue/logging/NestedBackendTest.java | 180 ++++++++++++++++++ 7 files changed, 354 insertions(+), 69 deletions(-) create mode 100644 epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/NestedBackendTest.java diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java index c433d739a5..9cbd2ba2f7 100644 --- a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java @@ -106,14 +106,13 @@ public abstract class ClassSpecificLogger { return; } - var builder = - m_sendables.computeIfAbsent( - sendable, - s -> { - var b = new LogBackedSendableBuilder(backend); - s.initSendable(b); - return b; - }); - builder.update(); + if (m_sendables.containsKey(sendable)) { + m_sendables.get(sendable).update(); + } else { + var builder = new LogBackedSendableBuilder(backend); + sendable.initSendable(builder); + m_sendables.put(sendable, builder); + builder.update(); + } } } diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileBackend.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileBackend.java index 2b5b6b2071..0a116a03d9 100644 --- a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileBackend.java +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileBackend.java @@ -23,7 +23,9 @@ import edu.wpi.first.util.datalog.StructArrayLogEntry; import edu.wpi.first.util.datalog.StructLogEntry; import edu.wpi.first.util.struct.Struct; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import java.util.function.BiFunction; /** A backend implementation that saves information to a WPILib {@link DataLog} file on disk. */ @@ -31,6 +33,7 @@ public class FileBackend implements EpilogueBackend { private final DataLog m_dataLog; private final Map m_entries = new HashMap<>(); private final Map m_subLoggers = new HashMap<>(); + private final Set> m_seenSchemas = new HashSet<>(); /** * Creates a new file-based backend. @@ -43,7 +46,13 @@ public class FileBackend implements EpilogueBackend { @Override public EpilogueBackend getNested(String path) { - return m_subLoggers.computeIfAbsent(path, k -> new NestedBackend(k, this)); + if (!m_subLoggers.containsKey(path)) { + var nested = new NestedBackend(path, this); + m_subLoggers.put(path, nested); + return nested; + } + + return m_subLoggers.get(path); } @SuppressWarnings("unchecked") @@ -131,14 +140,30 @@ public class FileBackend implements EpilogueBackend { @Override @SuppressWarnings("unchecked") public void log(String identifier, S value, Struct struct) { - m_dataLog.addSchema(struct); - getEntry(identifier, (log, k) -> StructLogEntry.create(log, k, struct)).append(value); + // DataLog.addSchema has checks that we're able to skip, avoiding allocations + if (m_seenSchemas.add(struct)) { + m_dataLog.addSchema(struct); + } + + if (!m_entries.containsKey(identifier)) { + m_entries.put(identifier, StructLogEntry.create(m_dataLog, identifier, struct)); + } + + ((StructLogEntry) m_entries.get(identifier)).append(value); } @Override @SuppressWarnings("unchecked") public void log(String identifier, S[] value, Struct struct) { - m_dataLog.addSchema(struct); - getEntry(identifier, (log, k) -> StructArrayLogEntry.create(log, k, struct)).append(value); + // DataLog.addSchema has checks that we're able to skip, avoiding allocations + if (m_seenSchemas.add(struct)) { + m_dataLog.addSchema(struct); + } + + if (!m_entries.containsKey(identifier)) { + m_entries.put(identifier, StructArrayLogEntry.create(m_dataLog, identifier, struct)); + } + + ((StructArrayLogEntry) m_entries.get(identifier)).append(value); } } diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyBackend.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyBackend.java index adad963e07..bd925165e0 100644 --- a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyBackend.java +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyBackend.java @@ -40,7 +40,13 @@ public class LazyBackend implements EpilogueBackend { @Override public EpilogueBackend getNested(String path) { - return m_subLoggers.computeIfAbsent(path, k -> new NestedBackend(k, this)); + if (!m_subLoggers.containsKey(path)) { + var nested = new NestedBackend(path, this); + m_subLoggers.put(path, nested); + return nested; + } + + return m_subLoggers.get(path); } @Override diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiBackend.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiBackend.java index 408d65aad6..575fde05b2 100644 --- a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiBackend.java +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiBackend.java @@ -24,7 +24,13 @@ public class MultiBackend implements EpilogueBackend { @Override public EpilogueBackend getNested(String path) { - return m_nestedBackends.computeIfAbsent(path, k -> new NestedBackend(k, this)); + if (!m_nestedBackends.containsKey(path)) { + var nested = new NestedBackend(path, this); + m_nestedBackends.put(path, nested); + return nested; + } + + return m_nestedBackends.get(path); } @Override diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTEpilogueBackend.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTEpilogueBackend.java index cf381a2f02..e398172e77 100644 --- a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTEpilogueBackend.java +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTEpilogueBackend.java @@ -21,7 +21,10 @@ import edu.wpi.first.networktables.StructArrayPublisher; import edu.wpi.first.networktables.StructPublisher; import edu.wpi.first.util.struct.Struct; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.function.Function; /** * A backend implementation that sends data over network tables. Be careful when using this, since @@ -32,61 +35,81 @@ public class NTEpilogueBackend implements EpilogueBackend { private final Map m_publishers = new HashMap<>(); private final Map m_nestedBackends = new HashMap<>(); + private final Set> m_seenSchemas = new HashSet<>(); + private final Function m_createIntPublisher; + private final Function m_createFloatPublisher; + private final Function m_createDoublePublisher; + private final Function m_createBooleanPublisher; + private final Function m_createRawPublisher; + private final Function m_createIntegerArrayPublisher; + private final Function m_createFloatArrayPublisher; + private final Function m_createDoubleArrayPublisher; + private final Function m_createBooleanArrayPublisher; + private final Function m_createStringPublisher; + private final Function m_createStringArrayPublisher; /** * Creates a logging backend that sends information to NetworkTables. * * @param nt the NetworkTable instance to use to send data to */ + @SuppressWarnings("unchecked") public NTEpilogueBackend(NetworkTableInstance nt) { this.m_nt = nt; + m_createIntPublisher = identifier -> m_nt.getIntegerTopic(identifier).publish(); + m_createFloatPublisher = identifier -> m_nt.getFloatTopic(identifier).publish(); + m_createDoublePublisher = identifier -> m_nt.getDoubleTopic(identifier).publish(); + m_createBooleanPublisher = identifier -> m_nt.getBooleanTopic(identifier).publish(); + m_createRawPublisher = identifier -> m_nt.getRawTopic(identifier).publish("raw"); + m_createIntegerArrayPublisher = identifier -> m_nt.getIntegerArrayTopic(identifier).publish(); + m_createFloatArrayPublisher = identifier -> m_nt.getFloatArrayTopic(identifier).publish(); + m_createDoubleArrayPublisher = identifier -> m_nt.getDoubleArrayTopic(identifier).publish(); + m_createBooleanArrayPublisher = identifier -> m_nt.getBooleanArrayTopic(identifier).publish(); + m_createStringPublisher = identifier -> m_nt.getStringTopic(identifier).publish(); + m_createStringArrayPublisher = identifier -> m_nt.getStringArrayTopic(identifier).publish(); } @Override public EpilogueBackend getNested(String path) { - return m_nestedBackends.computeIfAbsent(path, k -> new NestedBackend(k, this)); + if (!m_nestedBackends.containsKey(path)) { + var nested = new NestedBackend(path, this); + m_nestedBackends.put(path, nested); + return nested; + } + + return m_nestedBackends.get(path); } @Override public void log(String identifier, int value) { - ((IntegerPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerTopic(k).publish())) - .set(value); + ((IntegerPublisher) m_publishers.computeIfAbsent(identifier, m_createIntPublisher)).set(value); } @Override public void log(String identifier, long value) { - ((IntegerPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerTopic(k).publish())) - .set(value); + ((IntegerPublisher) m_publishers.computeIfAbsent(identifier, m_createIntPublisher)).set(value); } @Override public void log(String identifier, float value) { - ((FloatPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getFloatTopic(k).publish())) - .set(value); + ((FloatPublisher) m_publishers.computeIfAbsent(identifier, m_createFloatPublisher)).set(value); } @Override public void log(String identifier, double value) { - ((DoublePublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getDoubleTopic(k).publish())) + ((DoublePublisher) m_publishers.computeIfAbsent(identifier, m_createDoublePublisher)) .set(value); } @Override public void log(String identifier, boolean value) { - ((BooleanPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getBooleanTopic(k).publish())) + ((BooleanPublisher) m_publishers.computeIfAbsent(identifier, m_createBooleanPublisher)) .set(value); } @Override public void log(String identifier, byte[] value) { - ((RawPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getRawTopic(k).publish("raw"))) - .set(value); + ((RawPublisher) m_publishers.computeIfAbsent(identifier, m_createRawPublisher)).set(value); } @Override @@ -100,68 +123,79 @@ public class NTEpilogueBackend implements EpilogueBackend { } ((IntegerArrayPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerArrayTopic(k).publish())) + m_publishers.computeIfAbsent(identifier, m_createIntegerArrayPublisher)) .set(widened); } @Override public void log(String identifier, long[] value) { ((IntegerArrayPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerArrayTopic(k).publish())) + m_publishers.computeIfAbsent(identifier, m_createIntegerArrayPublisher)) .set(value); } @Override public void log(String identifier, float[] value) { - ((FloatArrayPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getFloatArrayTopic(k).publish())) + ((FloatArrayPublisher) m_publishers.computeIfAbsent(identifier, m_createFloatArrayPublisher)) .set(value); } @Override public void log(String identifier, double[] value) { - ((DoubleArrayPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getDoubleArrayTopic(k).publish())) + ((DoubleArrayPublisher) m_publishers.computeIfAbsent(identifier, m_createDoubleArrayPublisher)) .set(value); } @Override public void log(String identifier, boolean[] value) { ((BooleanArrayPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getBooleanArrayTopic(k).publish())) + m_publishers.computeIfAbsent(identifier, m_createBooleanArrayPublisher)) .set(value); } @Override public void log(String identifier, String value) { - ((StringPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getStringTopic(k).publish())) + ((StringPublisher) m_publishers.computeIfAbsent(identifier, m_createStringPublisher)) .set(value); } @Override public void log(String identifier, String[] value) { - ((StringArrayPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getStringArrayTopic(k).publish())) + ((StringArrayPublisher) m_publishers.computeIfAbsent(identifier, m_createStringArrayPublisher)) .set(value); } @Override @SuppressWarnings("unchecked") public void log(String identifier, S value, Struct struct) { - m_nt.addSchema(struct); - ((StructPublisher) - m_publishers.computeIfAbsent(identifier, k -> m_nt.getStructTopic(k, struct).publish())) - .set(value); + // NetworkTableInstance.addSchema has checks that we're able to skip, avoiding allocations + if (m_seenSchemas.add(struct)) { + m_nt.addSchema(struct); + } + + if (m_publishers.containsKey(identifier)) { + ((StructPublisher) m_publishers.get(identifier)).set(value); + } else { + StructPublisher publisher = m_nt.getStructTopic(identifier, struct).publish(); + m_publishers.put(identifier, publisher); + publisher.set(value); + } } @Override @SuppressWarnings("unchecked") public void log(String identifier, S[] value, Struct struct) { - m_nt.addSchema(struct); - ((StructArrayPublisher) - m_publishers.computeIfAbsent( - identifier, k -> m_nt.getStructArrayTopic(k, struct).publish())) - .set(value); + // NetworkTableInstance.addSchema has checks that we're able to skip, avoiding allocations + if (m_seenSchemas.add(struct)) { + m_nt.addSchema(struct); + } + + if (m_publishers.containsKey(identifier)) { + ((StructArrayPublisher) m_publishers.get(identifier)).set(value); + } else { + StructArrayPublisher publisher = m_nt.getStructArrayTopic(identifier, struct).publish(); + m_publishers.put(identifier, publisher); + publisher.set(value); + } } } diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NestedBackend.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NestedBackend.java index 50003b24ca..f288566085 100644 --- a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NestedBackend.java +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NestedBackend.java @@ -17,6 +17,15 @@ public class NestedBackend implements EpilogueBackend { private final EpilogueBackend m_impl; private final Map m_nestedBackends = new HashMap<>(); + // String concatenation can be expensive, especially for deeply nested hierarchies with many + // logged fields. For example, logging a hypothetical Robot.elevator.io.getHeight() would result + // in "/Robot/" + "elevator/" + "io/" + "getHeight"; three concatenations and string and byte + // array allocations that need to be cleaned up by the GC. Caching the results means those + // allocations only occur once, resulting in no GC (the strings are always referenced in the + // cache), and minimal time costs (the String object caches its own hash code, so all we do is an + // O(1) table lookup per concatenation) + private final Map m_prefixedIdentifiers = new HashMap<>(); + /** * Creates a new nested backed underneath another backend. * @@ -33,83 +42,109 @@ public class NestedBackend implements EpilogueBackend { this.m_impl = impl; } + /** + * Fast lookup to avoid redundant `m_prefix + identifier` concatenations. If the identifier has + * not been seen before, we compute the concatenation and cache the result for later invocations + * to read. This avoids redundantly recomputing the same concatenations every loop and + * significantly cuts down on the CPU and memory overhead of the Epilogue library. + * + * @param identifier The identifier to prepend with {@link #m_prefix}. + * @return The concatenated string. + */ + private String withPrefix(String identifier) { + // Using computeIfAbsent would result in a new lambda object allocation on every call + if (m_prefixedIdentifiers.containsKey(identifier)) { + return m_prefixedIdentifiers.get(identifier); + } + + String result = m_prefix + identifier; + m_prefixedIdentifiers.put(identifier, result); + return result; + } + @Override public EpilogueBackend getNested(String path) { - return m_nestedBackends.computeIfAbsent(path, k -> new NestedBackend(k, this)); + if (!m_nestedBackends.containsKey(path)) { + var nested = new NestedBackend(path, this); + m_nestedBackends.put(path, nested); + return nested; + } + + return m_nestedBackends.get(path); } @Override public void log(String identifier, int value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, long value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, float value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, double value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, boolean value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, byte[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, int[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, long[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, float[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, double[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, boolean[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, String value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, String[] value) { - m_impl.log(m_prefix + identifier, value); + m_impl.log(withPrefix(identifier), value); } @Override public void log(String identifier, S value, Struct struct) { - m_impl.log(m_prefix + identifier, value, struct); + m_impl.log(withPrefix(identifier), value, struct); } @Override public void log(String identifier, S[] value, Struct struct) { - m_impl.log(m_prefix + identifier, value, struct); + m_impl.log(withPrefix(identifier), value, struct); } } diff --git a/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/NestedBackendTest.java b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/NestedBackendTest.java new file mode 100644 index 0000000000..e9ee64ac6a --- /dev/null +++ b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/NestedBackendTest.java @@ -0,0 +1,180 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +class NestedBackendTest { + @Test + void prefixesAppliedAndNested() { + var root = new TestBackend(); + var nested = new NestedBackend("/Robot", root); + + nested.log("int", 1); + nested.log("string", "hello"); + + var arm = nested.getNested("arm"); + arm.log("position", 2.0); + arm.log("enabled", true); + + assertEquals(4, root.getEntries().size()); + assertEquals("/Robot/int", root.getEntries().get(0).identifier()); + assertEquals(1, root.getEntries().get(0).value()); + + assertEquals("/Robot/string", root.getEntries().get(1).identifier()); + assertEquals("hello", root.getEntries().get(1).value()); + + assertEquals("/Robot/arm/position", root.getEntries().get(2).identifier()); + assertEquals(2.0, root.getEntries().get(2).value()); + + assertEquals("/Robot/arm/enabled", root.getEntries().get(3).identifier()); + assertEquals(true, root.getEntries().get(3).value()); + } + + @Test + void handlesTrailingSlashOnPrefix() { + var root = new TestBackend(); + var a = new NestedBackend("/Robot", root); + var b = new NestedBackend("/Robot/", root); + + a.log("x", 1); + b.log("y", 2); + + assertEquals("/Robot/x", root.getEntries().get(0).identifier()); + assertEquals("/Robot/y", root.getEntries().get(1).identifier()); + } + + @Test + void getNestedIsCached() { + var root = new TestBackend(); + var nested = new NestedBackend("/Robot", root); + + var arm1 = nested.getNested("arm"); + var arm2 = nested.getNested("arm"); + + assertSame(arm1, arm2); + } + + @Test + void usesPrefixedIdentifierCacheForSameField() { + var root = new TestBackend(); + var nested = new NestedBackend("/Robot", root); + + // Same field logged multiple times - identifier object should be the same (cached) + // We use assertSame to check that the references are identical + nested.log("x", 0); + nested.log("x", 1); + + String id0 = root.getEntries().get(0).identifier(); + String id1 = root.getEntries().get(1).identifier(); + assertSame( + id0, + id1, + "Identifier %s (id: %d) was not reused (new id: %d)" + .formatted(id0, System.identityHashCode(id0), System.identityHashCode(id1))); + + // Also verify through a nested backend path + var arm = nested.getNested("arm"); + arm.log("position", 0.0); + arm.log("position", 1.0); + + String id2 = root.getEntries().get(2).identifier(); + String id3 = root.getEntries().get(3).identifier(); + assertSame( + id2, + id3, + "Identifier %s (id: %d) was not reused (new id: %d)" + .formatted(id2, System.identityHashCode(id2), System.identityHashCode(id3))); + + // Sanity check actual full values + assertEquals("/Robot/x", id0); + assertEquals("/Robot/arm/position", id2); + } + + @Test + void logsAllOverloads() { + var root = new TestBackend(); + var nested = new NestedBackend("/Robot", root); + + // Scalars + nested.log("int", 1); + nested.log("long", 2L); + nested.log("float", 3.0f); + nested.log("double", 4.0); + nested.log("boolean", true); + nested.log("string", "hello"); + + // Arrays + nested.log("bytes", new byte[] {1, 2}); + nested.log("ints", new int[] {3, 4}); + nested.log("longs", new long[] {5L, 6L}); + nested.log("floats", new float[] {7.0f, 8.0f}); + nested.log("doubles", new double[] {9.0, 10.0}); + nested.log("booleans", new boolean[] {true, false}); + nested.log("strings", new String[] {"x", "y"}); + + // Structs + nested.log("customStruct", new CustomStruct(7), CustomStruct.struct); + nested.log( + "customStructs", + new CustomStruct[] {new CustomStruct(0), new CustomStruct(1)}, + CustomStruct.struct); + + var entries = root.getEntries(); + int idx = 0; + + // Scalars + assertEquals(new TestBackend.LogEntry<>("/Robot/int", 1), entries.get(idx++)); + assertEquals(new TestBackend.LogEntry<>("/Robot/long", 2L), entries.get(idx++)); + assertEquals(new TestBackend.LogEntry<>("/Robot/float", 3.0f), entries.get(idx++)); + assertEquals(new TestBackend.LogEntry<>("/Robot/double", 4.0), entries.get(idx++)); + assertEquals(new TestBackend.LogEntry<>("/Robot/boolean", true), entries.get(idx++)); + assertEquals(new TestBackend.LogEntry<>("/Robot/string", "hello"), entries.get(idx++)); + + // Arrays + assertEquals("/Robot/bytes", entries.get(idx).identifier()); + assertArrayEquals(new byte[] {1, 2}, (byte[]) entries.get(idx++).value()); + + assertEquals("/Robot/ints", entries.get(idx).identifier()); + assertArrayEquals(new int[] {3, 4}, (int[]) entries.get(idx++).value()); + + assertEquals("/Robot/longs", entries.get(idx).identifier()); + assertArrayEquals(new long[] {5L, 6L}, (long[]) entries.get(idx++).value()); + + assertEquals("/Robot/floats", entries.get(idx).identifier()); + assertArrayEquals(new float[] {7.0f, 8.0f}, (float[]) entries.get(idx++).value()); + + assertEquals("/Robot/doubles", entries.get(idx).identifier()); + assertArrayEquals(new double[] {9.0, 10.0}, (double[]) entries.get(idx++).value()); + + assertEquals("/Robot/booleans", entries.get(idx).identifier()); + assertArrayEquals(new boolean[] {true, false}, (boolean[]) entries.get(idx++).value()); + + assertEquals("/Robot/strings", entries.get(idx).identifier()); + assertArrayEquals(new String[] {"x", "y"}, (String[]) entries.get(idx++).value()); + + // Structs are serialized to bytes + assertEquals("/Robot/customStruct", entries.get(idx).identifier()); + assertArrayEquals(new byte[] {0x07, 0x00, 0x00, 0x00}, (byte[]) entries.get(idx++).value()); + + assertEquals("/Robot/customStructs", entries.get(idx).identifier()); + // two int32 values, little-endian + assertArrayEquals( + new byte[] { + 0x00, 0x00, 0x00, 0x00, // 0 (first element) + 0x01, 0x00, 0x00, 0x00, // 1 (second element) + 0x00, 0x00, 0x00, 0x00, // 0 (empty space allocated by StructBuffer) + 0x00, 0x00, 0x00, 0x00 // 0 (empty space allocated by StructBuffer) + }, + (byte[]) entries.get(idx++).value()); + + // Ensure we covered all calls + assertEquals(idx, entries.size()); + } +}