Implement Phase 1: project skeleton and data layer
Android project with Kotlin, Jetpack Compose, and Room. Includes: - Gradle build system with version catalog, foojay JDK resolver, lint config - Room entities (Notebook, Page, Stroke) with packed float BLOB encoding - DAOs and repositories for all entities - Unit tests for blob roundtrip and PageSize enum (10 tests, all passing) - Minimal Application class and stub MainActivity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
app/build.gradle.kts
Normal file
78
app/build.gradle.kts
Normal file
@@ -0,0 +1,78 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.metacircular.engpad"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.metacircular.engpad"
|
||||
minSdk = 30
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
lint {
|
||||
warningsAsErrors = true
|
||||
abortOnError = true
|
||||
checkDependencies = true
|
||||
// AGP 9.x requires Gradle 9.x; suppress until we're ready to migrate
|
||||
disable += "AndroidGradlePluginVersion"
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
implementation(libs.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.room.testing)
|
||||
}
|
||||
3
app/proguard-rules.pro
vendored
Normal file
3
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# eng-pad ProGuard rules
|
||||
# Room
|
||||
-keep class net.metacircular.engpad.data.model.** { *; }
|
||||
32
app/src/main/AndroidManifest.xml
Normal file
32
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".EngPadApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.EngPad">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.EngPad">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
8
app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt
Normal file
8
app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package net.metacircular.engpad
|
||||
|
||||
import android.app.Application
|
||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||
|
||||
class EngPadApp : Application() {
|
||||
val database: EngPadDatabase by lazy { EngPadDatabase.getInstance(this) }
|
||||
}
|
||||
15
app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt
Normal file
15
app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package net.metacircular.engpad
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
Text("eng-pad")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Encode a FloatArray as a packed little-endian byte array.
|
||||
* Points are stored as [x0, y0, x1, y1, ...].
|
||||
*/
|
||||
fun FloatArray.toBlob(): ByteArray {
|
||||
val buffer = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
||||
for (f in this) buffer.putFloat(f)
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a packed little-endian byte array back to a FloatArray.
|
||||
*/
|
||||
fun ByteArray.toFloatArray(): FloatArray {
|
||||
val buffer = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
|
||||
return FloatArray(size / 4) { buffer.getFloat() }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import net.metacircular.engpad.data.model.Notebook
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
@Database(
|
||||
entities = [Notebook::class, Page::class, Stroke::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class EngPadDatabase : RoomDatabase() {
|
||||
abstract fun notebookDao(): NotebookDao
|
||||
abstract fun pageDao(): PageDao
|
||||
abstract fun strokeDao(): StrokeDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: EngPadDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): EngPadDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
EngPadDatabase::class.java,
|
||||
"engpad.db",
|
||||
).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.model.Notebook
|
||||
|
||||
@Dao
|
||||
interface NotebookDao {
|
||||
@Insert
|
||||
suspend fun insert(notebook: Notebook): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(notebook: Notebook)
|
||||
|
||||
@Query("SELECT * FROM notebooks ORDER BY updated_at DESC")
|
||||
fun getAll(): Flow<List<Notebook>>
|
||||
|
||||
@Query("SELECT * FROM notebooks WHERE id = :id")
|
||||
suspend fun getById(id: Long): Notebook?
|
||||
|
||||
@Query("DELETE FROM notebooks WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
|
||||
@Dao
|
||||
interface PageDao {
|
||||
@Insert
|
||||
suspend fun insert(page: Page): Long
|
||||
|
||||
@Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC")
|
||||
fun getByNotebookId(notebookId: Long): Flow<List<Page>>
|
||||
|
||||
@Query("SELECT * FROM pages WHERE id = :id")
|
||||
suspend fun getById(id: Long): Page?
|
||||
|
||||
@Query("SELECT MAX(page_number) FROM pages WHERE notebook_id = :notebookId")
|
||||
suspend fun getMaxPageNumber(notebookId: Long): Int?
|
||||
|
||||
@Query("DELETE FROM pages WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
@Dao
|
||||
interface StrokeDao {
|
||||
@Insert
|
||||
suspend fun insert(stroke: Stroke): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertAll(strokes: List<Stroke>)
|
||||
|
||||
@Query("SELECT * FROM strokes WHERE page_id = :pageId ORDER BY stroke_order ASC")
|
||||
suspend fun getByPageId(pageId: Long): List<Stroke>
|
||||
|
||||
@Query("DELETE FROM strokes WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("DELETE FROM strokes WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Long>)
|
||||
|
||||
@Query("SELECT MAX(stroke_order) FROM strokes WHERE page_id = :pageId")
|
||||
suspend fun getMaxStrokeOrder(pageId: Long): Int?
|
||||
|
||||
@Query("UPDATE strokes SET point_data = :pointData WHERE id = :id")
|
||||
suspend fun updatePointData(id: Long, pointData: ByteArray)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "notebooks")
|
||||
data class Notebook(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val title: String,
|
||||
@ColumnInfo(name = "page_size") val pageSize: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "pages",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Notebook::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["notebook_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["notebook_id"]),
|
||||
Index(value = ["notebook_id", "page_number"], unique = true),
|
||||
],
|
||||
)
|
||||
data class Page(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "notebook_id") val notebookId: Long,
|
||||
@ColumnInfo(name = "page_number") val pageNumber: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
/**
|
||||
* Page sizes in canonical coordinates (300 DPI).
|
||||
*/
|
||||
enum class PageSize(val widthPt: Int, val heightPt: Int) {
|
||||
/** 8.5 × 11 inches */
|
||||
REGULAR(2550, 3300),
|
||||
|
||||
/** 11 × 17 inches */
|
||||
LARGE(3300, 5100);
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): PageSize =
|
||||
entries.first { it.name.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "strokes",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Page::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["page_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["page_id"])],
|
||||
)
|
||||
data class Stroke(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "page_id") val pageId: Long,
|
||||
@ColumnInfo(name = "pen_size") val penSize: Float,
|
||||
val color: Int,
|
||||
@ColumnInfo(name = "point_data") val pointData: ByteArray,
|
||||
@ColumnInfo(name = "stroke_order") val strokeOrder: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Stroke) return false
|
||||
return id == other.id &&
|
||||
pageId == other.pageId &&
|
||||
penSize == other.penSize &&
|
||||
color == other.color &&
|
||||
pointData.contentEquals(other.pointData) &&
|
||||
strokeOrder == other.strokeOrder &&
|
||||
createdAt == other.createdAt
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + pageId.hashCode()
|
||||
result = 31 * result + penSize.hashCode()
|
||||
result = 31 * result + color
|
||||
result = 31 * result + pointData.contentHashCode()
|
||||
result = 31 * result + strokeOrder
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package net.metacircular.engpad.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.db.NotebookDao
|
||||
import net.metacircular.engpad.data.db.PageDao
|
||||
import net.metacircular.engpad.data.model.Notebook
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
|
||||
class NotebookRepository(
|
||||
private val notebookDao: NotebookDao,
|
||||
private val pageDao: PageDao,
|
||||
) {
|
||||
fun getAll(): Flow<List<Notebook>> = notebookDao.getAll()
|
||||
|
||||
suspend fun getById(id: Long): Notebook? = notebookDao.getById(id)
|
||||
|
||||
/**
|
||||
* Create a notebook and its first page. Returns the notebook ID.
|
||||
*/
|
||||
suspend fun create(title: String, pageSize: String): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val notebookId = notebookDao.insert(
|
||||
Notebook(
|
||||
title = title,
|
||||
pageSize = pageSize,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
)
|
||||
pageDao.insert(
|
||||
Page(
|
||||
notebookId = notebookId,
|
||||
pageNumber = 1,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
return notebookId
|
||||
}
|
||||
|
||||
suspend fun delete(id: Long) = notebookDao.deleteById(id)
|
||||
|
||||
suspend fun updateTitle(id: Long, title: String) {
|
||||
val notebook = notebookDao.getById(id) ?: return
|
||||
notebookDao.update(
|
||||
notebook.copy(title = title, updatedAt = System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package net.metacircular.engpad.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.db.PageDao
|
||||
import net.metacircular.engpad.data.db.StrokeDao
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
class PageRepository(
|
||||
private val pageDao: PageDao,
|
||||
private val strokeDao: StrokeDao,
|
||||
) {
|
||||
fun getPages(notebookId: Long): Flow<List<Page>> =
|
||||
pageDao.getByNotebookId(notebookId)
|
||||
|
||||
suspend fun getById(id: Long): Page? = pageDao.getById(id)
|
||||
|
||||
/**
|
||||
* Add a new page to a notebook. Returns the page ID.
|
||||
*/
|
||||
suspend fun addPage(notebookId: Long): Long {
|
||||
val maxPage = pageDao.getMaxPageNumber(notebookId) ?: 0
|
||||
return pageDao.insert(
|
||||
Page(
|
||||
notebookId = notebookId,
|
||||
pageNumber = maxPage + 1,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deletePage(id: Long) = pageDao.deleteById(id)
|
||||
|
||||
suspend fun getStrokes(pageId: Long): List<Stroke> =
|
||||
strokeDao.getByPageId(pageId)
|
||||
|
||||
suspend fun addStroke(stroke: Stroke): Long = strokeDao.insert(stroke)
|
||||
|
||||
suspend fun deleteStroke(id: Long) = strokeDao.deleteById(id)
|
||||
|
||||
suspend fun deleteStrokes(ids: List<Long>) = strokeDao.deleteByIds(ids)
|
||||
|
||||
suspend fun insertStrokes(strokes: List<Stroke>) = strokeDao.insertAll(strokes)
|
||||
|
||||
suspend fun getNextStrokeOrder(pageId: Long): Int =
|
||||
(strokeDao.getMaxStrokeOrder(pageId) ?: 0) + 1
|
||||
|
||||
suspend fun updateStrokePoints(id: Long, pointData: ByteArray) =
|
||||
strokeDao.updatePointData(id, pointData)
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
11
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Simple pen icon -->
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,24 L62,72 L54,80 L46,72 Z" />
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">eng-pad</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.EngPad" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.metacircular.engpad.data
|
||||
|
||||
import net.metacircular.engpad.data.model.PageSize
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PageSizeTest {
|
||||
|
||||
@Test
|
||||
fun `regular page dimensions at 300 DPI`() {
|
||||
assertEquals(2550, PageSize.REGULAR.widthPt)
|
||||
assertEquals(3300, PageSize.REGULAR.heightPt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large page dimensions at 300 DPI`() {
|
||||
assertEquals(3300, PageSize.LARGE.widthPt)
|
||||
assertEquals(5100, PageSize.LARGE.heightPt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromString case insensitive`() {
|
||||
assertEquals(PageSize.REGULAR, PageSize.fromString("regular"))
|
||||
assertEquals(PageSize.REGULAR, PageSize.fromString("REGULAR"))
|
||||
assertEquals(PageSize.LARGE, PageSize.fromString("large"))
|
||||
assertEquals(PageSize.LARGE, PageSize.fromString("Large"))
|
||||
}
|
||||
|
||||
@Test(expected = NoSuchElementException::class)
|
||||
fun `fromString invalid value throws`() {
|
||||
PageSize.fromString("unknown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.metacircular.engpad.data
|
||||
|
||||
import net.metacircular.engpad.data.db.toBlob
|
||||
import net.metacircular.engpad.data.db.toFloatArray
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class StrokeBlobTest {
|
||||
|
||||
@Test
|
||||
fun `roundtrip simple points`() {
|
||||
val points = floatArrayOf(100f, 200f, 300f, 400f, 500f, 600f)
|
||||
val blob = points.toBlob()
|
||||
val decoded = blob.toFloatArray()
|
||||
assertArrayEquals(points, decoded, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roundtrip single point`() {
|
||||
val points = floatArrayOf(1.08f, 2550.0f)
|
||||
val blob = points.toBlob()
|
||||
val decoded = blob.toFloatArray()
|
||||
assertArrayEquals(points, decoded, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roundtrip empty array`() {
|
||||
val points = floatArrayOf()
|
||||
val blob = points.toBlob()
|
||||
val decoded = blob.toFloatArray()
|
||||
assertEquals(0, decoded.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blob size is 4 bytes per float`() {
|
||||
val points = floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f)
|
||||
val blob = points.toBlob()
|
||||
assertEquals(points.size * 4, blob.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roundtrip fractional coordinates`() {
|
||||
val points = floatArrayOf(
|
||||
14.4f, 28.8f, // grid intersection
|
||||
4.488f, 5.906f, // pen width values
|
||||
2550f, 3300f, // regular page corner
|
||||
3300f, 5100f, // large page corner
|
||||
)
|
||||
val blob = points.toBlob()
|
||||
val decoded = blob.toFloatArray()
|
||||
assertArrayEquals(points, decoded, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roundtrip negative coordinates`() {
|
||||
// Negative values shouldn't appear in practice but encoding must handle them
|
||||
val points = floatArrayOf(-1f, -2f, 0f, 0f)
|
||||
val blob = points.toBlob()
|
||||
val decoded = blob.toFloatArray()
|
||||
assertArrayEquals(points, decoded, 0f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user