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)); + } +}