From 1b09cfe15b7aefdb9e5b961fa782179518556ae7 Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Thu, 30 Jul 2026 22:34:54 -0600 Subject: [PATCH] =?UTF-8?q?ADFA-4954=20feat(kolibri):=20domain=20layer=20f?= =?UTF-8?q?or=20content=20seeding=20=E2=80=94=20ids,=20selection,=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the Android side. Contracts only, per the "shared contracts land first" rule: no android.*, no HTTP, so it is unit-testable on a plain JVM and the data/service layers can be built against it. ChannelId — what counts as a usable channel or node identifier. Normalises the dashed and uppercase spellings, and rejects a Studio channel token outright: a token passed where an id is expected ends in a 404 from the downloader with no useful message, so looksLikeToken() exists purely to make the error say "that is a token, resolve it first". Same fail-closed boundary ModuleName draws for module names — an id reaches a request the box acts on. ChannelSelection — the whole channel, or named subtrees. Encodes the trap in Kolibri's API where omitting node_ids means everything and sending an empty list means nothing: the importer accepts node_ids: [] and finishes successfully having transferred zero bytes. wholeChannel() and ofSubtrees() are separate factories and ofSubtrees() refuses to produce an empty selection, including when every supplied id fails validation. Immutable, ids normalised and deduplicated, order kept. SeedPlan — what is queued and whether it fits. Answers the space question before committing to a download, which Kolibri cannot: it subtracts a 250 MB cushion when computing free space, so it can report zero with room left and only finds out mid-transfer. fitsIn() returns null for "cannot tell" rather than false, because the catalog only carries whole-channel sizes: reading an over-estimate as "does not fit" would block seeding a small subtree of a large channel, which is the case a phone needs most. 52 JVM unit tests. Verified: all six files parse; the domain imports only java.util; every method the tests call exists; JUnit 4 only, matching build.gradle. NOT COMPILED. The sandbox has no javac — no JDK, Maven Central and the Adoptium release assets are both outside the allowlist, and jdk4py ships a runtime without jdk.compiler. Type errors would not have been caught. Needs a local `gradlew :app:testDebugUnitTest` before merge. --- .../controller/kolibri/domain/ChannelId.java | 139 ++++++++++++ .../kolibri/domain/ChannelSelection.java | 158 +++++++++++++ .../controller/kolibri/domain/SeedPlan.java | 209 ++++++++++++++++++ .../kolibri/domain/ChannelIdTest.java | 106 +++++++++ .../kolibri/domain/ChannelSelectionTest.java | 164 ++++++++++++++ .../kolibri/domain/SeedPlanTest.java | 184 +++++++++++++++ 6 files changed, 960 insertions(+) create mode 100644 controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java create mode 100644 controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java create mode 100644 controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java create mode 100644 controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/kolibri/domain/SeedPlanTest.java diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java new file mode 100644 index 00000000..2c815c21 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelId.java @@ -0,0 +1,139 @@ +/* + * ============================================================================ + * Name : ChannelId.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain rule: what counts as a usable Kolibri channel or node + * identifier, and how to normalise one. Pure JVM (ADFA-4954). + * ============================================================================ + */ +package org.iiab.controller.kolibri.domain; + +/** + * Guard for Kolibri channel and content-node identifiers. + * + *

Kolibri identifies channels and nodes by a UUID rendered as 32 lowercase + * hex characters without dashes. Two different things get confused with it and + * both fail in ways that are hard to diagnose: + * + *

+ * + *

Beyond correctness this is a safety boundary, the same one + * {@link org.iiab.controller.deploy.domain.ModuleName} draws for module names: + * an identifier reaching this class ends up in a request the box acts on, so + * anything not explicitly allowed is rejected. Fail-closed. + * + *

No {@code android.*} and no HTTP, so it is unit-testable on a plain JVM. + */ +public final class ChannelId { + + /** A Kolibri UUID is exactly 32 hex characters once the dashes are gone. */ + private static final int LENGTH = 32; + + private ChannelId() { + // Static utility; not instantiable. + } + + /** + * Canonical form of {@code raw}: trimmed, dashes removed, lowercased — or + * {@code null} if the result is not a valid identifier. + * + *

Returning {@code null} rather than throwing keeps the common + * "filter out what is not usable" path free of exception handling; callers + * that need to fail loudly should check and raise their own error. + */ + public static String normalise(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(trimmed.length()); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (c == '-') { + continue; + } + // Uppercase hex is a legitimate way to write a UUID; fold it. + if (c >= 'A' && c <= 'F') { + c = (char) (c - 'A' + 'a'); + } + sb.append(c); + } + + String candidate = sb.toString(); + return isCanonical(candidate) ? candidate : null; + } + + /** + * True if {@code raw} can be normalised into a usable identifier. Accepts the + * dashed and uppercase spellings; rejects tokens, empty input and anything + * that is not hex. + */ + public static boolean isValid(String raw) { + return normalise(raw) != null; + } + + /** + * True if {@code value} is already in canonical form: exactly 32 + * lowercase hex characters. Used to assert on values that should have been + * normalised earlier rather than normalising them again silently. + */ + public static boolean isCanonical(String value) { + if (value == null || value.length() != LENGTH) { + return false; + } + for (int i = 0; i < LENGTH; i++) { + char c = value.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + if (!hex) { + return false; + } + } + return true; + } + + /** + * True if {@code raw} looks like a Studio channel token rather than + * an id: ten characters of {@code [a-z0-9]}, optionally split by a dash into + * two groups of five. + * + *

Only used to tell the user why their input was rejected — "that + * is a token, resolve it first" beats "invalid identifier". It deliberately + * does not accept tokens as ids. + */ + public static boolean looksLikeToken(String raw) { + if (raw == null) { + return false; + } + String s = raw.trim().toLowerCase(); + if (s.length() == 11 && s.charAt(5) == '-') { + s = s.substring(0, 5) + s.substring(6); + } + if (s.length() != 10) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + } + // A 10-char all-hex string is ambiguous in principle, but it can never be + // a valid id (those are 32), so treating it as a token is the useful read. + return true; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java new file mode 100644 index 00000000..be26f322 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/ChannelSelection.java @@ -0,0 +1,158 @@ +/* + * ============================================================================ + * Name : ChannelSelection.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain rule: what the user asked to seed from one Kolibri + * channel — the whole thing, or named subtrees. Pure JVM + * (ADFA-4954). + * ============================================================================ + */ +package org.iiab.controller.kolibri.domain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +/** + * One channel the device should seed, optionally narrowed to a set of subtrees. + * + *

The distinction this type exists to protect is a real trap in Kolibri's API: + * + *

+ * + *

Those two are one keystroke apart and fail silently, so the difference is + * encoded in the type rather than left to each call site: {@link #wholeChannel} + * builds the first, {@link #ofSubtrees} the second, and {@code ofSubtrees} + * refuses to produce an empty selection at all. + * + *

Instances are immutable and always canonical: ids are normalised on the way + * in, invalid ones rejected, duplicates dropped, insertion order kept so the + * request is stable and diffable. + * + *

No {@code android.*} and no HTTP, so it is unit-testable on a plain JVM. + */ +public final class ChannelSelection { + + private final String channelId; + private final List nodeIds; + + private ChannelSelection(String channelId, List nodeIds) { + this.channelId = channelId; + this.nodeIds = nodeIds; + } + + /** + * The entire channel. + * + * @throws IllegalArgumentException if the channel id is not usable + */ + public static ChannelSelection wholeChannel(String rawChannelId) { + return new ChannelSelection(requireChannelId(rawChannelId), + Collections.emptyList()); + } + + /** + * The named subtrees of a channel. Each node id expands to its descendants on + * Kolibri's side, so a topic id pulls in everything under it. + * + * @throws IllegalArgumentException if the channel id is not usable, if + * {@code rawNodeIds} is null or empty, or if none of the node ids + * survive validation — an empty result would silently download + * nothing, so it is rejected here instead + */ + public static ChannelSelection ofSubtrees(String rawChannelId, List rawNodeIds) { + String id = requireChannelId(rawChannelId); + + if (rawNodeIds == null || rawNodeIds.isEmpty()) { + throw new IllegalArgumentException( + "empty subtree selection for channel " + id + + "; use wholeChannel() to seed everything"); + } + + // LinkedHashSet: drop duplicates, keep the order the user picked. + LinkedHashSet canonical = new LinkedHashSet<>(); + for (String raw : rawNodeIds) { + String node = ChannelId.normalise(raw); + if (node != null) { + canonical.add(node); + } + } + if (canonical.isEmpty()) { + throw new IllegalArgumentException( + "no valid node id among " + rawNodeIds.size() + + " entries for channel " + id); + } + + return new ChannelSelection(id, + Collections.unmodifiableList(new ArrayList<>(canonical))); + } + + private static String requireChannelId(String raw) { + String id = ChannelId.normalise(raw); + if (id != null) { + return id; + } + if (ChannelId.looksLikeToken(raw)) { + throw new IllegalArgumentException( + "'" + raw + "' is a channel token, not an id; resolve it first"); + } + throw new IllegalArgumentException("invalid channel id: " + raw); + } + + /** Canonical 32-hex channel id. */ + public String channelId() { + return channelId; + } + + /** Canonical node ids, empty when the whole channel was requested. Immutable. */ + public List nodeIds() { + return nodeIds; + } + + /** True when nothing was narrowed down and the whole channel should come. */ + public boolean isWholeChannel() { + return nodeIds.isEmpty(); + } + + /** + * Whether to ask Kolibri for every topic thumbnail in the channel. + * + *

Only worth it for a partial selection: without it the topics the user did + * not pick have no artwork and the browsing screen looks broken. A whole + * channel already brings them, so asking again is pure extra download. + */ + public boolean wantsAllThumbnails() { + return !isWholeChannel(); + } + + @Override + public String toString() { + return isWholeChannel() + ? "ChannelSelection{" + channelId + ", whole channel}" + : "ChannelSelection{" + channelId + ", " + nodeIds.size() + " subtree(s)}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ChannelSelection)) { + return false; + } + ChannelSelection other = (ChannelSelection) o; + return channelId.equals(other.channelId) && nodeIds.equals(other.nodeIds); + } + + @Override + public int hashCode() { + return 31 * channelId.hashCode() + nodeIds.hashCode(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java new file mode 100644 index 00000000..3853ff97 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java @@ -0,0 +1,209 @@ +/* + * ============================================================================ + * Name : SeedPlan.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain rule: the set of Kolibri channels queued for seeding and + * whether it fits on the device. Pure JVM (ADFA-4954). + * ============================================================================ + */ +package org.iiab.controller.kolibri.domain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * What the device has been asked to seed, as one unit. + * + *

Holds the ordered selections plus each one's published size so the app can + * answer "does this fit?" before committing to a download. That question + * has to be answered here rather than left to Kolibri: Kolibri subtracts a + * configurable cushion ({@code MINIMUM_DISK_SPACE}, 250 MB by default) when it + * computes free space, so it can report zero while the filesystem still has room, + * and it only finds out mid-transfer. + * + *

Sizes are the published size of the whole channel, which is what the + * offline catalog asset carries. For a partial selection that is an + * over-estimate — see {@link #isEstimateExact()} — so a plan that does not fit is + * a warning, not a verdict. The exact figure for a subtree needs the box: + * {@code POST /k2go-api/kolibri/estimate}. + * + *

Immutable. No {@code android.*}, so it is unit-testable on a plain JVM. + */ +public final class SeedPlan { + + /** + * Head-room kept free on top of the estimate, as a percentage. Content is not + * the only thing writing to the device while an import runs. + */ + public static final int DEFAULT_MARGIN_PERCENT = 110; + + private final List selections; + private final Map bytesByChannel; + + private SeedPlan(List selections, Map bytesByChannel) { + this.selections = selections; + this.bytesByChannel = bytesByChannel; + } + + /** An empty plan: nothing queued. */ + public static SeedPlan empty() { + return new SeedPlan(Collections.emptyList(), + Collections.emptyMap()); + } + + /** + * Builds a plan. + * + *

Later entries for a channel already present replace the earlier one + * rather than queueing it twice: importing the same channel concurrently only + * creates contention on the same SQLite file. Order of first appearance is + * kept. + * + * @param selections what to seed; null or empty yields {@link #empty()} + * @param bytesByChannel published size per channel id; a channel missing from + * the map, or mapped to a non-positive value, counts as + * unknown and makes the total inexact + */ + public static SeedPlan of(List selections, + Map bytesByChannel) { + if (selections == null || selections.isEmpty()) { + return empty(); + } + + LinkedHashMap byChannel = new LinkedHashMap<>(); + for (ChannelSelection s : selections) { + if (s != null) { + byChannel.put(s.channelId(), s); + } + } + if (byChannel.isEmpty()) { + return empty(); + } + + LinkedHashMap sizes = new LinkedHashMap<>(); + if (bytesByChannel != null) { + for (String channelId : byChannel.keySet()) { + Long bytes = bytesByChannel.get(channelId); + if (bytes != null && bytes > 0L) { + sizes.put(channelId, bytes); + } + } + } + + return new SeedPlan( + Collections.unmodifiableList(new ArrayList<>(byChannel.values())), + Collections.unmodifiableMap(sizes)); + } + + /** The selections, in order, one per channel. Immutable. */ + public List selections() { + return selections; + } + + public boolean isEmpty() { + return selections.isEmpty(); + } + + public int size() { + return selections.size(); + } + + /** Published size of a channel, or 0 when unknown. */ + public long bytesFor(String channelId) { + Long bytes = bytesByChannel.get(channelId); + return bytes == null ? 0L : bytes; + } + + /** Sum of the known published sizes. Channels with no size contribute 0. */ + public long estimatedBytes() { + long total = 0L; + for (ChannelSelection s : selections) { + total += bytesFor(s.channelId()); + } + return total; + } + + /** How many channels in the plan have no published size. */ + public int channelsWithUnknownSize() { + int unknown = 0; + for (ChannelSelection s : selections) { + if (bytesFor(s.channelId()) <= 0L) { + unknown++; + } + } + return unknown; + } + + /** + * True when {@link #estimatedBytes()} can be trusted as a real figure: every + * channel has a published size and every selection is a whole channel. + * + *

A partial selection makes the total an over-estimate, because the catalog + * only carries whole-channel sizes. Callers should present an inexact total as + * an upper bound, and should not refuse to start on the strength of it alone. + */ + public boolean isEstimateExact() { + if (selections.isEmpty()) { + return true; + } + if (channelsWithUnknownSize() > 0) { + return false; + } + for (ChannelSelection s : selections) { + if (!s.isWholeChannel()) { + return false; + } + } + return true; + } + + /** Bytes required including the default head-room. */ + public long requiredBytes() { + return requiredBytes(DEFAULT_MARGIN_PERCENT); + } + + /** + * Bytes required including {@code marginPercent} head-room, e.g. 110 for ten + * per cent extra. A margin below 100 is treated as 100: never plan to use more + * of the disk than the content actually needs. + */ + public long requiredBytes(int marginPercent) { + int margin = Math.max(100, marginPercent); + return estimatedBytes() / 100L * margin; + } + + /** + * Whether the plan fits in {@code freeBytes}. + * + *

{@code null} means "cannot tell" — either the free space is unknown or + * the estimate is not exact — and is deliberately different from + * {@link Boolean#FALSE}. A caller that treats "cannot tell" as "does not fit" + * would refuse to seed a small subtree of a large channel, which is exactly + * the case a phone needs most. + */ + public Boolean fitsIn(Long freeBytes) { + return fitsIn(freeBytes, DEFAULT_MARGIN_PERCENT); + } + + /** @see #fitsIn(Long) */ + public Boolean fitsIn(Long freeBytes, int marginPercent) { + if (isEmpty()) { + return Boolean.TRUE; + } + if (freeBytes == null || freeBytes < 0L || !isEstimateExact()) { + return null; + } + return freeBytes >= requiredBytes(marginPercent) ? Boolean.TRUE : Boolean.FALSE; + } + + @Override + public String toString() { + return "SeedPlan{" + selections.size() + " channel(s), " + + estimatedBytes() + " bytes" + + (isEstimateExact() ? "" : ", inexact") + "}"; + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java new file mode 100644 index 00000000..fd83807b --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelIdTest.java @@ -0,0 +1,106 @@ +package org.iiab.controller.kolibri.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Unit tests for {@link ChannelId} — the guard that stops a channel token, a + * dashed UUID or plain junk from reaching a request as if it were an id. + * Pure JVM, no Android dependencies. + */ +public class ChannelIdTest { + + /** African Storybook Library, as Kolibri stores it. */ + private static final String CANONICAL = "f9d3e0e46ea25789bbed672ff6a399ed"; + + @Test + public void acceptsACanonicalId() { + assertEquals(CANONICAL, ChannelId.normalise(CANONICAL)); + assertTrue(ChannelId.isValid(CANONICAL)); + assertTrue(ChannelId.isCanonical(CANONICAL)); + } + + @Test + public void stripsDashesFromAUuidCopiedFromAUrl() { + assertEquals(CANONICAL, ChannelId.normalise("f9d3e0e4-6ea2-5789-bbed-672ff6a399ed")); + } + + @Test + public void foldsUppercaseHex() { + assertEquals(CANONICAL, ChannelId.normalise(CANONICAL.toUpperCase())); + } + + @Test + public void trimsSurroundingWhitespace() { + assertEquals(CANONICAL, ChannelId.normalise(" " + CANONICAL + "\n")); + } + + @Test + public void rejectsAChannelTokenBecauseItIsNotAnId() { + // The trap this class exists for: a token passed as an id ends in a 404 + // from the downloader with no useful message. It has to be resolved first. + assertNull(ChannelId.normalise("bisan-sukod")); + assertNull(ChannelId.normalise("bisansukod")); + assertFalse(ChannelId.isValid("bisan-sukod")); + } + + @Test + public void rejectsWrongLength() { + assertNull(ChannelId.normalise(CANONICAL.substring(0, 31))); + assertNull(ChannelId.normalise(CANONICAL + "a")); + } + + @Test + public void rejectsNonHexEvenAtTheRightLength() { + assertNull(ChannelId.normalise("z9d3e0e46ea25789bbed672ff6a399ed")); + // 32 chars of dashes collapse to nothing, not to a valid id. + assertNull(ChannelId.normalise("--------------------------------")); + } + + @Test + public void rejectsNullAndEmpty() { + assertNull(ChannelId.normalise(null)); + assertNull(ChannelId.normalise("")); + assertNull(ChannelId.normalise(" ")); + assertFalse(ChannelId.isValid(null)); + } + + @Test + public void rejectsInjectionAttempts() { + // An id reaches a request the box acts on, so anything with structure in + // it is rejected outright rather than escaped downstream. + assertNull(ChannelId.normalise(CANONICAL + "; rm -rf /")); + assertNull(ChannelId.normalise("../../etc/passwd")); + assertNull(ChannelId.normalise(CANONICAL.substring(0, 30) + "$(")); + } + + @Test + public void isCanonicalDoesNotNormaliseSilently() { + // isCanonical asserts the value is already clean; it must not accept the + // dashed or uppercase spellings that normalise() would happily convert. + assertFalse(ChannelId.isCanonical("f9d3e0e4-6ea2-5789-bbed-672ff6a399ed")); + assertFalse(ChannelId.isCanonical(CANONICAL.toUpperCase())); + assertFalse(ChannelId.isCanonical(null)); + } + + @Test + public void recognisesTokensSoTheErrorCanSayWhy() { + assertTrue(ChannelId.looksLikeToken("bisan-sukod")); + assertTrue(ChannelId.looksLikeToken("bisansukod")); + assertTrue(ChannelId.looksLikeToken("BISAN-SUKOD")); + assertTrue(ChannelId.looksLikeToken(" bisansukod ")); + } + + @Test + public void doesNotMistakeAnIdForAToken() { + assertFalse(ChannelId.looksLikeToken(CANONICAL)); + assertFalse(ChannelId.looksLikeToken(null)); + assertFalse(ChannelId.looksLikeToken("too-short")); + assertFalse(ChannelId.looksLikeToken("has spaces")); + assertFalse(ChannelId.looksLikeToken("bisan_sukod")); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java new file mode 100644 index 00000000..79b6392a --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/ChannelSelectionTest.java @@ -0,0 +1,164 @@ +package org.iiab.controller.kolibri.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +/** + * Unit tests for {@link ChannelSelection} — the type that keeps "the whole + * channel" and "nothing at all" from collapsing into each other, which in + * Kolibri's API is the difference between omitting node_ids and sending an empty + * list. Pure JVM, no Android dependencies. + */ +public class ChannelSelectionTest { + + private static final String CHANNEL = "f9d3e0e46ea25789bbed672ff6a399ed"; + private static final String NODE_A = "6277aa0c44235435acdc8a9ed98f466b"; + private static final String NODE_B = "aaaa1111bbbb2222cccc3333dddd4444"; + + @Test + public void wholeChannelHasNoNodeIds() { + ChannelSelection s = ChannelSelection.wholeChannel(CHANNEL); + assertEquals(CHANNEL, s.channelId()); + assertTrue(s.isWholeChannel()); + assertTrue(s.nodeIds().isEmpty()); + } + + @Test + public void subtreeSelectionKeepsTheNodes() { + ChannelSelection s = ChannelSelection.ofSubtrees(CHANNEL, Arrays.asList(NODE_A, NODE_B)); + assertFalse(s.isWholeChannel()); + assertEquals(Arrays.asList(NODE_A, NODE_B), s.nodeIds()); + } + + @Test + public void anEmptySubtreeListIsRejectedRatherThanDownloadingNothing() { + // Kolibri would accept node_ids: [] and finish successfully having + // transferred zero bytes. That silent no-op is what this rejects. + try { + ChannelSelection.ofSubtrees(CHANNEL, Collections.emptyList()); + fail("expected an empty subtree list to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("wholeChannel")); + } + } + + @Test + public void aSelectionOfOnlyInvalidNodesIsRejected() { + // Same silent no-op by another route: ids that all fail validation would + // leave an empty list behind. + try { + ChannelSelection.ofSubtrees(CHANNEL, Arrays.asList("bisan-sukod", "nope", "")); + fail("expected an all-invalid subtree list to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("no valid node id")); + } + } + + @Test + public void invalidNodesAreDroppedWhenAtLeastOneSurvives() { + ChannelSelection s = ChannelSelection.ofSubtrees( + CHANNEL, Arrays.asList("nope", NODE_A, "")); + assertEquals(Collections.singletonList(NODE_A), s.nodeIds()); + } + + @Test + public void nodeIdsAreNormalisedAndDeduplicated() { + String dashed = "6277aa0c-4423-5435-acdc-8a9ed98f466b"; // same as NODE_A + ChannelSelection s = ChannelSelection.ofSubtrees( + CHANNEL, Arrays.asList(NODE_A, dashed, NODE_A.toUpperCase(), NODE_B)); + // Three spellings of one node collapse to one entry, order preserved. + assertEquals(Arrays.asList(NODE_A, NODE_B), s.nodeIds()); + } + + @Test + public void channelIdIsNormalised() { + ChannelSelection s = ChannelSelection.wholeChannel( + "F9D3E0E4-6EA2-5789-BBED-672FF6A399ED"); + assertEquals(CHANNEL, s.channelId()); + } + + @Test + public void aTokenAsChannelIdSaysSoInsteadOfJustFailing() { + try { + ChannelSelection.wholeChannel("bisan-sukod"); + fail("expected a token to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("token")); + assertTrue(expected.getMessage().contains("resolve")); + } + } + + @Test + public void anInvalidChannelIdIsRejected() { + try { + ChannelSelection.wholeChannel("not-an-id"); + fail("expected an invalid channel id to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("invalid channel id")); + } + } + + @Test + public void nullNodeListIsRejected() { + try { + ChannelSelection.ofSubtrees(CHANNEL, null); + fail("expected a null subtree list to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("empty subtree selection")); + } + } + + @Test + public void thumbnailsAreOnlyWorthAskingForOnAPartialSelection() { + // A whole channel already brings its topic artwork; asking again is pure + // extra download. A partial one would otherwise browse with gaps. + assertFalse(ChannelSelection.wholeChannel(CHANNEL).wantsAllThumbnails()); + assertTrue(ChannelSelection.ofSubtrees(CHANNEL, Collections.singletonList(NODE_A)) + .wantsAllThumbnails()); + } + + @Test + public void nodeIdsAreNotMutableFromOutside() { + ChannelSelection s = ChannelSelection.ofSubtrees( + CHANNEL, Collections.singletonList(NODE_A)); + try { + s.nodeIds().add(NODE_B); + fail("expected the node id list to be immutable"); + } catch (UnsupportedOperationException expected) { + // as designed + } + } + + @Test + public void mutatingTheInputListLaterDoesNotChangeTheSelection() { + List input = new ArrayList<>(); + input.add(NODE_A); + ChannelSelection s = ChannelSelection.ofSubtrees(CHANNEL, input); + input.add(NODE_B); + assertEquals(Collections.singletonList(NODE_A), s.nodeIds()); + } + + @Test + public void equalityIgnoresSpelling() { + assertEquals(ChannelSelection.wholeChannel(CHANNEL), + ChannelSelection.wholeChannel(CHANNEL.toUpperCase())); + assertEquals(ChannelSelection.wholeChannel(CHANNEL).hashCode(), + ChannelSelection.wholeChannel(CHANNEL.toUpperCase()).hashCode()); + } + + @Test + public void wholeChannelIsNotEqualToAPartialOne() { + assertNotEquals(ChannelSelection.wholeChannel(CHANNEL), + ChannelSelection.ofSubtrees(CHANNEL, Collections.singletonList(NODE_A))); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/kolibri/domain/SeedPlanTest.java b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/SeedPlanTest.java new file mode 100644 index 00000000..06048e56 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/kolibri/domain/SeedPlanTest.java @@ -0,0 +1,184 @@ +package org.iiab.controller.kolibri.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +/** + * Unit tests for {@link SeedPlan} — what is queued and whether it fits. + * Pure JVM, no Android dependencies. + */ +public class SeedPlanTest { + + private static final String STORYBOOK = "f9d3e0e46ea25789bbed672ff6a399ed"; + private static final String KHAN = "c9d7f950ab6b5a1199e3d6c10d7f0103"; + private static final String NODE = "6277aa0c44235435acdc8a9ed98f466b"; + + private static final long GB = 1024L * 1024L * 1024L; + + private static Map sizes(Object... pairs) { + Map m = new HashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + m.put((String) pairs[i], (Long) pairs[i + 1]); + } + return m; + } + + @Test + public void anEmptyPlanIsEmptyAndFitsAnywhere() { + SeedPlan p = SeedPlan.empty(); + assertTrue(p.isEmpty()); + assertEquals(0, p.size()); + assertEquals(0L, p.estimatedBytes()); + assertTrue(p.isEstimateExact()); + assertEquals(Boolean.TRUE, p.fitsIn(0L)); + } + + @Test + public void nullOrEmptySelectionsYieldAnEmptyPlan() { + assertTrue(SeedPlan.of(null, null).isEmpty()); + assertTrue(SeedPlan.of(Collections.emptyList(), null).isEmpty()); + } + + @Test + public void sumsTheKnownSizes() { + SeedPlan p = SeedPlan.of( + Arrays.asList(ChannelSelection.wholeChannel(STORYBOOK), + ChannelSelection.wholeChannel(KHAN)), + sizes(STORYBOOK, 8L * GB, KHAN, 2L * GB)); + assertEquals(2, p.size()); + assertEquals(10L * GB, p.estimatedBytes()); + assertTrue(p.isEstimateExact()); + } + + @Test + public void aChannelQueuedTwiceIsKeptOnce() { + // Importing one channel concurrently only contends on the same SQLite file. + SeedPlan p = SeedPlan.of( + Arrays.asList(ChannelSelection.wholeChannel(STORYBOOK), + ChannelSelection.ofSubtrees(STORYBOOK, Collections.singletonList(NODE))), + sizes(STORYBOOK, 8L * GB)); + assertEquals(1, p.size()); + // The later entry wins. + assertFalse(p.selections().get(0).isWholeChannel()); + } + + @Test + public void firstAppearanceOrderIsKept() { + SeedPlan p = SeedPlan.of( + Arrays.asList(ChannelSelection.wholeChannel(KHAN), + ChannelSelection.wholeChannel(STORYBOOK)), + null); + assertEquals(KHAN, p.selections().get(0).channelId()); + assertEquals(STORYBOOK, p.selections().get(1).channelId()); + } + + @Test + public void anUnknownSizeMakesTheEstimateInexact() { + SeedPlan p = SeedPlan.of( + Arrays.asList(ChannelSelection.wholeChannel(STORYBOOK), + ChannelSelection.wholeChannel(KHAN)), + sizes(STORYBOOK, 8L * GB)); + assertEquals(1, p.channelsWithUnknownSize()); + assertEquals(8L * GB, p.estimatedBytes()); + assertFalse(p.isEstimateExact()); + } + + @Test + public void nonPositiveSizesCountAsUnknown() { + SeedPlan p = SeedPlan.of( + Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)), + sizes(STORYBOOK, 0L)); + assertEquals(1, p.channelsWithUnknownSize()); + assertFalse(p.isEstimateExact()); + } + + @Test + public void aPartialSelectionMakesTheEstimateInexactEvenWithAKnownSize() { + // The catalog only carries whole-channel sizes, so a subtree total is an + // upper bound, not a figure. + SeedPlan p = SeedPlan.of( + Collections.singletonList( + ChannelSelection.ofSubtrees(STORYBOOK, Collections.singletonList(NODE))), + sizes(STORYBOOK, 8L * GB)); + assertEquals(0, p.channelsWithUnknownSize()); + assertFalse(p.isEstimateExact()); + } + + @Test + public void requiredBytesAddsHeadRoom() { + SeedPlan p = SeedPlan.of( + Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)), + sizes(STORYBOOK, 1000L)); + assertEquals(1100L, p.requiredBytes()); // default 110 % + assertEquals(1500L, p.requiredBytes(150)); + } + + @Test + public void aMarginBelowOneHundredIsClampedNotHonoured() { + // Planning to use less space than the content needs is never right. + SeedPlan p = SeedPlan.of( + Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)), + sizes(STORYBOOK, 1000L)); + assertEquals(1000L, p.requiredBytes(50)); + assertEquals(1000L, p.requiredBytes(0)); + } + + @Test + public void fitsWhenThereIsRoomForTheMarginToo() { + SeedPlan p = SeedPlan.of( + Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)), + sizes(STORYBOOK, 1000L)); + assertEquals(Boolean.TRUE, p.fitsIn(1100L)); + assertEquals(Boolean.FALSE, p.fitsIn(1099L)); + } + + @Test + public void unknownFreeSpaceIsUnknownNotFalse() { + // A caller that read null as "does not fit" would refuse to seed when it + // simply could not measure. + SeedPlan p = SeedPlan.of( + Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)), + sizes(STORYBOOK, 1000L)); + assertNull(p.fitsIn(null)); + assertNull(p.fitsIn(-1L)); + } + + @Test + public void anInexactEstimateCannotDecideFit() { + // The case a phone needs most: a small subtree of a very large channel. + // Answering FALSE here would block it on an over-estimate. + SeedPlan p = SeedPlan.of( + Collections.singletonList( + ChannelSelection.ofSubtrees(STORYBOOK, Collections.singletonList(NODE))), + sizes(STORYBOOK, 8L * GB)); + assertNull(p.fitsIn(1L * GB)); + } + + @Test + public void selectionsAreNotMutableFromOutside() { + SeedPlan p = SeedPlan.of( + Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)), null); + try { + p.selections().add(ChannelSelection.wholeChannel(KHAN)); + throw new AssertionError("expected the selection list to be immutable"); + } catch (UnsupportedOperationException expected) { + // as designed + } + } + + @Test + public void bytesForAnUnknownChannelIsZeroNotAnError() { + SeedPlan p = SeedPlan.empty(); + assertEquals(0L, p.bytesFor(STORYBOOK)); + assertEquals(0L, p.bytesFor(null)); + } +}