@@ -200,3 +200,70 @@ describe("⑥ 原值快照属于**第一个**持锁者(弹层交接时不许
200200 expect ( overflow ( ) , "快照被后来者改写 ⇒ 宿主页的 'auto' 永久丢失" ) . toBe ( "auto" ) ;
201201 } ) ;
202202} ) ;
203+
204+ /* ── T17 / C10#14 追加段:解锁恢复滚动位置(**纯追加**:既有 6 个用例与既有 import 一行未动)──────
205+ * 判据形态(C15「只能登记」边界):jsdom **不做布局** ⇒「恢复后用户看见的位置对不对」**不可验**;
206+ * 本段只钉两件**可观测**的事,**不作**"已在浏览器验证滚动恢复"的声称:① 两条**纯函数**的契约;
207+ * ② **接线**:加锁时读宿主、解锁写回**快照**(不是当前值),且快照归**第一个**持锁者。
208+ * 宿主两条腿都覆盖(探针实测:jsdom 30 **没有** `document.scrollingElement` ⇒ 天然只走兜底腿):
209+ * 主腿 = 注入的假宿主(`Object.defineProperty`,仓库既有注入先例);兜底腿 = jsdom 的真 `body`
210+ * (它**会存** `scrollTop`:探针实测写 251 读回 251 —— 是属性存储,**不是**布局)。
211+ */
212+ import { restoreScroll , saveScroll } from "./Modal" ; // 独立成行:既有那行 import 保持逐字原样
213+
214+ /** 假滚动宿主:`scrollTop` 是唯一契约面;`reads` 计读、`writes` 记写、`jump` = 外部挪动(不计写) */
215+ function fakeHost ( initial : number ) : { host : { scrollTop : number } ; reads : ( ) => number ; writes : ( ) => number [ ] ; jump : ( v : number ) => void } {
216+ let value = initial ;
217+ let reads = 0 ;
218+ const writes : number [ ] = [ ] ;
219+ const host = {
220+ get scrollTop ( ) : number { reads += 1 ; return value ; } ,
221+ set scrollTop ( v : number ) { writes . push ( v ) ; value = v ; } ,
222+ } ;
223+ return { host, reads : ( ) => reads , writes : ( ) => writes , jump : ( v ) => { value = v ; } } ;
224+ }
225+
226+ describe ( "⑦ 解锁时恢复滚动位置(T17 / C10#14;规格 §7.3 第 2 条同族)" , ( ) => {
227+ afterEach ( ( ) => {
228+ delete ( document as unknown as { scrollingElement ?: unknown } ) . scrollingElement ;
229+ document . body . scrollTop = 0 ;
230+ } ) ;
231+
232+ it ( "纯函数契约:saveScroll 读宿主并原样返回;restoreScroll 写宿主(含 0 与覆盖)" , ( ) => {
233+ const { host, reads, writes } = fakeHost ( 250 ) ;
234+ expect ( saveScroll ( host ) , "保存 = 原样读回宿主的 scrollTop" ) . toBe ( 250 ) ;
235+ expect ( reads ( ) , "saveScroll 必须真的读了宿主(否则它可以返回任何常量)" ) . toBeGreaterThan ( 0 ) ;
236+ restoreScroll ( host , 120 ) ;
237+ expect ( host . scrollTop , "计划逐字的样例:restoreScroll(fakeHost, 120) ⇒ 120" ) . toBe ( 120 ) ;
238+ restoreScroll ( host , 0 ) ;
239+ expect ( host . scrollTop , "恢复 0 也是合法写(宿主本来就可能在顶部)" ) . toBe ( 0 ) ;
240+ expect ( writes ( ) , "两次恢复各写一次,写的都是传进来的值" ) . toEqual ( [ 120 , 0 ] ) ;
241+ } ) ;
242+
243+ it ( "接线主腿:加锁时读 scrollingElement;解锁写回**快照**(期间宿主被挪动 ⇒ 仍回快照)" , ( ) => {
244+ vi . useFakeTimers ( ) ;
245+ const { host, reads, writes, jump } = fakeHost ( 250 ) ;
246+ Object . defineProperty ( document , "scrollingElement" , { configurable : true , get : ( ) => host } ) ;
247+ const { rerender } = render ( < Host open /> ) ;
248+ tick ( 0 ) ;
249+ expect ( reads ( ) , "前提:加锁时确实读了这个宿主(主腿优先于兜底腿)" ) . toBeGreaterThan ( 0 ) ;
250+ jump ( 999 ) ; // 弹层期间宿主被别的东西挪动过
251+ closeAndSettle ( rerender , < Host open = { false } /> ) ;
252+ expect ( writes ( ) , "解锁时必须写回**打开前的快照**,不是当前值" ) . toContain ( 250 ) ;
253+ expect ( host . scrollTop , "恢复后宿主回到打开前的位置" ) . toBe ( 250 ) ;
254+ } ) ;
255+
256+ it ( "接线兜底腿 + 快照归属:无 scrollingElement ⇒ 写 document.body;关内层不恢复、关外层才恢复" , ( ) => {
257+ vi . useFakeTimers ( ) ;
258+ document . body . scrollTop = 300 ; // 打开前"页面已滚了 300"
259+ const { rerender } = render ( < Nested outer = { false } inner = { false } /> ) ;
260+ tick ( 0 ) ;
261+ rerender ( < Nested outer inner /> ) ;
262+ tick ( 0 ) ;
263+ document . body . scrollTop = 888 ; // 期间被挪动
264+ closeAndSettle ( rerender , < Nested outer inner = { false } /> , "inner" ) ;
265+ expect ( document . body . scrollTop , "内层关闭时外层还在 ⇒ 不许恢复(快照归第一个持锁者)" ) . toBe ( 888 ) ;
266+ closeAndSettle ( rerender , < Nested outer = { false } inner = { false } /> , "outer" ) ;
267+ expect ( document . body . scrollTop , "最后一层关闭必须写回打开前的快照" ) . toBe ( 300 ) ;
268+ } ) ;
269+ } ) ;
0 commit comments