diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml index cf9bf5f..2df3a68 100644 --- a/.github/workflows/auto-merge-dependabot.yml +++ b/.github/workflows/auto-merge-dependabot.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Fetch Dependabot metadata id: dependabot-metadata - uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34 + uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34 # v2.2.0 - name: Enable auto-merge for minor updates if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }} env: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e4d7a4..ef9d7ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 1 steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Java - uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 + uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2 with: distribution: temurin java-version: 17 @@ -40,15 +40,15 @@ jobs: security-events: write steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Java - uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 + uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2 with: distribution: temurin java-version: 17 cache: maven - name: Initialize CodeQL - uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a + uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 with: languages: java queries: security-and-quality @@ -57,4 +57,4 @@ jobs: - name: Compile project run: ./mvnw compile - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a \ No newline at end of file + uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51a54e3..5871ab2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,9 +13,9 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Java - uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 + uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2 with: distribution: temurin java-version: 17 diff --git a/codewars-sdk-client/pom.xml b/codewars-sdk-client/pom.xml index 096dfbe..c40c057 100644 --- a/codewars-sdk-client/pom.xml +++ b/codewars-sdk-client/pom.xml @@ -50,6 +50,11 @@ junit-jupiter test + + org.mockito + mockito-core + test + diff --git a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java index 26e0d2c..c06fbf9 100644 --- a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java +++ b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java @@ -8,7 +8,7 @@ import dev.noid.codewars.client.model.CompletedChallenges; import dev.noid.codewars.client.model.User; import java.util.List; -import java.util.function.IntFunction; +import java.util.Map; public final class CodewarsClient { @@ -41,9 +41,11 @@ public List listAuthoredChallenges(String idOrUsername) throw public List listCompletedChallenges(String idOrUsername) throws ApiException { CompletedChallenges firstPage = usersApi.listCompletedChallenges(idOrUsername, 0); - List recentChallenges = firstPage.getData(); - IntFunction> pageLoader = page -> usersApi.listCompletedChallenges(idOrUsername, page).getData(); - return new LazyList<>(pageLoader, recentChallenges, recentChallenges.size(), firstPage.getTotalItems()); + return new PaginatedList<>( + page -> usersApi.listCompletedChallenges(idOrUsername, page).getData(), + firstPage.getTotalPages(), + firstPage.getTotalItems(), + Map.of(0, firstPage.getData())); } public CodeChallenge getCodeChallenge(String idOrSlag) throws ApiException { diff --git a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/LazyList.java b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/LazyList.java deleted file mode 100644 index 64cb425..0000000 --- a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/LazyList.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.noid.codewars.client; - -import java.util.AbstractList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.function.IntFunction; - -public class LazyList extends AbstractList { - - private final Map loaded = new LinkedHashMap<>(); - private final IntFunction> pageLoader; - private final int totalItems; - private final int pageSize; - - /** - * Create a LazyList backed by the given query, using pageSize results per page, and expecting numResults from the - * query. - */ - public LazyList(IntFunction> pageLoader, List preload, int pageSize, int totalItems) { - this.pageLoader = pageLoader; - this.pageSize = pageSize; - this.totalItems = totalItems; - addItems(0, preload); - } - - /** - * Fetch an item, loading it from the query results if it hasn't already been. - */ - @Override - public E get(int i) { - if (i < 0 || i >= size()) { - throw new IllegalArgumentException(i + " is not valid index."); - } - if (!loaded.containsKey(i)) { - List page = pageLoader.apply(i / pageSize); - addItems(i, page); - } - return loaded.get(i); - } - - @Override - public int size() { - return totalItems; - } - - private void addItems(int offset, List items) { - for (int i = 0; i < items.size(); i++) { - loaded.put(offset + i, items.get(i)); - } - } -} \ No newline at end of file diff --git a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/PaginatedList.java b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/PaginatedList.java new file mode 100644 index 0000000..5a84d7f --- /dev/null +++ b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/PaginatedList.java @@ -0,0 +1,42 @@ +package dev.noid.codewars.client; + +import java.util.AbstractList; +import java.util.List; +import java.util.Map; +import java.util.function.IntFunction; + +class PaginatedList extends AbstractList { + + private final IntFunction> pageLoader; + private final List[] cache; + private final int totalItems; + private final int pageSize; + + PaginatedList(IntFunction> pageLoader, int totalPages, int totalItems, Map> preloaded) { + this.pageLoader = pageLoader; + this.cache = new List[totalPages]; + preloaded.forEach((pageNumber, page) -> cache[pageNumber] = page); + this.totalItems = totalItems; + this.pageSize = (int) Math.ceil((double) totalItems / totalPages); + } + + @Override + public E get(int i) { + if (i < 0 || i >= size()) { + throw new IndexOutOfBoundsException(); + } + + int pageNumber = Math.floorDiv(i, pageSize); + List page = cache[pageNumber]; + if (page == null) { + page = pageLoader.apply(pageNumber); + cache[pageNumber] = page; + } + return page.get(i % pageSize); + } + + @Override + public int size() { + return totalItems; + } +} diff --git a/codewars-sdk-client/src/test/java/dev/noid/codewars/client/LazyListTest.java b/codewars-sdk-client/src/test/java/dev/noid/codewars/client/LazyListTest.java deleted file mode 100644 index b2a0129..0000000 --- a/codewars-sdk-client/src/test/java/dev/noid/codewars/client/LazyListTest.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.noid.codewars.client; - -import static org.junit.jupiter.api.Assertions.*; - -class LazyListTest { - -} \ No newline at end of file diff --git a/codewars-sdk-client/src/test/java/dev/noid/codewars/client/PaginatedListTest.java b/codewars-sdk-client/src/test/java/dev/noid/codewars/client/PaginatedListTest.java new file mode 100644 index 0000000..d09a131 --- /dev/null +++ b/codewars-sdk-client/src/test/java/dev/noid/codewars/client/PaginatedListTest.java @@ -0,0 +1,141 @@ +package dev.noid.codewars.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class PaginatedListTest { + + private final List> remotePages = Mockito.spy(List.of( + List.of("a", "b", "c", "d", "e"), + List.of("f", "g", "h", "i", "j"), + List.of("k", "l", "m", "n", "o"), + List.of("p", "q", "r", "s", "t"), + List.of("u", "v", "w", "x", "y"), + List.of("z") + )); + + @Test + void load_all_remote_pages() { + assertEquals(List.of( + "a", "b", "c", "d", "e", + "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", + "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", + "z" + ), getList(6, 26, Map.of())); + + Mockito.verify(remotePages, Mockito.times(remotePages.size())).get(anyInt()); + for (int page = 0; page < remotePages.size(); page++) { + Mockito.verify(remotePages, Mockito.times(1)).get(page); + } + } + + @Test + void preload_first_page() { + assertEquals(List.of( + "1", "2", "3", "4", "5", + "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", + "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", + "z" + ), getList(6, 26, Map.of(0, List.of("1", "2", "3", "4", "5")))); + + Mockito.verify(remotePages, Mockito.times(remotePages.size() - 1)).get(anyInt()); + for (int page = 1; page < remotePages.size(); page++) { + Mockito.verify(remotePages, Mockito.times(1)).get(page); + } + } + + @Test + void preload_last_page() { + assertEquals(List.of( + "a", "b", "c", "d", "e", + "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", + "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", + "1" + ), getList(6, 26, Map.of(5, List.of("1", "2", "3", "4", "5")))); + + Mockito.verify(remotePages, Mockito.times(remotePages.size() - 1)).get(anyInt()); + for (int page = 0; page < remotePages.size() - 1; page++) { + Mockito.verify(remotePages, Mockito.times(1)).get(page); + } + } + + @Test + void load_no_remote_pages() { + Map> preloaded = Map.of( + 0, List.of("1", "2", "3", "4", "5"), + 1, List.of("6", "7", "8", "9", "0"), + 2, List.of("~", "!", "@", "#", "$"), + 3, List.of("%", "^", "&", "*", "("), + 4, List.of(")", "_", "+", "`", "-"), + 5, List.of("=") + ); + + assertEquals(List.of( + "1", "2", "3", "4", "5", + "6", "7", "8", "9", "0", + "~", "!", "@", "#", "$", + "%", "^", "&", "*", "(", + ")", "_", "+", "`", "-", + "=" + ), getList(6, 26, preloaded)); + + Mockito.verify(remotePages, Mockito.times(0)).get(anyInt()); + } + + @Test + void limit_pages_access() { + assertEquals(List.of( + "a", "b", "c", "d", "e", + "f", "g", "h", "i", "j", + "k", "l", "m" + ), getList(3, 13, Map.of())); + + Mockito.verify(remotePages, Mockito.times(3)).get(anyInt()); + Mockito.verify(remotePages, Mockito.times(1)).get(0); + Mockito.verify(remotePages, Mockito.times(1)).get(1); + Mockito.verify(remotePages, Mockito.times(1)).get(2); + } + + @Test + void read_from_empty() { + List list = getList(0, 0, Map.of()); + assertTrue(list.isEmpty()); + assertEquals(List.of(), list); + } + + @Test + void constant_size() { + List list = getList(6, 26, Map.of()); + assertEquals(26, list.size()); + for (String ignored : list) { + assertEquals(26, list.size()); + } + assertEquals(26, list.size()); + } + + @Test + void immutability() { + List list = getList(1, 1, Map.of()); + assertThrows(UnsupportedOperationException.class, () -> list.add(0, "!")); + assertThrows(UnsupportedOperationException.class, () -> list.set(0, "!")); + assertThrows(UnsupportedOperationException.class, () -> list.remove(0)); + assertThrows(UnsupportedOperationException.class, () -> list.remove("a")); + } + + private List getList(int totalPages, int totalItems, Map> preloaded) { + return new PaginatedList<>(remotePages::get, totalPages, totalItems, preloaded); + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 97f43a6..77269c0 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 3.0.2 1.6.14 5.10.3 + 5.12.0 @@ -63,6 +64,15 @@ org.junit.jupiter junit-jupiter ${junit.version} + pom + import + + + org.mockito + mockito-bom + ${mockito.version} + pom + import