diff --git a/TESTING.md b/TESTING.md index 5f94946b3..39b7a63c3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -75,7 +75,7 @@ Nothing here touches a device. Fixtures are synthesised in code; no device data | Suite | Tests | | --- | --- | -| Java, standard | **284**, 14 skipped — the opt-in FAT32 classes, and two link cases each of which only one platform can set up. A third conditional case, the non-loopback binding check, skips only on a machine with no routable address | +| Java, standard | **304**, 14 skipped — the opt-in FAT32 classes, and two link cases each of which only one platform can set up. A third conditional case, the non-loopback binding check, skips only on a machine with no routable address | | Java, with `-Dstudio.test.fat32.root=` | last measured at **172** before the C6d-5 additions; not re-measured since, because it needs the volume mounted | | JavaScript | **57** | @@ -91,6 +91,10 @@ counts. | Area | Test class | Notes | | --- | --- | --- | | Endianness helpers used for the V3 AES key | `BytesUtilsCharacterizationTest` | core module | +| What a raw binary pack keeps across a write and a read back, and what it does not | `BinaryPackRoundTripTest` | `core` module; specifications and characterization together. The `KNOWN GAP` cases are the pack uuid, asset names, sector padding, and an option pointing at the first stage node | +| The same for the archive format, where the two differ | `ArchivePackRoundTripTest` | `core` module; asset bytes and option lists survive here but the pack uuid does not, for the same reason | +| Packs synthesised in code for both of the above | `PackFixtures` | `core` module; support only, asserts nothing. No device data, no third-party content | +| The XXTEA round trip, its boundaries, and a fixed vector | `XXTEACipherTest` | `core` module; **specifications** — every ciphered device transfer goes through this and nothing exercised it. One `KNOWN GAP`: a one-word block passes through in clear | | UUID → `.content` folder name | `PackFolderNamingCharacterizationTest` | includes the driver/core duplication cross-check | | V2 and V3 transfer ciphering, file selection | `CipherUtilsCharacterizationTest` | | | `.md` parsing, version dispatch, key derivation, stream lifecycle | `DeviceMetadataCharacterizationTest` | 4 handle-lifecycle cases are Windows-only | diff --git a/core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java b/core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java new file mode 100644 index 000000000..89acc145a --- /dev/null +++ b/core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java @@ -0,0 +1,118 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import studio.core.v1.model.StageNode; +import studio.core.v1.model.StoryPack; +import studio.core.v1.reader.archive.ArchiveStoryPackReader; +import studio.core.v1.writer.archive.ArchiveStoryPackWriter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * What survives a write and a read back in the archive format. + * + *

The archive format is a zip with a description of the story beside its assets, so it can record + * things the sector-based raw format cannot. This is where the two differ, and the differences are + * worth pinning: a conversion between them is what the library offers a user, and it is lossy in one + * direction and not the other. + */ +@DisplayName("An archive pack, written and read back") +class ArchivePackRoundTripTest { + + private static StoryPack roundTrip(StoryPack pack) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new ArchiveStoryPackWriter().write(pack, out); + return new ArchiveStoryPackReader().read(new ByteArrayInputStream(out.toByteArray())); + } + + @Test + @DisplayName("KNOWN GAP: not the pack's own uuid — no pack-level uuid is stored at all") + void packUuidIsReplacedByTheFirstNodes() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack()); + + // The same substitution as the raw format, and for a more basic reason: the archive writer + // never writes a pack-level uuid. It writes one uuid per stage node, and the reader builds + // the pack from `nodes.get(0).getUuid()`. + // + // So of the three formats only FS carries a pack uuid of its own. A pack read from an FS + // folder, written to archive or raw and read back, comes out identified by its first node. + // LibraryService names converted artifacts `.converted_` from exactly this + // value, so the name follows the node rather than the pack. + assertEquals("11111111-1111-1111-1111-111111111111", read.getUuid(), + "the first stage node's uuid"); + assertNotEquals(PackFixtures.UUID, read.getUuid(), "not the pack's own uuid"); + } + + @Test + @DisplayName("keeps the stage nodes and their uuids") + void keepsStageNodes() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + StoryPack read = roundTrip(original); + + assertEquals(original.getStageNodes().size(), read.getStageNodes().size(), "node count"); + for (int i = 0; i < original.getStageNodes().size(); i++) { + assertEquals(original.getStageNodes().get(i).getUuid(), + read.getStageNodes().get(i).getUuid(), "uuid of node " + i); + } + } + + @Test + @DisplayName("keeps asset bytes exactly, with no sector padding") + void keepsAssetBytesExactly() throws IOException { + // Deliberately not a multiple of the sector size. The raw format would pad this to 1024 + // bytes; a zip entry has a length of its own, so the archive format has no reason to. + int oddSize = Constants.SECTOR_SIZE + 1; + StoryPack original = PackFixtures.packWithAssetSizes(oddSize, oddSize); + + StoryPack read = roundTrip(original); + + for (int i = 0; i < original.getStageNodes().size(); i++) { + StageNode before = original.getStageNodes().get(i); + StageNode after = read.getStageNodes().get(i); + assertArrayEquals(before.getImage().getRawData(), after.getImage().getRawData(), + "image bytes of node " + i); + assertEquals(oddSize, after.getImage().getRawData().length, + "image length of node " + i + " is not rounded up"); + } + } + + @Test + @DisplayName("keeps the transitions and the shared action node") + void keepsTransitions() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack()); + + StageNode first = read.getStageNodes().get(0); + assertNotNull(first.getOkTransition(), "ok transition"); + assertNotNull(first.getOkTransition().getActionNode(), "action node"); + assertEquals(1, first.getOkTransition().getActionNode().getOptions().size(), + "action node options"); + } + + @Test + @DisplayName("keeps an option that points at the first stage node, unlike the raw format") + void keepsOptionOnTheFirstNode() throws IOException { + StoryPack read = roundTrip(PackFixtures.packWithOptionOnFirstNode()); + + // The counterpart of BinaryPackRoundTripTest#optionOnTheFirstNodeTruncatesTheList. Options + // are named here rather than addressed by sector, so nothing collides with a terminator and + // the list comes back whole. It is the same story in both formats; only one of them can + // express it. + assertEquals(2, read.getStageNodes().get(0).getOkTransition() + .getActionNode().getOptions().size(), + "both options survive the archive format"); + } +} diff --git a/core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java b/core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java new file mode 100644 index 000000000..fd39639a1 --- /dev/null +++ b/core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java @@ -0,0 +1,235 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import studio.core.v1.model.StageNode; +import studio.core.v1.model.StoryPack; +import studio.core.v1.reader.binary.BinaryStoryPackReader; +import studio.core.v1.writer.binary.BinaryStoryPackWriter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What survives a write and a read back in the raw binary format. + * + *

This module produces the bytes that end up on the card, and until now nothing exercised it: the + * only test in {@code core} covered the endianness helpers. The round trip is the cheapest property + * worth having — a pack written and read back must describe the same story — and it is the one that + * would notice a reader and a writer drifting apart. + * + *

The cases are marked as they are found. Where the format keeps something faithfully that is a + * specification and stays that way. Where it does not, the case is + * characterization: it records what the code does today, including where that is + * arguably wrong, so that changing it is a decision someone makes rather than an accident. Four are + * below, and they are not all of the same weight: two are consequences of a sector-based format that + * the model does not express, one substitutes a pack's identity, and one silently loses data. A + * {@code KNOWN GAP} case passing says the behaviour is known, not that it is acceptable. + */ +@DisplayName("A raw binary pack, written and read back") +class BinaryPackRoundTripTest { + + private static StoryPack roundTrip(StoryPack pack, boolean enriched) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new BinaryStoryPackWriter().write(pack, out, enriched); + return new BinaryStoryPackReader().read(new ByteArrayInputStream(out.toByteArray())); + } + + @Nested + @DisplayName("keeps") + class Keeps { + + @Test + @DisplayName("its version and its node count") + void versionAndNodeCount() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + assertEquals((short) 1, read.getVersion(), "pack version"); + assertEquals(2, read.getStageNodes().size(), "stage node count"); + } + + @Test + @DisplayName("each stage node's asset bytes, when they fill whole sectors") + void assetBytes() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + StoryPack read = roundTrip(original, false); + + for (int i = 0; i < original.getStageNodes().size(); i++) { + StageNode before = original.getStageNodes().get(i); + StageNode after = read.getStageNodes().get(i); + assertArrayEquals(before.getImage().getRawData(), after.getImage().getRawData(), + "image bytes of node " + i); + assertArrayEquals(before.getAudio().getRawData(), after.getAudio().getRawData(), + "audio bytes of node " + i); + } + } + + @Test + @DisplayName("the mime types the format is defined over") + void mimeTypes() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + for (StageNode node : read.getStageNodes()) { + assertEquals("image/bmp", node.getImage().getMimeType()); + assertEquals("audio/x-wav", node.getAudio().getMimeType()); + } + } + + @Test + @DisplayName("the control settings of each node") + void controlSettings() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + for (StageNode node : read.getStageNodes()) { + assertNotNull(node.getControlSettings(), "control settings"); + assertTrue(node.getControlSettings().isWheelEnabled(), "wheel"); + assertTrue(node.getControlSettings().isOkEnabled(), "ok"); + } + } + + @Test + @DisplayName("the transitions, still pointing at a shared action node") + void transitions() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + StageNode first = read.getStageNodes().get(0); + assertNotNull(first.getOkTransition(), "ok transition"); + assertNotNull(first.getHomeTransition(), "home transition"); + // The fixture points both stage nodes at one action node. A format that stored the + // action node twice would still read back, so this is worth asserting rather than + // assuming: the identity of the target is part of the story's shape. + assertSame(first.getOkTransition().getActionNode(), + read.getStageNodes().get(1).getOkTransition().getActionNode(), + "both stage nodes should reach the same action node"); + assertEquals(1, first.getOkTransition().getActionNode().getOptions().size(), + "action node options"); + } + } + + @Nested + @DisplayName("does not keep — KNOWN GAP") + class DoesNotKeep { + + @Test + @DisplayName("KNOWN GAP: the pack's own uuid, replaced by its first stage node's") + void packUuidIsReplacedByTheFirstNodes() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + + StoryPack read = roundTrip(original, false); + + // The binary header does carry a pack uuid -- readMetadata() returns it -- but read() + // ignores it and takes the uuid of whatever stage node sits at sector 0: + // + // new StoryPack(stageNodes.get(new SectorAddr(0)).getUuid(), ...) + // + // So a pack whose own uuid differs from its first node's does not survive a raw round + // trip with its identity intact. This is not academic: LibraryService names a converted + // artifact `.converted_`, so the file a conversion produces is named after + // the first node rather than the pack. + assertEquals("11111111-1111-1111-1111-111111111111", read.getUuid(), + "the first stage node's uuid, not the pack's"); + assertNotEquals(PackFixtures.UUID, read.getUuid(), + "the pack's own uuid is not what comes back"); + } + + @Test + @DisplayName("KNOWN GAP: an option pointing at the first stage node, which ends the list") + void optionOnTheFirstNodeTruncatesTheList() throws IOException { + StoryPack original = PackFixtures.packWithOptionOnFirstNode(); + assertEquals(2, original.getStageNodes().get(0).getOkTransition() + .getActionNode().getOptions().size(), "the fixture offers two options"); + + StoryPack read = roundTrip(original, false); + + // Options are stored as sector addresses and the list ends at the first zero. Stage + // nodes are laid out from sector 0, so the first one has address 0 and is + // indistinguishable from the terminator. An action node that offers it loses that + // option *and every option after it* -- here, both of them. + // + // "Back to the beginning" is an ordinary thing for a menu to offer, so this is reachable + // from a pack someone would actually build. Fixing it means changing either the + // terminator or the layout, which is a format decision rather than a reader fix, and is + // why this is recorded rather than corrected here. + assertEquals(0, read.getStageNodes().get(0).getOkTransition() + .getActionNode().getOptions().size(), + "the whole option list is lost, not just the offending entry"); + } + + @Test + @DisplayName("KNOWN GAP: asset names, which the reader invents from the sector offset") + void assetNamesAreLost() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + String nameBefore = original.getStageNodes().get(0).getImage().getName(); + + StoryPack read = roundTrip(original, false); + String nameAfter = read.getStageNodes().get(0).getImage().getName(); + + // The binary format has nowhere to put an asset name, so the reader manufactures one + // from the address it found the asset at. Nothing is broken by this — names are not + // used to address anything — but a pack that goes raw and comes back has lost them, + // and the enriched metadata that carries human-facing names is a separate section. + assertEquals(PackFixtures.UUID.length() > 0 ? "11111111-1111-1111-1111-111111111111.bmp" : "", + nameBefore, "the fixture names its assets"); + assertTrue(nameAfter.startsWith("0x"), + "the reader names assets after their offset, was: " + nameAfter); + } + + @Test + @DisplayName("KNOWN GAP: asset length, when it is not a whole number of sectors") + void assetLengthIsPaddedToSectors() throws IOException { + int imageSize = Constants.SECTOR_SIZE + 1; + StoryPack original = PackFixtures.packWithAssetSizes(imageSize, Constants.SECTOR_SIZE); + + StoryPack read = roundTrip(original, false); + byte[] after = read.getStageNodes().get(0).getImage().getRawData(); + + // Assets occupy whole sectors and the reader reads the whole allocation back, so an + // asset of 513 bytes returns as 1024. The extra bytes are zero. A consumer that trusts + // the length gets a longer asset than was written; nothing in the model records the + // real one. + assertEquals(Constants.SECTOR_SIZE * 2, after.length, + "an asset of " + imageSize + " bytes comes back padded to two sectors"); + assertArrayEquals(original.getStageNodes().get(0).getImage().getRawData(), + Arrays.copyOf(after, imageSize), + "the bytes that were written are intact at the front"); + for (int i = imageSize; i < after.length; i++) { + assertEquals(0, after[i], "padding at index " + i); + } + } + } + + @Nested + @DisplayName("refuses") + class Refuses { + + @Test + @DisplayName("a pack whose assets are compressed, rather than writing something unreadable") + void refusesCompressedAssets() { + StoryPack pack = PackFixtures.twoStagePack(); + pack.getStageNodes().get(0).getImage().setMimeType("image/png"); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new BinaryStoryPackWriter().write(pack, new ByteArrayOutputStream(), false)); + + assertTrue(thrown.getMessage().contains("Uncompress"), + "the message should say what to do, was: " + thrown.getMessage()); + } + } +} diff --git a/core/src/test/java/studio/core/v1/PackFixtures.java b/core/src/test/java/studio/core/v1/PackFixtures.java new file mode 100644 index 000000000..5da53e450 --- /dev/null +++ b/core/src/test/java/studio/core/v1/PackFixtures.java @@ -0,0 +1,113 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1; + +import studio.core.v1.model.ActionNode; +import studio.core.v1.model.AudioAsset; +import studio.core.v1.model.ControlSettings; +import studio.core.v1.model.ImageAsset; +import studio.core.v1.model.StageNode; +import studio.core.v1.model.StoryPack; +import studio.core.v1.model.Transition; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Story packs built in code, for tests that need a pack rather than a file. + * + *

Nothing here comes from a device or from anyone's library: the assets are generated byte + * patterns carrying the mime types the writers require, not real images or audio. That is enough + * for the formats, which copy asset bytes verbatim and never decode them — {@code BinaryStoryPackWriter} + * checks {@code image/bmp} and {@code audio/x-wav} and writes whatever bytes it is given. + * + *

Asset sizes default to whole multiples of {@link Constants#SECTOR_SIZE}. The binary format + * stores assets in sectors and reads back {@code 512 × sectors} bytes, so an asset whose length is + * not a multiple of 512 comes back longer than it went in. Tests that want to pin that behaviour ask + * for it explicitly with {@link #packWithAssetSizes}; the rest stay on sector boundaries so a failed + * comparison means something went wrong rather than that padding exists. + */ +public final class PackFixtures { + + public static final String UUID = "12345678-1234-1234-1234-123456789abc"; + + private PackFixtures() { + } + + /** A pack with two stage nodes, distinct assets on each, and an action node joining them. */ + public static StoryPack twoStagePack() { + return packWithAssetSizes(Constants.SECTOR_SIZE, Constants.SECTOR_SIZE * 2); + } + + /** + * The same shape, with asset lengths chosen by the caller. + * + * @param imageSize bytes of image data on each stage node + * @param audioSize bytes of audio data on each stage node + */ + public static StoryPack packWithAssetSizes(int imageSize, int audioSize) { + StageNode first = stageNode("11111111-1111-1111-1111-111111111111", + pattern((byte) 0x41, imageSize), pattern((byte) 0x61, audioSize)); + StageNode second = stageNode("22222222-2222-2222-2222-222222222222", + pattern((byte) 0x42, imageSize), pattern((byte) 0x62, audioSize)); + + // One action node, reached from both stage nodes, offering only the second of them. + // + // Offering the first as well would be the more obvious fixture and it is deliberately not + // used here: the binary format addresses options by sector, the first stage node sits at + // sector 0, and the reader treats 0 as the end of the option list. An option pointing at it + // therefore truncates the list. That is pinned in its own case by BinaryPackRoundTripTest, + // and kept out of the general fixture so every other test is not silently exercising it. + ActionNode action = new ActionNode(new ArrayList<>(Arrays.asList(second)), null); + first.setOkTransition(new Transition(action, (short) 0)); + first.setHomeTransition(new Transition(action, (short) 0)); + second.setOkTransition(new Transition(action, (short) 0)); + second.setHomeTransition(new Transition(action, (short) 0)); + + List nodes = new ArrayList<>(Arrays.asList(first, second)); + return new StoryPack(UUID, false, (short) 1, nodes, null, false); + } + + /** + * The same pack, with the action node offering the first stage node as well. + * + *

Only for the case that pins what the binary format does with it. See + * {@link #twoStagePack()}. + */ + public static StoryPack packWithOptionOnFirstNode() { + StoryPack pack = twoStagePack(); + StageNode first = pack.getStageNodes().get(0); + StageNode second = pack.getStageNodes().get(1); + first.getOkTransition().getActionNode() + .setOptions(new ArrayList<>(Arrays.asList(first, second))); + return pack; + } + + private static StageNode stageNode(String uuid, byte[] image, byte[] audio) { + return new StageNode( + uuid, + new ImageAsset("image/bmp", image, uuid + ".bmp"), + new AudioAsset("audio/x-wav", audio, uuid + ".wav"), + null, + null, + new ControlSettings(true, true, true, false, false), + null); + } + + /** + * Bytes that differ from one another and from padding, so a comparison that passes is not + * passing because everything is zero. + */ + public static byte[] pattern(byte seed, int length) { + byte[] data = new byte[length]; + for (int i = 0; i < length; i++) { + data[i] = (byte) (seed + (i % 97)); + } + return data; + } +} diff --git a/core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java b/core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java new file mode 100644 index 000000000..f74117079 --- /dev/null +++ b/core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.ByteOrder; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * The block cipher the device transfer path is built on. + * + *

{@code CipherUtils} in the driver calls {@link XXTEACipher#btea} with a positive block count to + * encipher and the negative of it to decipher, and every ciphered transfer to a device goes through + * it. Nothing exercised it, which meant a change to the rounds, the delta constant or the endianness + * of the key would have been caught only by a device refusing a pack — if at all, and long after. + * + *

These are specifications. The round trip is the property the transfer path + * depends on; the fixed vector exists so that a change in the algorithm itself is visible as a + * failing test rather than as packs that no longer play. + */ +@DisplayName("The XXTEA cipher") +class XXTEACipherTest { + + private static int[] commonKey() { + return BytesUtils.toIntArray(XXTEACipher.COMMON_KEY, ByteOrder.BIG_ENDIAN); + } + + private static int[] block(int size) { + int[] data = new int[size]; + for (int i = 0; i < size; i++) { + data[i] = 0x01020304 + i * 0x11111111; + } + return data; + } + + @Test + @DisplayName("deciphers what it ciphered") + void roundTrip() { + int[] plain = block(8); + int[] working = Arrays.copyOf(plain, plain.length); + + int[] ciphered = XXTEACipher.btea(working, working.length, commonKey()); + int[] deciphered = XXTEACipher.btea(Arrays.copyOf(ciphered, ciphered.length), + -ciphered.length, commonKey()); + + assertArrayEquals(plain, deciphered, "decipher(cipher(x)) should be x"); + } + + @Test + @DisplayName("actually changes the data, so the round trip is not trivially true") + void cipheringChangesTheBlock() { + int[] plain = block(8); + + int[] ciphered = XXTEACipher.btea(Arrays.copyOf(plain, plain.length), plain.length, commonKey()); + + assertFalse(Arrays.equals(plain, ciphered), "ciphered data should differ from the plaintext"); + } + + @Test + @DisplayName("round-trips the smallest block the algorithm accepts") + void roundTripTwoWords() { + // XXTEA is defined for at least two words; the driver's callers can pass small blocks when + // a file is short, so the boundary is worth holding. + int[] plain = block(2); + + int[] ciphered = XXTEACipher.btea(Arrays.copyOf(plain, plain.length), plain.length, commonKey()); + int[] deciphered = XXTEACipher.btea(Arrays.copyOf(ciphered, ciphered.length), + -ciphered.length, commonKey()); + + assertArrayEquals(plain, deciphered); + } + + @Test + @DisplayName("leaves a block of one word untouched — KNOWN GAP") + void singleWordIsNotCiphered() { + int[] plain = block(1); + + int[] result = XXTEACipher.btea(Arrays.copyOf(plain, plain.length), plain.length, commonKey()); + + // XXTEA needs two words to mix anything, and this implementation returns a one-word block + // unchanged rather than refusing it. A caller handing it a short tail gets plaintext back + // and no indication that nothing happened. Recorded, not relied upon. + assertArrayEquals(plain, result, "a single word passes through in clear"); + } + + @Test + @DisplayName("produces the same ciphertext as it always has, for a fixed input and the common key") + void fixedVector() { + int[] ciphered = XXTEACipher.btea(new int[]{0x01020304, 0x05060708}, 2, commonKey()); + + // Pinned from the current implementation rather than from an external specification: the + // point is not that this value is canonical, it is that it stops changing silently. If a + // refactor of the rounds or the key endianness alters it, this fails and the change is + // deliberate rather than discovered on a device. + assertEquals(2, ciphered.length); + assertEquals(0x15189889, ciphered[0], "first word"); + assertEquals(0x9ba9310e, ciphered[1], "second word"); + } +}