package social.grain import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test import social.grain.data.api.PinnedFeed import social.grain.data.model.AspectRatio import social.grain.data.model.GrainExif import social.grain.util.compactCount import social.grain.util.monthDay import social.grain.util.nowIso import social.grain.util.parseIso import social.grain.util.relativeTime import java.time.Instant /** * The small conversions that sit under every screen. * * None of them can fail loudly: a mis-rounded shutter speed or a count that * reads "1.0K" is simply wrong on screen, in a caption nobody is checking * against the source. */ class FormattingTest { // MARK: - Counts @Test fun countsBelowAThousandAreWrittenOutInFull() { assertEquals("0", 0.compactCount()) assertEquals("999", 999.compactCount()) } @Test fun thousandsAndMillionsDropTheirTrailingZero() { assertEquals("1K", 1_000.compactCount()) assertEquals("1.2K", 1_200.compactCount()) assertEquals("2K", 2_000.compactCount()) assertEquals("12.3K", 12_345.compactCount()) assertEquals("1M", 1_000_000.compactCount()) assertEquals("1.5M", 1_500_000.compactCount()) } // MARK: - Dates @Test fun timestampsAreWrittenTheWayJavascriptWritesThem() { assertEquals( "2026-03-01T12:00:00.000Z", nowIso(Instant.parse("2026-03-01T12:00:00Z")), ) } @Test fun timestampsParseWithOrWithoutFractionalSecondsAndAnOffset() { assertEquals( Instant.parse("2026-03-01T12:00:00Z"), parseIso("2026-03-01T12:00:00.000Z"), ) assertEquals(Instant.parse("2026-03-01T12:00:00Z"), parseIso("2026-03-01T12:00:00Z")) assertEquals( "An offset is still a moment in time", Instant.parse("2026-03-01T12:00:00Z"), parseIso("2026-03-01T13:00:00+01:00"), ) assertNull(parseIso("some time last week")) } @Test fun relativeTimeCountsUpThroughTheUnitsAndThenGivesADate() { val now = System.currentTimeMillis() fun ago(seconds: Long) = relativeTime(Instant.ofEpochMilli(now - seconds * 1000)) assertEquals("now", ago(5)) assertEquals("59m", ago(59 * 60)) assertEquals("2h", ago(2 * 3600)) assertEquals("3d", ago(3 * 86_400)) assertEquals("2w", ago(14 * 86_400)) assertEquals("Anything older is a date", "Mar 5", relativeTime("2020-03-05T12:00:00Z")) } @Test fun aMissingOrUnreadableTimestampFormatsAsNothingAtAll() { assertEquals("", relativeTime(null)) assertEquals("", relativeTime("not a date")) assertEquals("", monthDay(null)) assertEquals("Mar 5", monthDay("2020-03-05T12:00:00Z")) } // MARK: - Aspect ratios @Test fun anAspectRatioWithNoHeightIsTreatedAsSquare() { assertEquals(1.5f, AspectRatio(3, 2).ratio) assertEquals("A zero height would otherwise divide by zero", 1f, AspectRatio(3, 0).ratio) } // MARK: - Exif private fun exif( make: String? = null, model: String? = null, lensMake: String? = null, lensModel: String? = null, fNumber: String? = null, exposureTime: String? = null, focal: String? = null, iso: Int? = null, ) = GrainExif( make = make, model = model, lensMake = lensMake, lensModel = lensModel, fNumber = fNumber, exposureTime = exposureTime, focalLengthIn35mmFormat = focal, iSO = iso, ) @Test fun theCameraNameJoinsTheMakeAndModelAndSkipsWhatIsMissing() { assertEquals("Fujifilm X100V", exif(make = "Fujifilm", model = "X100V").cameraName) assertEquals("X100V", exif(model = "X100V").cameraName) assertNull(exif().cameraName) assertNull("Empty strings are as absent as nulls", exif(make = "", model = "").cameraName) } @Test fun theLensNamePrefersTheModelAndFallsBackToTheMake() { assertEquals("XF 23mm f/2", exif(lensModel = "XF 23mm f/2").lensName) assertEquals("Fujinon", exif(lensMake = "Fujinon").lensName) assertNull(exif().lensName) } @Test fun apertureDropsTheZeroesTheServerLeavesOn() { assertEquals("f/2", exif(fNumber = "2.0").formattedFNumber) assertEquals("f/2.8", exif(fNumber = "f/2.80").formattedFNumber) assertEquals("f/1.4", exif(fNumber = "1.4").formattedFNumber) assertEquals("Something unparseable is passed through", "wide", exif(fNumber = "wide").formattedFNumber) assertNull(exif().formattedFNumber) } @Test fun shutterSpeedIsNormalisedToAWholeFractionOrSeconds() { assertEquals("1/500s", exif(exposureTime = "1/500").formattedExposureTime) assertEquals("1/500s", exif(exposureTime = "0.002s").formattedExposureTime) assertEquals("30s", exif(exposureTime = "30").formattedExposureTime) assertEquals("1.5s", exif(exposureTime = "1.5").formattedExposureTime) assertEquals("fast", exif(exposureTime = "fast").formattedExposureTime) assertNull(exif().formattedExposureTime) } @Test fun focalLengthIsRoundedToWholeMillimetres() { assertEquals("35mm", exif(focal = "35").formattedFocalLength) assertEquals("35mm", exif(focal = "34.6mm").formattedFocalLength) assertEquals("long", exif(focal = "long").formattedFocalLength) assertNull(exif().formattedFocalLength) } @Test fun theSettingsLineJoinsWhateverIsKnownAndIsAbsentWhenNothingIs() { val full = exif(fNumber = "2.0", exposureTime = "1/500", focal = "35", iso = 400) assertEquals("35mm · f/2 · 1/500s · ISO 400", full.settingsLine) assertEquals("ISO 400", exif(iso = 400).settingsLine) assertNull(exif().settingsLine) } @Test fun aPhotoWithNothingToSayHidesTheExifPanel() { assertEquals(false, exif().hasDisplayableData) assertEquals(true, exif(iso = 100).hasDisplayableData) assertEquals(true, exif(make = "Leica").hasDisplayableData) } // MARK: - Pinned feeds @Test fun theDefaultPinsAreTheThreeGrainShipsWith() { assertEquals(listOf("recent", "following", "foryou"), PinnedFeed.defaults.map { it.id }) assertEquals("Recent", PinnedFeed.defaults.first().label) } @Test fun aPlainFeedNamesItselfByIdAndACompoundOneByType() { assertEquals("following", PinnedFeed.defaults[1].feedName) assertNull(PinnedFeed.defaults[1].feedValue) val location = PinnedFeed("location:8928308280fffff", "Mission", "location", "/l") assertEquals("location", location.feedName) assertEquals("8928308280fffff", location.feedValue) } }