@@ -2,14 +2,20 @@ import { describe, expect, test } from "bun:test"
22
33import {
44 MARK_PERIOD_SECONDS ,
5+ SNOW_CHAR ,
56 markFrame ,
67 markText ,
78 renderMark ,
89 smooth ,
910} from "./mark-anim"
10- import { MARK_COLS , MARK_COVERAGE , MARK_LARGE , MARK_ROWS } from "./mark-shape"
11+ import { MARK_COLS , MARK_LARGE , MARK_ROWS , MARK_SMALL } from "./mark-shape"
1112import { UI } from "./theme"
1213
14+ const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█"
15+
16+ const isMountain = ( char : string ) : boolean => MOUNTAIN_CHARS . includes ( char )
17+ const isSnow = ( char : string ) : boolean => char === SNOW_CHAR
18+
1319describe ( "smooth" , ( ) => {
1420 test ( "clamps outside [0, 1] and eases inside it" , ( ) => {
1521 expect ( smooth ( - 3 ) ) . toBe ( 0 )
@@ -62,20 +68,36 @@ describe("markFrame", () => {
6268} )
6369
6470describe ( "renderMark" , ( ) => {
65- const emptyCells = ( grid : readonly ( readonly { char : string } [ ] ) [ ] ) : number =>
66- grid . flat ( ) . filter ( ( cell ) => cell . char === " " ) . length
71+ /** Mountain-block weight only — snow must not pollute silhouette metrics. */
72+ const mountainWeight = (
73+ grid : readonly ( readonly { char : string } [ ] ) [ ] ,
74+ ) : number =>
75+ grid
76+ . flat ( )
77+ . reduce ( ( sum , cell ) => sum + Math . max ( 0 , MOUNTAIN_CHARS . indexOf ( cell . char ) + 1 ) , 0 )
78+
79+ const mountainCells = (
80+ grid : readonly ( readonly { char : string } [ ] ) [ ] ,
81+ ) : number => grid . flat ( ) . filter ( ( cell ) => isMountain ( cell . char ) ) . length
6782
6883 test ( "is the mark's cell dimensions" , ( ) => {
6984 const grid = renderMark ( { nowMs : 0 , still : true } )
7085 expect ( grid ) . toHaveLength ( MARK_ROWS )
7186 for ( const row of grid ) expect ( row ) . toHaveLength ( MARK_COLS )
7287 } )
7388
74- test ( "never paints outside the silhouette " , ( ) => {
75- const grid = renderMark ( { nowMs : 2000 , still : false } )
89+ test ( "sky is empty or snow; mountain cells never hold snow " , ( ) => {
90+ const grid = renderMark ( { nowMs : 2000 , still : false , grid : MARK_LARGE } )
7691 grid . forEach ( ( row , y ) => {
7792 row . forEach ( ( cell , x ) => {
78- if ( ( MARK_COVERAGE [ y ] ?. [ x ] ?? 0 ) === 0 ) expect ( cell . char ) . toBe ( " " )
93+ const coverage = MARK_LARGE . coverage [ y ] ?. [ x ] ?? 0
94+ if ( coverage === 0 ) {
95+ expect ( cell . char === " " || isSnow ( cell . char ) ) . toBe ( true )
96+ if ( isSnow ( cell . char ) ) expect ( cell . fg ) . toBe ( UI . textFaint )
97+ } else if ( isMountain ( cell . char ) ) {
98+ expect ( cell . fg ) . toBe ( UI . action )
99+ expect ( isSnow ( cell . char ) ) . toBe ( false )
100+ }
79101 } )
80102 } )
81103 } )
@@ -84,17 +106,19 @@ describe("renderMark", () => {
84106 const grid = renderMark ( { nowMs : 0 , still : true , grid : MARK_LARGE } )
85107 grid . forEach ( ( row , y ) => {
86108 row . forEach ( ( cell , x ) => {
87- expect ( " ▁▂▃▄▅▆▇█" ) . toContain ( cell . char )
109+ // Still mode has no snow — only space or mountain blocks.
110+ expect ( ` ${ MOUNTAIN_CHARS } ` ) . toContain ( cell . char )
88111 if ( ( MARK_LARGE . coverage [ y ] ?. [ x ] ?? 0 ) === 1 ) expect ( cell . char ) . toBe ( "█" )
89112 } )
90113 } )
91114 } )
92115
93- test ( "the still frame is clock-independent" , ( ) => {
116+ test ( "the still frame is clock-independent and has no snow " , ( ) => {
94117 const a = markText ( renderMark ( { nowMs : 0 , still : true } ) )
95118 const b = markText ( renderMark ( { nowMs : 987_654 , still : true } ) )
96119 expect ( b ) . toBe ( a )
97120 expect ( a . replace ( / [ \s \n ] / g, "" ) . length ) . toBeGreaterThan ( 0 )
121+ expect ( a . includes ( SNOW_CHAR ) ) . toBe ( false )
98122 } )
99123
100124 test ( "the animated frame advances with the injected clock" , ( ) => {
@@ -105,14 +129,14 @@ describe("renderMark", () => {
105129 } )
106130
107131 test ( "the outline reveals left to right" , ( ) => {
108- // Early in the draw phase only the leftmost columns may be lit.
132+ // Early in the draw phase only the leftmost mountain columns may be lit.
109133 const grid = renderMark ( { nowMs : 0.06 * MARK_PERIOD_SECONDS * 1000 , still : false } )
110134 const lit = grid . flatMap ( ( row ) =>
111- row . flatMap ( ( cell , col ) => ( cell . char === " " ? [ ] : [ col ] ) ) ,
135+ row . flatMap ( ( cell , col ) => ( isMountain ( cell . char ) ? [ col ] : [ ] ) ) ,
112136 )
113137 expect ( Math . max ( ...lit , - 1 ) ) . toBeLessThan ( MARK_COLS )
114138 const full = renderMark ( { nowMs : 0.4 * MARK_PERIOD_SECONDS * 1000 , still : false } )
115- expect ( emptyCells ( grid ) ) . toBeGreaterThan ( emptyCells ( full ) )
139+ expect ( mountainCells ( full ) ) . toBeGreaterThan ( mountainCells ( grid ) )
116140 } )
117141
118142 test ( "the fade thins the mark out toward empty" , ( ) => {
@@ -121,12 +145,10 @@ describe("renderMark", () => {
121145 nowMs : 0.995 * MARK_PERIOD_SECONDS * 1000 ,
122146 still : false ,
123147 } )
124- expect ( emptyCells ( fading ) ) . toBeGreaterThan ( emptyCells ( held ) )
148+ expect ( mountainCells ( held ) ) . toBeGreaterThan ( mountainCells ( fading ) )
125149 } )
126150
127151 test ( "filling makes the mark denser than its outline alone" , ( ) => {
128- const weight = ( grid : readonly ( readonly { char : string } [ ] ) [ ] ) : number =>
129- grid . flat ( ) . reduce ( ( sum , cell ) => sum + " ▁▂▃▄▅▆▇█" . indexOf ( cell . char ) , 0 )
130152 const outlineOnly = renderMark ( {
131153 nowMs : 0.42 * MARK_PERIOD_SECONDS * 1000 ,
132154 still : false ,
@@ -135,6 +157,56 @@ describe("renderMark", () => {
135157 nowMs : 0.8 * MARK_PERIOD_SECONDS * 1000 ,
136158 still : false ,
137159 } )
138- expect ( weight ( filled ) ) . toBeGreaterThan ( weight ( outlineOnly ) )
160+ expect ( mountainWeight ( filled ) ) . toBeGreaterThan ( mountainWeight ( outlineOnly ) )
161+ } )
162+
163+ test ( "snow drifts over time without overwriting the silhouette" , ( ) => {
164+ // Sample across several seconds so flakes advance even at a slow fall rate.
165+ const times = [ 0 , 1500 , 3000 , 4500 , 6000 , 7500 ]
166+ const snowSets = times . map ( ( nowMs ) => {
167+ const grid = renderMark ( { nowMs, still : false , grid : MARK_LARGE } )
168+ const snow : string [ ] = [ ]
169+ grid . forEach ( ( row , y ) => {
170+ row . forEach ( ( cell , x ) => {
171+ if ( isSnow ( cell . char ) ) {
172+ snow . push ( `${ y } ,${ x } ` )
173+ // Flakes live only in sky cells — never on mountain coverage.
174+ expect ( MARK_LARGE . coverage [ y ] ?. [ x ] ?? 0 ) . toBe ( 0 )
175+ }
176+ } )
177+ } )
178+ return snow . join ( "|" )
179+ } )
180+
181+ const withSnow = snowSets . filter ( ( s ) => s . length > 0 )
182+ expect ( withSnow . length ) . toBeGreaterThan ( 1 )
183+ expect ( new Set ( withSnow ) . size ) . toBeGreaterThan ( 1 )
184+
185+ // During the full-hold phase the ridgeline dominates the flake field.
186+ const held = renderMark ( {
187+ nowMs : 0.82 * MARK_PERIOD_SECONDS * 1000 ,
188+ still : false ,
189+ grid : MARK_LARGE ,
190+ } )
191+ let flakes = 0
192+ let mountains = 0
193+ held . forEach ( ( row , y ) => {
194+ row . forEach ( ( cell , x ) => {
195+ if ( isSnow ( cell . char ) ) {
196+ flakes += 1
197+ expect ( MARK_LARGE . coverage [ y ] ?. [ x ] ?? 0 ) . toBe ( 0 )
198+ }
199+ if ( isMountain ( cell . char ) ) mountains += 1
200+ } )
201+ } )
202+ expect ( mountains ) . toBeGreaterThan ( 20 )
203+ expect ( mountains ) . toBeGreaterThan ( flakes )
204+ } )
205+
206+ test ( "still mode freezes the mark with no snow motion" , ( ) => {
207+ const a = renderMark ( { nowMs : 0 , still : true , grid : MARK_SMALL } )
208+ const b = renderMark ( { nowMs : 50_000 , still : true , grid : MARK_SMALL } )
209+ expect ( markText ( b ) ) . toBe ( markText ( a ) )
210+ expect ( a . flat ( ) . some ( ( cell ) => isSnow ( cell . char ) ) ) . toBe ( false )
139211 } )
140212} )
0 commit comments