feat(frontend): add submission performance distribution previews
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { Submission } from '../data/preview'
|
||||
import { findPerformancePreview } from '../data/performancePreview'
|
||||
import { summarizeDistribution } from '../lib/performance'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
import UiIcon from './UiIcon.vue'
|
||||
|
||||
const props = defineProps<{ submission: Submission }>()
|
||||
type Metric = 'time' | 'memory'
|
||||
const metric = ref<Metric>('time')
|
||||
const metrics = [
|
||||
{ key: 'time' as const, title: '运行时间', unit: 'ms', icon: 'clock' },
|
||||
{ key: 'memory' as const, title: '内存消耗', unit: 'MiB', icon: 'monitor' },
|
||||
]
|
||||
const preview = computed(() => findPerformancePreview(props.submission))
|
||||
const summaries = computed(() => ({
|
||||
time: summarizeDistribution(preview.value?.time ?? [], props.submission.time),
|
||||
memory: summarizeDistribution(
|
||||
preview.value?.memory.map((point) => ({ ...point, value: point.value / 1024 })) ?? [],
|
||||
props.submission.memory === null ? null : props.submission.memory / 1024,
|
||||
),
|
||||
}))
|
||||
const summary = computed(() => summaries.value[metric.value])
|
||||
const unit = computed(() => (metric.value === 'time' ? 'ms' : 'MiB'))
|
||||
const active = ref<number | null>(null)
|
||||
const selected = computed(() =>
|
||||
active.value === null ? undefined : summary.value.bins[active.value],
|
||||
)
|
||||
const chart = ref<HTMLDivElement>()
|
||||
const width = ref(640)
|
||||
let observer: ResizeObserver | undefined
|
||||
onMounted(() => {
|
||||
if (!chart.value) return
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
if (entry) width.value = Math.max(280, entry.contentRect.width)
|
||||
})
|
||||
observer.observe(chart.value)
|
||||
})
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
watch([metric, () => props.submission.id], () => (active.value = null))
|
||||
|
||||
const plotWidth = computed(() => width.value - 62)
|
||||
const binWidth = computed(() => plotWidth.value / summary.value.bins.length)
|
||||
const ceiling = computed(() =>
|
||||
Math.max(5, Math.ceil(Math.max(...summary.value.bins.map((bin) => bin.percent)) / 5) * 5),
|
||||
)
|
||||
const ticks = computed(() => Array.from({ length: 5 }, (_, index) => (ceiling.value / 4) * index))
|
||||
const xTicks = computed(() => {
|
||||
const count = width.value < 450 ? 3 : 5
|
||||
return Array.from({ length: count + 1 }, (_, index) => (summary.value.end / count) * index)
|
||||
})
|
||||
const x = (value: number) => 44 + (value / summary.value.end) * plotWidth.value
|
||||
const y = (percent: number) => 224 - (percent / ceiling.value) * 164
|
||||
function formatValue(value: number, kind?: Metric) {
|
||||
return Number((kind === 'memory' ? value / 1024 : value).toFixed(2)).toLocaleString('zh-CN')
|
||||
}
|
||||
function describeBin(index: number) {
|
||||
const bin = summary.value.bins[index]!
|
||||
const end = index === summary.value.bins.length - 1 ? '(含)' : '(不含)'
|
||||
return `${formatValue(bin.start)}–${formatValue(bin.end)} ${unit.value}${end}:${bin.count} 条,占 ${bin.percent.toFixed(1)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="performance-distribution" aria-label="答题表现分布">
|
||||
<header class="performance-heading">
|
||||
<div>
|
||||
<h2><UiIcon name="rankings" />答题表现分布</h2>
|
||||
<p>同题 · {{ submission.language }} · 通过记录</p>
|
||||
</div>
|
||||
<span class="performance-preview">示例统计</span>
|
||||
</header>
|
||||
<div class="performance-metrics" aria-label="选择分布指标">
|
||||
<button
|
||||
v-for="item in metrics"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:aria-pressed="metric === item.key"
|
||||
:class="{ selected: metric === item.key }"
|
||||
@click="metric = item.key"
|
||||
>
|
||||
<span class="metric-title"><UiIcon :name="item.icon" />{{ item.title }}</span>
|
||||
<span class="metric-value">
|
||||
{{ submission[item.key] === null ? '—' : formatValue(submission[item.key]!, item.key) }}
|
||||
<small v-if="submission[item.key] !== null">{{ item.unit }}</small>
|
||||
</span>
|
||||
<span v-if="preview && summaries[item.key].betterThan !== null" class="metric-percentile">
|
||||
优于 <strong>{{ summaries[item.key].betterThan!.toFixed(1) }}%</strong> 示例提交
|
||||
</span>
|
||||
<span v-else class="metric-unavailable">暂无可比较的统计</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="chart" class="performance-chart">
|
||||
<template v-if="preview && summary.total && summary.current !== null">
|
||||
<div class="distribution-legend">
|
||||
<span><i class="legend-bar" />通过记录占比</span>
|
||||
<span><i class="legend-marker" />当前提交</span>
|
||||
<span class="distribution-count">{{ summary.total }} 条示例记录</span>
|
||||
</div>
|
||||
<svg
|
||||
:viewBox="`0 0 ${width} 275`"
|
||||
role="group"
|
||||
:aria-label="`${metric === 'time' ? '运行时间' : '内存消耗'}分布,共 ${summary.total} 条示例通过记录;当前提交 ${formatValue(summary.current)} ${unit}`"
|
||||
>
|
||||
<g v-for="tick in ticks" :key="tick" aria-hidden="true">
|
||||
<line x1="44" :x2="width - 18" :y1="y(tick)" :y2="y(tick)" class="grid-line" />
|
||||
<text x="36" :y="y(tick) + 4" text-anchor="end">{{ Number(tick.toFixed(1)) }}%</text>
|
||||
</g>
|
||||
<g v-for="(bin, index) in summary.bins" :key="index">
|
||||
<rect
|
||||
:x="44 + index * binWidth + 1"
|
||||
:y="y(bin.percent)"
|
||||
:width="Math.max(1, binWidth - 3)"
|
||||
:height="224 - y(bin.percent)"
|
||||
rx="2"
|
||||
class="distribution-bar"
|
||||
:class="{ highlighted: active === index }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<rect
|
||||
:x="44 + index * binWidth"
|
||||
y="53"
|
||||
:width="binWidth"
|
||||
height="175"
|
||||
fill="transparent"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="describeBin(index)"
|
||||
class="distribution-hit"
|
||||
@pointerenter="active = index"
|
||||
@pointerleave="active = null"
|
||||
@focus="active = index"
|
||||
@blur="active = null"
|
||||
@click="active = index"
|
||||
>
|
||||
<title>{{ describeBin(index) }}</title>
|
||||
</rect>
|
||||
</g>
|
||||
<g v-for="tick in xTicks" :key="tick" aria-hidden="true">
|
||||
<text
|
||||
:x="x(tick)"
|
||||
y="250"
|
||||
:text-anchor="tick === 0 ? 'start' : tick === summary.end ? 'end' : 'middle'"
|
||||
>
|
||||
{{ formatValue(tick) }}
|
||||
</text>
|
||||
</g>
|
||||
<g class="current-marker" aria-hidden="true">
|
||||
<line :x1="x(summary.current)" :x2="x(summary.current)" y1="43" y2="224" />
|
||||
<circle :cx="x(summary.current)" cy="43" r="5" />
|
||||
<text
|
||||
:x="x(summary.current)"
|
||||
y="29"
|
||||
:text-anchor="
|
||||
x(summary.current) < 95
|
||||
? 'start'
|
||||
: x(summary.current) > width - 75
|
||||
? 'end'
|
||||
: 'middle'
|
||||
"
|
||||
>
|
||||
本次 {{ formatValue(summary.current) }} {{ unit }}
|
||||
</text>
|
||||
</g>
|
||||
<text :x="width - 18" y="272" text-anchor="end" aria-hidden="true">{{ unit }}</text>
|
||||
</svg>
|
||||
<p class="distribution-detail" aria-live="polite">
|
||||
{{
|
||||
selected && active !== null
|
||||
? describeBin(active)
|
||||
: '悬停或聚焦柱形,查看区间记录数与占比'
|
||||
}}
|
||||
</p>
|
||||
<p class="distribution-note">
|
||||
用量越低,表现越好。同用量记录不计入“优于”比例;本次有
|
||||
{{ summary.tied }} 条同用量示例记录。
|
||||
</p>
|
||||
</template>
|
||||
<EmptyState
|
||||
v-else
|
||||
:title="submission.verdict !== 'AC' ? '通过后可比较答题表现' : '暂无可比较的统计'"
|
||||
:description="
|
||||
submission.verdict !== 'AC'
|
||||
? '分布图只统计通过记录。'
|
||||
: '当前题目与语言的分布数据尚未接入。'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.performance-distribution {
|
||||
padding: 26px;
|
||||
border: 1px solid #e4ebf4;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.performance-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.performance-heading h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
color: #455e80;
|
||||
}
|
||||
.performance-heading h2 .el-icon {
|
||||
color: var(--blue);
|
||||
}
|
||||
.performance-heading p {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8490a3;
|
||||
}
|
||||
.performance-preview {
|
||||
background: #f0f6ff;
|
||||
color: #6195ce;
|
||||
border-radius: 5px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.performance-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.performance-metrics button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
border: 1px solid #e4ebf4;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
text-align: left;
|
||||
color: #687f9c;
|
||||
background: #fafcfe;
|
||||
}
|
||||
.performance-metrics button.selected {
|
||||
background: #eff7ff;
|
||||
border-color: #8bbdf1;
|
||||
}
|
||||
.performance-metrics button:hover {
|
||||
border-color: #8bbdf1;
|
||||
}
|
||||
.performance-metrics button:focus-visible {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.metric-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
color: #354f70;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.metric-value small {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8499b2;
|
||||
}
|
||||
.metric-percentile,
|
||||
.metric-unavailable {
|
||||
font-size: 11px;
|
||||
}
|
||||
.metric-percentile strong {
|
||||
color: #409eff;
|
||||
}
|
||||
.metric-unavailable {
|
||||
color: #98a8bb;
|
||||
}
|
||||
.performance-chart {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.distribution-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 11px;
|
||||
color: #8499b2;
|
||||
}
|
||||
.distribution-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.distribution-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
.legend-bar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #7ab6f0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.legend-marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #355d93;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.performance-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.performance-chart svg text {
|
||||
font-size: 11px;
|
||||
fill: #8d9eb3;
|
||||
}
|
||||
.grid-line {
|
||||
stroke: #eaf0f7;
|
||||
}
|
||||
.distribution-bar {
|
||||
fill: #7ab6f0;
|
||||
}
|
||||
.distribution-bar.highlighted {
|
||||
fill: #409eff;
|
||||
}
|
||||
.distribution-hit:focus {
|
||||
outline: none;
|
||||
stroke: #409eff;
|
||||
stroke-width: 1;
|
||||
}
|
||||
.current-marker {
|
||||
pointer-events: none;
|
||||
}
|
||||
.current-marker line {
|
||||
stroke: #355d93;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
.current-marker circle {
|
||||
fill: #355d93;
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.current-marker text {
|
||||
fill: #355d93 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.distribution-detail {
|
||||
min-height: 27px;
|
||||
color: #617d9f;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
background: #f5f8fc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.distribution-note {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: #8d9eb3;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.performance-distribution {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
.performance-metrics {
|
||||
gap: 10px;
|
||||
}
|
||||
.performance-metrics button {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
.distribution-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { DistributionPoint } from '../lib/performance'
|
||||
import type { Submission } from './preview'
|
||||
|
||||
interface PerformancePreview {
|
||||
problemId: string
|
||||
language: string
|
||||
time: DistributionPoint[]
|
||||
memory: DistributionPoint[]
|
||||
}
|
||||
|
||||
// Fixed display fixtures, independent of real judge results. Memory values use KiB.
|
||||
const previews: PerformancePreview[] = [
|
||||
{
|
||||
problemId: '1001',
|
||||
language: 'Rust',
|
||||
time: [
|
||||
{ value: 0, count: 8 },
|
||||
{ value: 4, count: 12 },
|
||||
{ value: 8, count: 25 },
|
||||
{ value: 12, count: 16 },
|
||||
{ value: 16, count: 34 },
|
||||
{ value: 20, count: 46 },
|
||||
{ value: 24, count: 29 },
|
||||
{ value: 28, count: 18 },
|
||||
{ value: 32, count: 13 },
|
||||
{ value: 40, count: 9 },
|
||||
{ value: 48, count: 6 },
|
||||
{ value: 64, count: 4 },
|
||||
],
|
||||
memory: [
|
||||
{ value: 1024, count: 7 },
|
||||
{ value: 1536, count: 19 },
|
||||
{ value: 2048, count: 58 },
|
||||
{ value: 2560, count: 41 },
|
||||
{ value: 3072, count: 34 },
|
||||
{ value: 3584, count: 22 },
|
||||
{ value: 4096, count: 15 },
|
||||
{ value: 5120, count: 13 },
|
||||
{ value: 6144, count: 7 },
|
||||
{ value: 8192, count: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
problemId: '1002',
|
||||
language: 'Python',
|
||||
time: [
|
||||
{ value: 24, count: 3 },
|
||||
{ value: 32, count: 8 },
|
||||
{ value: 40, count: 16 },
|
||||
{ value: 48, count: 28 },
|
||||
{ value: 56, count: 39 },
|
||||
{ value: 64, count: 31 },
|
||||
{ value: 72, count: 24 },
|
||||
{ value: 80, count: 18 },
|
||||
{ value: 96, count: 11 },
|
||||
{ value: 112, count: 6 },
|
||||
{ value: 128, count: 4 },
|
||||
],
|
||||
memory: [
|
||||
{ value: 5120, count: 6 },
|
||||
{ value: 6144, count: 18 },
|
||||
{ value: 7168, count: 46 },
|
||||
{ value: 8192, count: 53 },
|
||||
{ value: 9216, count: 31 },
|
||||
{ value: 10240, count: 19 },
|
||||
{ value: 12288, count: 9 },
|
||||
{ value: 14336, count: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
problemId: '1003',
|
||||
language: 'C++',
|
||||
time: [
|
||||
{ value: 16, count: 8 },
|
||||
{ value: 24, count: 16 },
|
||||
{ value: 34, count: 21 },
|
||||
{ value: 40, count: 32 },
|
||||
{ value: 48, count: 41 },
|
||||
{ value: 56, count: 28 },
|
||||
{ value: 64, count: 19 },
|
||||
{ value: 80, count: 12 },
|
||||
{ value: 96, count: 7 },
|
||||
{ value: 120, count: 4 },
|
||||
],
|
||||
memory: [
|
||||
{ value: 2048, count: 9 },
|
||||
{ value: 3072, count: 24 },
|
||||
{ value: 4096, count: 51 },
|
||||
{ value: 5120, count: 47 },
|
||||
{ value: 6144, count: 29 },
|
||||
{ value: 8192, count: 17 },
|
||||
{ value: 10240, count: 8 },
|
||||
{ value: 12288, count: 3 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function findPerformancePreview(submission: Submission) {
|
||||
if (submission.verdict !== 'AC') return undefined
|
||||
return previews.find(
|
||||
(preview) =>
|
||||
preview.problemId === submission.problemId && preview.language === submission.language,
|
||||
)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ export const verdictLabels: Record<Verdict, string> = {
|
||||
CE: 'Compile Error',
|
||||
Pending: '等待评测',
|
||||
}
|
||||
export const submissions: {
|
||||
export interface Submission {
|
||||
id: string
|
||||
problemId: string
|
||||
username: string
|
||||
@@ -273,7 +273,8 @@ export const submissions: {
|
||||
time: number | null
|
||||
memory: number | null
|
||||
created: string
|
||||
}[] = [
|
||||
}
|
||||
export const submissions: Submission[] = [
|
||||
{
|
||||
id: '10248',
|
||||
problemId: '1001',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface DistributionPoint {
|
||||
value: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export function summarizeDistribution(
|
||||
points: readonly DistributionPoint[],
|
||||
current: number | null,
|
||||
) {
|
||||
const valid = points.filter(
|
||||
({ value, count }) =>
|
||||
Number.isFinite(value) && value >= 0 && Number.isSafeInteger(count) && count > 0,
|
||||
)
|
||||
const total = valid.reduce((sum, point) => sum + point.count, 0)
|
||||
const measured = current !== null && Number.isFinite(current) && current >= 0
|
||||
const slower = measured
|
||||
? valid.reduce((sum, point) => sum + (point.value > current ? point.count : 0), 0)
|
||||
: 0
|
||||
const tied = measured
|
||||
? valid.reduce((sum, point) => sum + (point.value === current ? point.count : 0), 0)
|
||||
: 0
|
||||
const maximum = valid.reduce((max, point) => Math.max(max, point.value), 0)
|
||||
const extent = Math.max(maximum, measured ? current : 0, 1)
|
||||
const rawStep = extent / 24
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
|
||||
const step = [1, 2, 5, 10].find((size) => size * magnitude >= rawStep)! * magnitude
|
||||
const binCount = Math.max(1, Math.ceil(extent / step))
|
||||
const bins = Array.from({ length: binCount }, (_, index) => ({
|
||||
start: index * step,
|
||||
end: (index + 1) * step,
|
||||
count: 0,
|
||||
percent: 0,
|
||||
}))
|
||||
for (const point of valid) {
|
||||
// The last interval includes its upper endpoint; other intervals exclude it.
|
||||
bins[Math.min(binCount - 1, Math.floor(point.value / step))]!.count += point.count
|
||||
}
|
||||
for (const bin of bins) bin.percent = total ? (bin.count / total) * 100 : 0
|
||||
return {
|
||||
total,
|
||||
bins,
|
||||
end: binCount * step,
|
||||
betterThan: total && measured ? (slower / total) * 100 : null,
|
||||
tied,
|
||||
current: measured ? current : null,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import VerdictTag from '../components/VerdictTag.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import SubmissionPerformance from '../components/SubmissionPerformance.vue'
|
||||
const route = useRoute()
|
||||
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
|
||||
</script>
|
||||
@@ -48,6 +49,7 @@ const submission = computed(() => submissions.find((s) => s.id === route.params.
|
||||
><small>{{ submission.created }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<SubmissionPerformance :submission="submission" />
|
||||
<PanelCard title="评测详情" icon="submissions"
|
||||
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
|
||||
<EmptyState
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { summarizeDistribution } from '../src/lib/performance.ts'
|
||||
|
||||
test('zero usage is measured and ties do not count as better performance', () => {
|
||||
const summary = summarizeDistribution(
|
||||
[
|
||||
{ value: 0, count: 3 },
|
||||
{ value: 12, count: 2 },
|
||||
{ value: 24, count: 5 },
|
||||
],
|
||||
12,
|
||||
)
|
||||
assert.equal(summary.total, 10)
|
||||
assert.equal(summary.betterThan, 50)
|
||||
assert.equal(summary.tied, 2)
|
||||
assert.equal(summarizeDistribution([{ value: 0, count: 3 }], 0).betterThan, 0)
|
||||
})
|
||||
|
||||
test('bin boundaries and the final upper endpoint preserve every sample', () => {
|
||||
const summary = summarizeDistribution(
|
||||
[
|
||||
{ value: 0, count: 2 },
|
||||
{ value: 5, count: 3 },
|
||||
{ value: 100, count: 7 },
|
||||
],
|
||||
100,
|
||||
)
|
||||
assert.equal(summary.bins[0].count, 2)
|
||||
assert.equal(summary.bins[1].count, 3)
|
||||
assert.equal(summary.bins.at(-1)!.count, 7)
|
||||
assert.equal(
|
||||
summary.bins.reduce((sum, bin) => sum + bin.count, 0),
|
||||
summary.total,
|
||||
)
|
||||
assert.ok(Math.abs(summary.bins.reduce((sum, bin) => sum + bin.percent, 0) - 100) < 1e-9)
|
||||
})
|
||||
|
||||
test('missing measurements and invalid samples produce no percentile', () => {
|
||||
const summary = summarizeDistribution(
|
||||
[
|
||||
{ value: -1, count: 3 },
|
||||
{ value: NaN, count: 2 },
|
||||
{ value: Infinity, count: 1 },
|
||||
{ value: 10, count: -1 },
|
||||
{ value: 20, count: 1.5 },
|
||||
{ value: 24, count: 0 },
|
||||
],
|
||||
null,
|
||||
)
|
||||
assert.equal(summary.total, 0)
|
||||
assert.equal(summary.betterThan, null)
|
||||
assert.equal(summary.current, null)
|
||||
assert.ok(summary.bins.every((bin) => bin.percent === 0))
|
||||
assert.equal(summarizeDistribution([{ value: 12, count: 2 }], null).betterThan, null)
|
||||
assert.equal(summarizeDistribution([{ value: 12, count: 2 }], NaN).current, null)
|
||||
})
|
||||
|
||||
test('current measurements outside the cohort remain within the plot', () => {
|
||||
const summary = summarizeDistribution([{ value: 12, count: 2 }], 140)
|
||||
assert.ok(summary.end >= 140)
|
||||
assert.equal(summary.betterThan, 0)
|
||||
assert.equal(summary.total, 2)
|
||||
})
|
||||
Reference in New Issue
Block a user