Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions core/src/main/kotlin/com/ams/fat12ex/core/FatDateTime.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.ams.fat12ex.core

import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime

/**
* Pure decoders for the DOS-packed 16-bit date/time fields carried raw on
* [Fat12Entry] ([Fat12Entry.createdDate], [Fat12Entry.modifiedDate],
* [Fat12Entry.modifiedTime]).
*
* **Date** (16-bit — [Fat12Entry.createdDate] / [Fat12Entry.modifiedDate]):
* - bits 0–4: day of month (1–31)
* - bits 5–8: month (1–12)
* - bits 9–15: year offset from 1980
*
* **Time** (16-bit — [Fat12Entry.modifiedTime]):
* - bits 0–4: seconds / 2 (0–29 → 0–58s, 2-second resolution)
* - bits 5–10: minutes (0–59)
* - bits 11–15: hours (0–23)
*
* These are additive helpers; the raw `Int` fields on [Fat12Entry] are unchanged.
*/

/**
* Decode a FAT-packed 16-bit date field into a [LocalDate].
*
* Returns `null` for the zero sentinel (`0x0000` = the invalid DOS date
* 1980-00-00 strict readers reject) and for any bit pattern that does not form a
* valid calendar date (e.g. month 0 or day 30 of February in a corrupt entry).
* Only the low 16 bits of [packed] are considered.
*/
fun decodeFatDate(packed: Int): LocalDate? {
val bits = packed and 0xFFFF
if (bits == 0) return null
val day = bits and 0x1F
val month = (bits ushr 5) and 0x0F
val year = 1980 + ((bits ushr 9) and 0x7F)
return runCatching { LocalDate.of(year, month, day) }.getOrNull()
}

/**
* Decode a FAT-packed 16-bit time field into a [LocalTime] (2-second resolution;
* odd seconds are not representable and are truncated to the lower even value by
* the encoding).
*
* The three components are masked to their bit-widths; the hours (0–23), minutes
* (0–59) and seconds-field (0–29) of a well-formed entry are always in range. A
* corrupt field whose decoded components fall outside those ranges (e.g. a
* seconds-field of 30/31 decoding to 60/62s) throws [java.time.DateTimeException].
* Only the low 16 bits of [packed] are considered.
*/
fun decodeFatTime(packed: Int): LocalTime {
val bits = packed and 0xFFFF
val seconds = (bits and 0x1F) * 2
val minutes = (bits ushr 5) and 0x3F
val hours = (bits ushr 11) and 0x1F
return LocalTime.of(hours, minutes, seconds)
}

/** The decoded creation date, or `null` when the raw [Fat12Entry.createdDate] field is absent/invalid. */
fun Fat12Entry.createdDateOrNull(): LocalDate? = decodeFatDate(createdDate)

/** The decoded last-modified date, or `null` when the raw [Fat12Entry.modifiedDate] field is absent/invalid. */
fun Fat12Entry.modifiedDateOrNull(): LocalDate? = decodeFatDate(modifiedDate)

/**
* The decoded last-modified date+time, or `null` when the raw modified date is
* absent/invalid. The modified time is combined with the modified date; a corrupt
* time field yields `null` rather than throwing.
*/
fun Fat12Entry.modifiedDateTimeOrNull(): LocalDateTime? {
val date = modifiedDateOrNull() ?: return null
return runCatching { LocalDateTime.of(date, decodeFatTime(modifiedTime)) }.getOrNull()
}
162 changes: 162 additions & 0 deletions core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.ams.fat12ex.core

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime

/**
* [decodeFatDate] / [decodeFatTime] and the [Fat12Entry] decode accessors.
*
* Fixtures are hand-computed from the FAT bit packing:
* date = ((year-1980) shl 9) or (month shl 5) or day
* time = (hours shl 11) or (minutes shl 5) or (seconds / 2)
*/
class FatDateTimeTest {

// ----- decodeFatDate ------------------------------------------------------

@Test
fun decodeFatDate_knownValue() {
// 2026-06-18 = (46 shl 9) or (6 shl 5) or 18 = 23762 (0x5CD2)
assertEquals(LocalDate.of(2026, 6, 18), decodeFatDate(0x5CD2))
}

@Test
fun decodeFatDate_engineDefault() {
// DirRegion.DEFAULT_DOS_DATE (0x58C1) = 2024-06-01
assertEquals(LocalDate.of(2024, 6, 1), decodeFatDate(0x58C1))
}

@Test
fun decodeFatDate_epochYear1980_offsetZero() {
// year offset 0, month 1, day 1 = (0 shl 9) or (1 shl 5) or 1 = 0x0021
assertEquals(LocalDate.of(1980, 1, 1), decodeFatDate(0x0021))
}

@Test
fun decodeFatDate_zeroIsNull() {
assertNull(decodeFatDate(0x0000))
}

@Test
fun decodeFatDate_invalidComponentsIsNull() {
// month = 0 (day 1) — not a valid calendar date.
assertNull(decodeFatDate(0x0001))
// month = 13, day = 1, year offset 0 = (13 shl 5) or 1 = 0x01A1 — invalid month.
assertNull(decodeFatDate(0x01A1))
}

@Test
fun decodeFatDate_ignoresHighBits() {
// High bits above the 16-bit field must not affect the result.
assertEquals(decodeFatDate(0x5CD2), decodeFatDate(0x5CD2 or 0x7FFF_0000))
}

// ----- decodeFatTime ------------------------------------------------------

@Test
fun decodeFatTime_knownValue() {
// 13:45:30 = (13 shl 11) or (45 shl 5) or (30 / 2) = 28079 (0x6DAF)
assertEquals(LocalTime.of(13, 45, 30), decodeFatTime(0x6DAF))
}

@Test
fun decodeFatTime_engineDefault() {
// DirRegion.DEFAULT_DOS_TIME (0x6000) = 12:00:00
assertEquals(LocalTime.of(12, 0, 0), decodeFatTime(0x6000))
}

@Test
fun decodeFatTime_zeroIsMidnight() {
assertEquals(LocalTime.of(0, 0, 0), decodeFatTime(0x0000))
}

@Test
fun decodeFatTime_roundTripsValidFields_secondsAlwaysEven() {
// Over the representable space (2-second resolution) the decode round-trips
// the packed components, and seconds are always even.
for (hours in 0..23) {
for (minutes in intArrayOf(0, 1, 30, 59)) {
for (secHalf in 0..29) {
val packed = (hours shl 11) or (minutes shl 5) or secHalf
val decoded = decodeFatTime(packed)
assertEquals(LocalTime.of(hours, minutes, secHalf * 2), decoded)
assertTrue(decoded.second % 2 == 0)
}
}
}
}

@Test
fun decodeFatTime_maxSecondsField() {
// seconds field = 29 -> 58s; minutes 0, hours 0 -> 0x001D.
assertEquals(LocalTime.of(0, 0, 58), decodeFatTime(0x001D))
}

// ----- Fat12Entry accessors ----------------------------------------------

@Test
fun entryAccessors_decodeRawFields() {
val entry = Fat12Entry(
name = "REPORT.TXT",
shortName = "REPORT TXT",
isDirectory = false,
size = 42,
firstCluster = 2,
attributes = 0x20, // ARCHIVE
createdDate = 0x5CD2, // 2026-06-18
modifiedDate = 0x58C1, // 2024-06-01
modifiedTime = 0x6DAF, // 13:45:30
)
assertEquals(LocalDate.of(2026, 6, 18), entry.createdDateOrNull())
assertEquals(LocalDate.of(2024, 6, 1), entry.modifiedDateOrNull())
assertEquals(
LocalDateTime.of(2024, 6, 1, 13, 45, 30),
entry.modifiedDateTimeOrNull(),
)
}

@Test
fun entryAccessors_zeroDateFieldsAreNull() {
val entry = Fat12Entry(
name = "EMPTY.TXT",
shortName = "EMPTY TXT",
isDirectory = false,
size = 0,
firstCluster = 0,
attributes = 0,
createdDate = 0,
modifiedDate = 0,
modifiedTime = 0,
)
assertNull(entry.createdDateOrNull())
assertNull(entry.modifiedDateOrNull())
assertNull(entry.modifiedDateTimeOrNull())
}

@Test
fun entryAccessors_corruptModifiedTime_decoderThrowsButAccessorIsNull() {
// modifiedTime = 0x001E has a seconds-field of 30 -> 60s, which is not a
// valid LocalTime. The low-level decoder rejects it; the entry accessor
// swallows that to null rather than propagating the exception.
assertThrows<java.time.DateTimeException> { decodeFatTime(0x001E) }

val entry = Fat12Entry(
name = "BROKEN.TXT",
shortName = "BROKEN TXT",
isDirectory = false,
size = 1,
firstCluster = 2,
attributes = 0x20,
createdDate = 0x5CD2, // valid date
modifiedDate = 0x58C1, // valid date
modifiedTime = 0x001E, // corrupt time
)
assertNull(entry.modifiedDateTimeOrNull())
}
}
Loading