feat(heatmap): 年份选择器新增最近365天窗口(默认)
- RECENT_YEAR(-1) sentinel:最近365天(今天往前364天~今天),默认加载窗口 - computeAllViews 支持窗口模式:每日/每周/累计/统计/分位按窗口计算 - 下拉置顶最近365天+ 分隔线 + 选中标记,与具体年份区分 - 浮层日期跨年自动带年份前缀(formatWeekRangeText 兼容 RECENT) - setYear 竞态 guard 改 latestRequestedYear(最后点击胜出);RECENT 缓存跨天自动失效重算 - HeatmapAllViewsTest 4 用例,全量 130 用例通过
This commit is contained in:
parent
a0cf7fd31c
commit
4ed6aaa3cc
@ -165,9 +165,11 @@ internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int, loc
|
||||
* 将周数据格式化为范围文本:"12月28日-12月31日"(不含"使用了X token",
|
||||
* 句子由调用方用 stringResource 按语言拼接)。
|
||||
*
|
||||
* - 末周只显示到该周落在所选年内的有效日期([dataEndTs],数据最后一天),
|
||||
* - 末周只显示到该周落在所选窗口内的有效日期([dataEndTs],数据最后一天),
|
||||
* 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟
|
||||
* - 首列跨年(start 在上一年)时,end 与 start 不同年 → end 强制带年份前缀,避免范围倒挂
|
||||
* - [RECENT_YEAR](最近 365 天窗口)时按周自身年份判断:start 不带前缀,
|
||||
* end 仅在与 start 跨年时带前缀(窗口可能跨年,如 "1月5日-2026年1月3日")
|
||||
*/
|
||||
internal fun formatWeekRangeText(
|
||||
week: HeatmapWeekData,
|
||||
@ -177,20 +179,22 @@ internal fun formatWeekRangeText(
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val start = formatDateChinese(week.weekStartTs, useUtc8, selectedYear, locale)
|
||||
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
|
||||
// 最近模式按周自身年份判断前缀;年份模式用所选年作上下文(与既有行为一致)
|
||||
val startContext = if (selectedYear == RECENT_YEAR) startDate.year else selectedYear
|
||||
val start = formatDateChinese(week.weekStartTs, useUtc8, startContext, locale)
|
||||
val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs ?: (week.weekStartTs + 6L * 86_400_000L))
|
||||
if (locale.language == "zh") {
|
||||
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
|
||||
val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate()
|
||||
val end = if (endDate.year != startDate.year) {
|
||||
"${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日"
|
||||
} else {
|
||||
formatDateChinese(endTs, useUtc8, selectedYear, locale)
|
||||
formatDateChinese(endTs, useUtc8, startContext, locale)
|
||||
}
|
||||
return "$start-$end"
|
||||
}
|
||||
// 非中文:formatDateChinese 已按 yearContext 决定是否带年份
|
||||
return "$start-${formatDateChinese(endTs, useUtc8, selectedYear, locale)}"
|
||||
// 非中文:end 用 start 的年份作上下文(同 startDate.year 时无前缀;跨年时带年份)
|
||||
return "$start-${formatDateChinese(endTs, useUtc8, startContext, locale)}"
|
||||
}
|
||||
|
||||
// ── 辅助类型与函数 ──────────────────────────────────────────
|
||||
|
||||
@ -26,12 +26,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -64,6 +66,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@ -174,25 +177,63 @@ fun HeatmapScreen(
|
||||
Text(stringResource(R.string.heatmap_title))
|
||||
if (state.availableYears.isNotEmpty()) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
// 年份下拉选择器
|
||||
// 年份下拉选择器:默认"最近 365 天"(置顶),下方为具体年份
|
||||
val selectRangeLabel = if (state.selectedYear == RECENT_YEAR) {
|
||||
stringResource(R.string.heatmap_select_recent)
|
||||
} else {
|
||||
"${state.selectedYear}"
|
||||
}
|
||||
Text(
|
||||
text = "${state.selectedYear} ▾",
|
||||
text = "$selectRangeLabel ▾",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { yearMenuExpanded = true }
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
.semantics { contentDescription = "$selectYearDesc: ${state.selectedYear}" }
|
||||
.semantics { contentDescription = "$selectYearDesc: $selectRangeLabel" }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = yearMenuExpanded,
|
||||
onDismissRequest = { yearMenuExpanded = false }
|
||||
) {
|
||||
// "最近 365 天"(默认选项,与具体年份区分:置顶 + 分隔线)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.heatmap_select_recent)) },
|
||||
trailingIcon = {
|
||||
if (state.selectedYear == RECENT_YEAR) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp).semantics { selected = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
yearMenuExpanded = false
|
||||
viewModel.setYear(RECENT_YEAR)
|
||||
// 切换范围时关闭浮层
|
||||
selectedDay = null
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
state.availableYears.sortedDescending().forEach { year ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("$year") },
|
||||
trailingIcon = {
|
||||
if (state.selectedYear == year) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp).semantics { selected = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
yearMenuExpanded = false
|
||||
viewModel.setYear(year)
|
||||
|
||||
@ -28,6 +28,9 @@ import javax.inject.Provider
|
||||
/** 热力图视图模式 */
|
||||
enum class HeatmapViewMode { DAILY, WEEKLY, CUMULATIVE }
|
||||
|
||||
/** 年份选择器中的"最近"窗口(最近 365 天)sentinel:selectedYear == RECENT_YEAR 表示最近模式 */
|
||||
internal const val RECENT_YEAR = -1
|
||||
|
||||
/** 每日 / 累计视图的单格数据 */
|
||||
data class HeatmapDayData(
|
||||
val dayTs: Long, // UTC 日期时间戳(即天分桶的时间戳)
|
||||
@ -67,7 +70,7 @@ data class HeatmapInsights(
|
||||
data class HeatmapUiState(
|
||||
val loading: Boolean = true,
|
||||
val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY,
|
||||
val selectedYear: Int = 0, // 当前选中年份(0=尚未加载)
|
||||
val selectedYear: Int = 0, // 当前选中范围:0=尚未加载,RECENT_YEAR(-1)=最近365天,>0=具体年份
|
||||
val currentYear: Int = 0, // 今年(浮层日期判断是否显示年份用)
|
||||
val availableYears: List<Int> = emptyList(), // 可选年份(最早数据年份..今年,降序展示)
|
||||
val dailyData: List<HeatmapDayData> = emptyList(),
|
||||
@ -192,15 +195,24 @@ class HeatmapViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(viewMode = mode, colorLevels = levels?.toIntArray() ?: it.colorLevels) }
|
||||
}
|
||||
|
||||
/** 切换年份:用缓存的记录按所选自然年重新聚合三视图 */
|
||||
/** 切换窗口范围(年份或最近365天):用缓存的记录按所选范围重新聚合三视图 */
|
||||
fun setYear(year: Int) {
|
||||
if (year == _uiState.value.selectedYear || records.isEmpty()) return
|
||||
// guard 用 latestRequestedYear(最后一次请求意图)而非已提交的 selectedYear:
|
||||
// 避免"在途切换未提交时重复点击导致最后一次意图被丢弃"的竞态
|
||||
if (year == latestRequestedYear || records.isEmpty()) return
|
||||
latestRequestedYear = year
|
||||
viewModelScope.launch {
|
||||
val result = yearResults[year] ?: withContext(Dispatchers.Default) {
|
||||
val cached = yearResults[year]
|
||||
// 最近 365 天窗口以计算时刻的 todayTs 为锚:页面跨天常驻时缓存已过期(窗口末日在昨天),需重算
|
||||
val stale = cached != null && year == RECENT_YEAR && cached.dailyData.lastOrNull()?.dayTs != todayTsOf()
|
||||
val result = if (cached == null || stale) {
|
||||
withContext(Dispatchers.Default) {
|
||||
computeAllViews(records, useUtc8, year).also { yearResults[year] = it }
|
||||
}
|
||||
// 期间用户又切了别的年份:丢弃过期结果,避免旧协程覆盖新选择
|
||||
} else {
|
||||
cached
|
||||
}
|
||||
// 期间用户又切了别的范围:丢弃过期结果,避免旧协程覆盖新选择
|
||||
if (year != latestRequestedYear) return@launch
|
||||
val mode = _uiState.value.viewMode
|
||||
val levels = when (mode) {
|
||||
@ -221,6 +233,12 @@ class HeatmapViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前时区下的今天自然日零点(缓存新鲜度校验用) */
|
||||
private fun todayTsOf(): Long {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
return startOfDayTs(System.currentTimeMillis(), zone)
|
||||
}
|
||||
|
||||
// ── 内部逻辑 ────────────────────────────────────────────
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
@ -246,14 +264,14 @@ class HeatmapViewModel @Inject constructor(
|
||||
all to computeAvailableYears(all, zone, currentYear)
|
||||
}
|
||||
records = fetched
|
||||
latestRequestedYear = currentYear
|
||||
latestRequestedYear = RECENT_YEAR
|
||||
|
||||
// 活动洞察基于全量历史记录(不随年份变化),在后台线程计算(万级记录仅数 ms)
|
||||
val insights = withContext(Dispatchers.Default) { computeInsights(fetched, useUtc8) }
|
||||
|
||||
// 所有重操作放在 Dispatchers.Default 上
|
||||
// 所有重操作放在 Dispatchers.Default 上;默认窗口 = 最近 365 天(RECENT_YEAR)
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it }
|
||||
computeAllViews(fetched, useUtc8, RECENT_YEAR).also { yearResults[RECENT_YEAR] = it }
|
||||
}
|
||||
|
||||
// 重操作完成后按当前视图模式写入对应的分位阈值(加载期间可能已切换视图)
|
||||
@ -266,7 +284,7 @@ class HeatmapViewModel @Inject constructor(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
selectedYear = currentYear,
|
||||
selectedYear = RECENT_YEAR,
|
||||
currentYear = currentYear,
|
||||
availableYears = availableYears,
|
||||
useUtc8 = useUtc8,
|
||||
@ -296,17 +314,19 @@ class HeatmapViewModel @Inject constructor(
|
||||
private const val DAY_MS = 86_400_000L
|
||||
|
||||
/**
|
||||
* 按指定自然年从全部记录计算三种视图数据 + 颜色等级阈值。
|
||||
* 按指定窗口从全部记录计算三种视图数据 + 颜色等级阈值。
|
||||
*
|
||||
* - 今年:范围 = 1月1日 ~ 今天(滚动 365 天窗口被年份选择器取代)
|
||||
* - 往年:范围 = 1月1日 ~ 12月31日(完整自然年)
|
||||
* - 周视图:周日对齐,第一列为该年 1月1日 所在周(可能含去年 12 月空位),
|
||||
* 最后一列为结束日所在周(今年=今天所在周;往年可能跨到次年 1 月初),
|
||||
* 列数按年份浮动(约 52~54 列),不丢数据
|
||||
* - 三种视图的分位数均按所选年份独立计算(颜色随年份数据变化是预期行为)
|
||||
* 窗口类型由 [year] 决定:
|
||||
* - [RECENT_YEAR](-1):最近 365 天(今天往前 364 天 ~ 今天,含今天共 365 天),默认窗口
|
||||
* - 今年(year == 当前自然年):1月1日 ~ 今天
|
||||
* - 往年:1月1日 ~ 12月31日(完整自然年)
|
||||
* - 周视图:周日对齐,第一列为窗口首日所在周(可能含窗口前空位),
|
||||
* 最后一列为结束日所在周(最近/今年=今天所在周;往年可能跨到次年 1 月初),
|
||||
* 列数按窗口浮动(约 52~54 列),不丢数据
|
||||
* - 三种视图的分位数均按所选窗口独立计算(颜色随窗口数据变化是预期行为)
|
||||
*
|
||||
* @param useUtc8 true=按 UTC+8 零点分桶(与全局图表设置一致),false=按 UTC 零点分桶
|
||||
* @param year 目标自然年
|
||||
* @param year 目标自然年,或 [RECENT_YEAR] 表示最近 365 天
|
||||
*/
|
||||
internal fun computeAllViews(records: List<UsageRecord>, useUtc8: Boolean = false, year: Int): AllViewsResult {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
@ -314,31 +334,36 @@ class HeatmapViewModel @Inject constructor(
|
||||
val todayTs = startOfDayTs(now, zone)
|
||||
val currentYear = Instant.ofEpochMilli(now).atOffset(zone).year
|
||||
val isCurrentYear = year == currentYear
|
||||
val isRecent = year == RECENT_YEAR
|
||||
|
||||
// 1. 年份边界(该时区下的自然年)
|
||||
val yearStartTs = Instant.parse("${year}-01-01T00:00:00Z")
|
||||
// 1. 窗口边界(该时区下的自然日零点)
|
||||
val windowStartTs = if (isRecent) {
|
||||
todayTs - 364 * DAY_MS
|
||||
} else {
|
||||
Instant.parse("${year}-01-01T00:00:00Z")
|
||||
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val yearEndTs = if (isCurrentYear) {
|
||||
}
|
||||
val windowEndTs = if (isRecent || isCurrentYear) {
|
||||
todayTs
|
||||
} else {
|
||||
Instant.parse("${year}-12-31T00:00:00Z")
|
||||
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val totalDays = ((yearEndTs - yearStartTs) / DAY_MS).toInt() + 1
|
||||
val totalDays = ((windowEndTs - windowStartTs) / DAY_MS).toInt() + 1
|
||||
|
||||
// 2. 按天分桶聚合 token 数(按所选时区的自然日零点切日)
|
||||
val dayTokenMap = HashMap<Long, Long>()
|
||||
for (r in records) {
|
||||
val dayTs = startOfDayTs(r.timeCreated, zone)
|
||||
if (dayTs < yearStartTs || dayTs > yearEndTs) continue
|
||||
if (dayTs < windowStartTs || dayTs > windowEndTs) continue
|
||||
val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens
|
||||
dayTokenMap.merge(dayTs, tokens, Long::plus)
|
||||
}
|
||||
|
||||
// 3. 构建完整的每日数据列表(1月1日 ~ 结束日)
|
||||
// 3. 构建完整的每日数据列表(窗口首日 ~ 结束日)
|
||||
val dailyRaw = ArrayList<HeatmapDayData>(totalDays)
|
||||
for (i in 0 until totalDays) {
|
||||
val ts = yearStartTs + i * DAY_MS
|
||||
val ts = windowStartTs + i * DAY_MS
|
||||
val tokens = dayTokenMap[ts] ?: 0L
|
||||
dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0))
|
||||
}
|
||||
@ -347,12 +372,12 @@ class HeatmapViewModel @Inject constructor(
|
||||
val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens })
|
||||
val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) }
|
||||
|
||||
// 5. 每周视图:周日对齐,第一列=1月1日所在周,最后一列=结束日所在周
|
||||
val firstWeekStart = startOfWeekTs(yearStartTs, zone)
|
||||
val lastWeekStart = startOfWeekTs(yearEndTs, zone)
|
||||
// 5. 每周视图:周日对齐,第一列=窗口首日所在周,最后一列=结束日所在周
|
||||
val firstWeekStart = startOfWeekTs(windowStartTs, zone)
|
||||
val lastWeekStart = startOfWeekTs(windowEndTs, zone)
|
||||
val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw, firstWeekStart, lastWeekStart)
|
||||
|
||||
// 6. 累计视图:从 1月1日 到结束日的累计总和
|
||||
// 6. 累计视图:从窗口首日到结束日的累计总和
|
||||
var cumulativeSum = 0L
|
||||
val cumulativeRaw = ArrayList<HeatmapDayData>(totalDays)
|
||||
for (d in dailyRaw) {
|
||||
|
||||
@ -507,6 +507,7 @@
|
||||
<string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string>
|
||||
<string name="heatmap_accessibility_desc_weekly">Token 活动热力图,%1$d 周数据</string>
|
||||
<string name="heatmap_select_year">选择年份</string>
|
||||
<string name="heatmap_select_recent">最近 365 天</string>
|
||||
<string name="heatmap_stats_total">累计 token</string>
|
||||
<string name="heatmap_stats_peak">峰值 token</string>
|
||||
<string name="heatmap_stats_current_streak">当前连续</string>
|
||||
|
||||
@ -507,6 +507,7 @@
|
||||
<string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string>
|
||||
<string name="heatmap_accessibility_desc_weekly">Token 活動熱力圖,%1$d 週資料</string>
|
||||
<string name="heatmap_select_year">選擇年份</string>
|
||||
<string name="heatmap_select_recent">最近 365 天</string>
|
||||
<string name="heatmap_stats_total">累計 Token</string>
|
||||
<string name="heatmap_stats_peak">峰值 Token</string>
|
||||
<string name="heatmap_stats_current_streak">當前連續</string>
|
||||
|
||||
@ -510,6 +510,7 @@
|
||||
<string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string>
|
||||
<string name="heatmap_accessibility_desc_weekly">Token activity heatmap, %1$d weeks</string>
|
||||
<string name="heatmap_select_year">Select year</string>
|
||||
<string name="heatmap_select_recent">Recent 365 days</string>
|
||||
<string name="heatmap_stats_total">Total tokens</string>
|
||||
<string name="heatmap_stats_peak">Peak tokens</string>
|
||||
<string name="heatmap_stats_current_streak">Current streak</string>
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
package com.rainy.token.ui.heatmap
|
||||
|
||||
import com.rainy.token.data.local.UsageRecord
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [HeatmapViewModel.Companion.computeAllViews](最近 365 天窗口与年份模式)。
|
||||
*
|
||||
* All tests are pure JVM — no Android framework, no network.
|
||||
*/
|
||||
class HeatmapAllViewsTest {
|
||||
|
||||
private fun rec(ts: Long, tokens: Long = 100L): UsageRecord = UsageRecord(
|
||||
id = "r$ts",
|
||||
workspaceId = "w",
|
||||
timeCreated = ts,
|
||||
timeUpdated = ts,
|
||||
model = "m",
|
||||
provider = "p",
|
||||
inputTokens = tokens,
|
||||
outputTokens = 0,
|
||||
reasoningTokens = 0,
|
||||
cacheReadTokens = 0,
|
||||
cost = 0,
|
||||
keyId = "k",
|
||||
sessionId = "s",
|
||||
)
|
||||
|
||||
private fun dayTsOf(ts: Long, useUtc8: Boolean): Long {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
private fun startOfWeekTs(ts: Long): Long {
|
||||
val zone = ZoneOffset.UTC
|
||||
val dow = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value % 7 // Sunday=7 → 0
|
||||
return ts - dow * 86_400_000L
|
||||
}
|
||||
|
||||
// ── 最近 365 天窗口 ──
|
||||
|
||||
@Test
|
||||
fun `recent window covers 365 days ending today`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val today = dayTsOf(now, useUtc8 = false)
|
||||
// 窗口内:今天、昨天;窗口外:366 天前(不应计入)
|
||||
val records = listOf(
|
||||
rec(today, 10),
|
||||
rec(today - 86_400_000L, 20),
|
||||
rec(today - 366L * 86_400_000L, 999),
|
||||
)
|
||||
val result = HeatmapViewModel.computeAllViews(records, useUtc8 = false, year = RECENT_YEAR)
|
||||
|
||||
assertEquals(365, result.dailyData.size)
|
||||
assertEquals(today - 364L * 86_400_000L, result.dailyData.first().dayTs)
|
||||
assertEquals(today, result.dailyData.last().dayTs)
|
||||
// 窗口外记录不计入:总 token = 10 + 20
|
||||
assertEquals(30L, result.dailyData.sumOf { it.tokens })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent cumulative ends at window total`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val today = dayTsOf(now, useUtc8 = false)
|
||||
val records = listOf(
|
||||
rec(today, 10),
|
||||
rec(today - 86_400_000L, 20),
|
||||
)
|
||||
val result = HeatmapViewModel.computeAllViews(records, useUtc8 = false, year = RECENT_YEAR)
|
||||
|
||||
assertEquals(30L, result.cumulativeData.last().tokens)
|
||||
assertEquals(365, result.cumulativeData.size)
|
||||
// 累计视图首日 = 窗口首日(自身 token,非 0)
|
||||
assertEquals(0L, result.cumulativeData.first().tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent week view aligned to window weeks`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val today = dayTsOf(now, useUtc8 = false)
|
||||
val firstWeekStart = startOfWeekTs(today - 364L * 86_400_000L)
|
||||
val lastWeekStart = startOfWeekTs(today)
|
||||
val expectedWeeks = ((lastWeekStart - firstWeekStart) / 86_400_000L).toInt() / 7 + 1
|
||||
|
||||
val result = HeatmapViewModel.computeAllViews(emptyList(), useUtc8 = false, year = RECENT_YEAR)
|
||||
|
||||
assertEquals(expectedWeeks, result.weeklyData.size)
|
||||
assertEquals(firstWeekStart, result.weeklyData.first().weekStartTs)
|
||||
assertEquals(lastWeekStart, result.weeklyData.last().weekStartTs)
|
||||
}
|
||||
|
||||
// ── 年份模式回归 ──
|
||||
|
||||
@Test
|
||||
fun `year mode unaffected by recent window`() {
|
||||
val rec2024 = rec(Instant.parse("2024-06-15T00:00:00Z").toEpochMilli(), 5)
|
||||
val rec2025 = rec(Instant.parse("2025-06-15T00:00:00Z").toEpochMilli(), 7)
|
||||
|
||||
val result = HeatmapViewModel.computeAllViews(listOf(rec2024, rec2025), useUtc8 = false, year = 2024)
|
||||
|
||||
assertEquals(366, result.dailyData.size) // 2024 闰年完整自然年
|
||||
assertEquals(5L, result.dailyData.sumOf { it.tokens }) // 2025 年记录不计入
|
||||
assertEquals(5L, result.cumulativeData.last().tokens)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user