Merge "Add testing for GridMigration." into main
diff --git a/src/com/android/launcher3/model/DeviceGridState.java b/src/com/android/launcher3/model/DeviceGridState.java
index f24d1d2..8c68eb8 100644
--- a/src/com/android/launcher3/model/DeviceGridState.java
+++ b/src/com/android/launcher3/model/DeviceGridState.java
@@ -53,6 +53,13 @@
     private final @DeviceType int mDeviceType;
     private final String mDbFile;
 
+    public DeviceGridState(int columns, int row, int numHotseat, int deviceType, String dbFile) {
+        mGridSizeString = String.format(Locale.ENGLISH, "%d,%d", columns, row);
+        mNumHotseat = numHotseat;
+        mDeviceType = deviceType;
+        mDbFile = dbFile;
+    }
+
     public DeviceGridState(InvariantDeviceProfile idp) {
         mGridSizeString = String.format(Locale.ENGLISH, "%d,%d", idp.numColumns, idp.numRows);
         mNumHotseat = idp.numDatabaseHotseatIcons;
diff --git a/src/com/android/launcher3/model/GridSizeMigrationUtil.java b/src/com/android/launcher3/model/GridSizeMigrationUtil.java
index 1d44f20..30d2cfb 100644
--- a/src/com/android/launcher3/model/GridSizeMigrationUtil.java
+++ b/src/com/android/launcher3/model/GridSizeMigrationUtil.java
@@ -38,6 +38,7 @@
 import android.util.Log;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.launcher3.InvariantDeviceProfile;
 import com.android.launcher3.LauncherPrefs;
@@ -94,6 +95,15 @@
         return needsToMigrate;
     }
 
+    @VisibleForTesting
+    public static List<DbEntry> readAllEntries(SQLiteDatabase db, String tableName,
+            Context context) {
+        DbReader dbReader = new DbReader(db, tableName, context, getValidPackages(context));
+        List<DbEntry> result = dbReader.loadAllWorkspaceEntries();
+        result.addAll(dbReader.loadHotseatEntries());
+        return result;
+    }
+
     /**
      * When migrating the grid, we copy the table
      * {@link LauncherSettings.Favorites#TABLE_NAME} from {@code source} into
@@ -654,7 +664,7 @@
         }
     }
 
-    protected static class DbEntry extends ItemInfo implements Comparable<DbEntry> {
+    public static class DbEntry extends ItemInfo implements Comparable<DbEntry> {
 
         private String mIntent;
         private String mProvider;
diff --git a/tests/assets/databases/GridMigrationTest/result5x5to3x3.db b/tests/assets/databases/GridMigrationTest/result5x5to3x3.db
new file mode 100644
index 0000000..686056d
--- /dev/null
+++ b/tests/assets/databases/GridMigrationTest/result5x5to3x3.db
Binary files differ
diff --git a/tests/assets/databases/GridMigrationTest/result5x5to4x7.db b/tests/assets/databases/GridMigrationTest/result5x5to4x7.db
new file mode 100644
index 0000000..cd105c5
--- /dev/null
+++ b/tests/assets/databases/GridMigrationTest/result5x5to4x7.db
Binary files differ
diff --git a/tests/assets/databases/GridMigrationTest/result5x5to5x8.db b/tests/assets/databases/GridMigrationTest/result5x5to5x8.db
new file mode 100644
index 0000000..4b46969
--- /dev/null
+++ b/tests/assets/databases/GridMigrationTest/result5x5to5x8.db
Binary files differ
diff --git a/tests/assets/databases/GridMigrationTest/test_launcher.db b/tests/assets/databases/GridMigrationTest/test_launcher.db
new file mode 100644
index 0000000..c680e95
--- /dev/null
+++ b/tests/assets/databases/GridMigrationTest/test_launcher.db
Binary files differ
diff --git a/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java b/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java
index 62f2259..e5ad888 100644
--- a/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java
+++ b/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java
@@ -199,6 +199,19 @@
         return 'z';
     }
 
+    /**
+     * Check if the given area is empty.
+     */
+    public boolean isEmpty(int x, int y, int spanX, int spanY) {
+        for (int xi = x; xi < x + spanX; xi++) {
+            for (int yi = y; yi < y + spanY; yi++) {
+                if (mWidget[xi][yi] == CellType.IGNORE) continue;
+                if (mWidget[xi][yi] != CellType.EMPTY) return false;
+            }
+        }
+        return true;
+    }
+
     public void addWidget(int x, int y, int spanX, int spanY, char type) {
         Rect rect = new Rect(x, y + spanY - 1, x + spanX - 1, y);
         removeOverlappingItems(rect);
diff --git a/tests/src/com/android/launcher3/model/GridMigrationTest.kt b/tests/src/com/android/launcher3/model/GridMigrationTest.kt
new file mode 100644
index 0000000..eb8604e
--- /dev/null
+++ b/tests/src/com/android/launcher3/model/GridMigrationTest.kt
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.model
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.launcher3.InvariantDeviceProfile.TYPE_PHONE
+import com.android.launcher3.LauncherSettings.Favorites.TABLE_NAME
+import com.android.launcher3.celllayout.board.CellLayoutBoard
+import com.android.launcher3.pm.UserCache
+import com.android.launcher3.util.rule.TestToPhoneFileCopier
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private val phoneContext = InstrumentationRegistry.getInstrumentation().targetContext
+
+data class EntryData(val x: Int, val y: Int, val spanX: Int, val spanY: Int, val rank: Int)
+
+/**
+ * Holds the data needed to run a test in GridMigrationTest, usually we would have a src
+ * GridMigrationData and a dst GridMigrationData meaning the data after a migration has occurred.
+ * This class holds a gridState, which is the size of the grid like 5x5 (among other things). a
+ * dbHelper which contains the readable database and writable database used to migrate the
+ * databases.
+ *
+ * You can also get all the entries defined in the dbHelper database.
+ */
+class GridMigrationData(dbFileName: String?, val gridState: DeviceGridState) {
+
+    val dbHelper: DatabaseHelper =
+        DatabaseHelper(
+            phoneContext,
+            dbFileName,
+            { UserCache.INSTANCE.get(phoneContext).getSerialNumberForUser(it) },
+            {}
+        )
+
+    fun readEntries(): List<GridSizeMigrationUtil.DbEntry> =
+        GridSizeMigrationUtil.readAllEntries(dbHelper.readableDatabase, TABLE_NAME, phoneContext)
+}
+
+/**
+ * Test the migration of a database from one size to another. It reads a database from the test
+ * assets, uploads it into the phone and migrates the database to a database in memory which is
+ * later compared against a database in the test assets to make sure they are identical.
+ */
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class GridMigrationTest {
+    private val DB_FILE = "test_launcher.db"
+
+    // Copying the src db for all tests.
+    @JvmField
+    @Rule
+    val fileCopier =
+        TestToPhoneFileCopier("databases/GridMigrationTest/$DB_FILE", "databases/$DB_FILE", true)
+
+    private fun migrate(src: GridMigrationData, dst: GridMigrationData) {
+        GridSizeMigrationUtil.migrateGridIfNeeded(
+            phoneContext,
+            src.gridState,
+            dst.gridState,
+            dst.dbHelper,
+            src.dbHelper.readableDatabase
+        )
+    }
+
+    /**
+     * Makes sure that none of the items overlaps on the result, i.e. no widget or icons share the
+     * same space in the db.
+     */
+    private fun validateDb(data: GridMigrationData) {
+        val cellLayoutBoard = CellLayoutBoard(data.gridState.columns, data.gridState.rows)
+        data.readEntries().forEach {
+            assert(cellLayoutBoard.isEmpty(it.cellX, it.cellY, it.spanX, it.spanY)) {
+                "Db has overlapping items"
+            }
+            cellLayoutBoard.addWidget(it.cellX, it.cellY, it.spanX, it.spanY)
+        }
+    }
+
+    private fun compare(dst: GridMigrationData, target: GridMigrationData) {
+        val sortX = { it: GridSizeMigrationUtil.DbEntry -> it.cellX }
+        val sortY = { it: GridSizeMigrationUtil.DbEntry -> it.cellX }
+        val mapF = { it: GridSizeMigrationUtil.DbEntry ->
+            EntryData(it.cellX, it.cellY, it.spanX, it.spanY, it.rank)
+        }
+        val entriesDst = dst.readEntries().sortedBy(sortX).sortedBy(sortY).map(mapF)
+        val entriesTarget = target.readEntries().sortedBy(sortX).sortedBy(sortY).map(mapF)
+        assert(entriesDst == entriesTarget) {
+            "The elements on the dst database is not the same as in the target"
+        }
+    }
+
+    /**
+     * Migrate src into dst and compare to target. This method validates 3 things:
+     * 1. dst has the same number of items as src after the migration, meaning, none of the items
+     *    were removed during the migration.
+     * 2. dst is valid, meaning that none of the items overlap with each other.
+     * 3. dst is equal to target to ensure we don't unintentionally change the migration logic.
+     */
+    private fun runTest(src: GridMigrationData, dst: GridMigrationData, target: GridMigrationData) {
+        migrate(src, dst)
+        assert(src.readEntries().size == dst.readEntries().size) {
+            "Source db and destination db do not contain the same number of elements"
+        }
+        validateDb(dst)
+        compare(dst, target)
+    }
+
+    @JvmField
+    @Rule
+    val result5x5to3x3 =
+        TestToPhoneFileCopier(
+            "databases/GridMigrationTest/result5x5to3x3.db",
+            "databases/result5x5to3x3.db",
+            true
+        )
+
+    @Test
+    fun `5x5 to 3x3`() =
+        runTest(
+            src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)),
+            dst =
+                GridMigrationData(
+                    null, // in memory db, to download a new db change null for the filename of the
+                    // db name to store it. Do not use existing names.
+                    DeviceGridState(3, 3, 3, TYPE_PHONE, "")
+                ),
+            target =
+                GridMigrationData("result5x5to3x3.db", DeviceGridState(3, 3, 3, TYPE_PHONE, ""))
+        )
+
+    @JvmField
+    @Rule
+    val result5x5to4x7 =
+        TestToPhoneFileCopier(
+            "databases/GridMigrationTest/result5x5to4x7.db",
+            "databases/result5x5to4x7.db",
+            true
+        )
+
+    @Test
+    fun `5x5 to 4x7`() =
+        runTest(
+            src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)),
+            dst =
+                GridMigrationData(
+                    null, // in memory db, to download a new db change null for the filename of the
+                    // db name to store it. Do not use existing names.
+                    DeviceGridState(4, 7, 4, TYPE_PHONE, "")
+                ),
+            target =
+                GridMigrationData("result5x5to4x7.db", DeviceGridState(4, 7, 4, TYPE_PHONE, ""))
+        )
+
+    @JvmField
+    @Rule
+    val result5x5to5x8 =
+        TestToPhoneFileCopier(
+            "databases/GridMigrationTest/result5x5to5x8.db",
+            "databases/result5x5to5x8.db",
+            true
+        )
+
+    @Test
+    fun `5x5 to 5x8`() =
+        runTest(
+            src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)),
+            dst =
+                GridMigrationData(
+                    null, // in memory db, to download a new db change null for the filename of the
+                    // db name to store it. Do not use existing names.
+                    DeviceGridState(5, 8, 5, TYPE_PHONE, "")
+                ),
+            target =
+                GridMigrationData("result5x5to5x8.db", DeviceGridState(5, 8, 5, TYPE_PHONE, ""))
+        )
+}
diff --git a/tests/src/com/android/launcher3/util/rule/TestToPhoneFileCopier.kt b/tests/src/com/android/launcher3/util/rule/TestToPhoneFileCopier.kt
new file mode 100644
index 0000000..72c4f16
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/rule/TestToPhoneFileCopier.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.util.rule
+
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.File
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/** Copy a file from the tests assets folder to the phone. */
+class TestToPhoneFileCopier(
+    val src: String,
+    dest: String,
+    private val removeOnFinish: Boolean = false
+) : TestRule {
+
+    private val dstFile =
+        File(InstrumentationRegistry.getInstrumentation().targetContext.dataDir, dest)
+
+    fun getDst() = dstFile.absolutePath
+
+    fun before() =
+        dstFile.writeBytes(
+            InstrumentationRegistry.getInstrumentation().context.assets.open(src).readBytes()
+        )
+
+    fun after() {
+        if (removeOnFinish) {
+            dstFile.delete()
+        }
+    }
+
+    override fun apply(base: Statement, description: Description): Statement =
+        object : Statement() {
+            override fun evaluate() {
+                before()
+                base.evaluate()
+            }
+        }
+}