1313 * 与 `data-degraded` 这类**结构事实**;真实播放 / seek 在本环境不可验证(不声称)。
1414 * 副作用:只挂 React 树;不写盘、不发请求、不真播放。
1515 */
16+ import { useState } from "react" ;
17+ import type { ReactElement } from "react" ;
1618import { cleanup , fireEvent , render } from "@testing-library/react" ;
1719import { afterEach , describe , expect , it , vi } from "vitest" ;
1820import type { SessionAudioState , SessionDetail } from "../../types/session" ;
@@ -54,6 +56,7 @@ interface Over {
5456 readonly playheadMs ?: number | null ;
5557 readonly detail ?: SessionDetail ;
5658}
59+ /** 展示形态:`playheadMs` 是**静态注入值**(视图受控 ⇒ 点击不会自己动它) */
5760function mountView ( over : Over = { } ) {
5861 const onSeekMs = vi . fn ( ) ;
5962 const view = render (
@@ -66,6 +69,35 @@ function mountView(over: Over = {}) {
6669 ) ;
6770 return { ...view , onSeekMs } ;
6871}
72+
73+ /**
74+ * 受控夹具:**逐字镜像容器**(`SessionDetailPanel`)的回路 —— 点时间码 ⇒ 上报 ⇒ `setState`
75+ * ⇒ 下一帧 `playheadMs` 回来。视图自身零状态 ⇒ 没有这个回路就没有位移(判据 I3/I4 的因)。
76+ */
77+ function Controlled ( { detail, audio, onSeek } : { readonly detail : SessionDetail ; readonly audio : SessionAudioState | null | undefined ; readonly onSeek : ( ms : number ) => void } ) : ReactElement {
78+ const [ ms , setMs ] = useState < number | null > ( null ) ;
79+ return (
80+ < SessionTriTrackView
81+ detail = { detail }
82+ audio = { audio }
83+ playheadMs = { ms }
84+ onSeekMs = { ( next ) => {
85+ onSeek ( next ) ;
86+ setMs ( next ) ;
87+ } }
88+ />
89+ ) ;
90+ }
91+ function mountControlled ( over : Over = { } ) {
92+ const onSeekMs = vi . fn ( ) ;
93+ const view = render ( < Controlled detail = { over . detail ?? detailOf ( ) } audio = { "audio" in over ? over . audio : OK } onSeek = { onSeekMs } /> ) ;
94+ return { ...view , onSeekMs } ;
95+ }
96+ /** 未接线形态:**完全不传** `onSeekMs`(宿主没接上上行口时的实际形态) */
97+ function mountUnwired ( over : Over = { } ) {
98+ const view = render ( < SessionTriTrackView detail = { over . detail ?? detailOf ( ) } audio = { "audio" in over ? over . audio : OK } playheadMs = { over . playheadMs ?? null } /> ) ;
99+ return view ;
100+ }
69101function q ( container : HTMLElement , sel : string ) : HTMLElement {
70102 const el = container . querySelector ( sel ) ;
71103 if ( el === null ) throw new Error ( `没有渲染出 ${ sel } ` ) ;
@@ -115,9 +147,9 @@ describe("I2 总长真源 `totalMsOf`:三数组取大,且有 `durationMs`
115147 } ) ;
116148} ) ;
117149
118- describe ( "I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**且播放头滑到该段 " , ( ) => {
119- it ( "点 9000ms 的时间码 ⇒ onSeekMs(9000) 恰 1 次 + 播放头 data-ms=9000;点另一段 ⇒ 逐字跟随" , ( ) => {
120- const { container, onSeekMs } = mountView ( { audio : NO_AUDIO } ) ;
150+ describe ( "I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**,回传后播放头滑到该段 " , ( ) => {
151+ it ( "受控回路:点 9000 ⇒ onSeekMs(9000) 恰 1 次 + 播放头 data-ms=9000;再点 3725000 ⇒ 逐字跟随" , ( ) => {
152+ const { container, onSeekMs } = mountControlled ( { audio : NO_AUDIO } ) ;
121153 fireEvent . click ( codeAt ( container , 9_000 ) ) ;
122154 expect ( onSeekMs . mock . calls . length , "点击没有(或重复)上报" ) . toBe ( 1 ) ;
123155 expect ( onSeekMs . mock . calls [ 0 ] [ 0 ] ) . toBe ( 9_000 ) ;
@@ -126,19 +158,25 @@ describe("I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**且播
126158 expect ( container . querySelector ( PLAYHEAD ) ?. getAttribute ( "data-ms" ) ) . toBe ( "3725000" ) ;
127159 // 反例守卫:不点 ⇒ 无播放头(位置不是常量、也不是「第一段」)
128160 cleanup ( ) ;
129- const fresh = mountView ( { audio : NO_AUDIO } ) ;
161+ const fresh = mountControlled ( { audio : NO_AUDIO } ) ;
130162 expect ( fresh . container . querySelector ( PLAYHEAD ) ) . toBeNull ( ) ;
131163 expect ( fresh . onSeekMs . mock . calls . length ) . toBe ( 0 ) ;
132164 } ) ;
133165} ) ;
134166
135- describe ( "I4 注入 `playheadMs` 优先于本地聚焦(T26 深链的先后次序 )" , ( ) => {
136- it ( "注入 52500 后点 9000 的时间码 ⇒ 上行仍收到 9000,但播放头停在注入值 " , ( ) => {
167+ describe ( "I4 受控语义:视图**不自持**播放头状态(单一真源在容器 —— T24 的槽位契约 )" , ( ) => {
168+ it ( "静态注入 52500:点 9000 的时间码只上报、播放头**不动**;未接线(不传 onSeekMs)⇒ 点击不产生播放头 " , ( ) => {
137169 const { container, onSeekMs } = mountView ( { audio : OK , playheadMs : 52_500 } ) ;
138170 expect ( q ( container , PLAYHEAD ) . dataset . ms ) . toBe ( "52500" ) ;
139171 fireEvent . click ( codeAt ( container , 9_000 ) ) ;
140- expect ( onSeekMs . mock . calls [ 0 ] [ 0 ] ) . toBe ( 9_000 ) ;
141- expect ( q ( container , PLAYHEAD ) . dataset . ms , "本地点击把注入的播放头覆盖掉了(深链会被旧聚焦吞掉)" ) . toBe ( "52500" ) ;
172+ expect ( onSeekMs . mock . calls [ 0 ] [ 0 ] , "点击没有上报" ) . toBe ( 9_000 ) ;
173+ expect ( q ( container , PLAYHEAD ) . dataset . ms , "视图自持了第二份播放头状态(受控契约被破坏)" ) . toBe ( "52500" ) ;
174+ // 未接线:宿主没给出上行口 ⇒ 点击不产生任何位移(也不抛错)
175+ cleanup ( ) ;
176+ const unwired = mountUnwired ( { audio : NO_AUDIO } ) ;
177+ fireEvent . click ( codeAt ( unwired . container , 9_000 ) ) ;
178+ expect ( unwired . container . querySelector ( PLAYHEAD ) , "视图自己动起来了(第二份真源)" ) . toBeNull ( ) ;
179+ expect ( unwired . container . querySelector ( RAIL ) , "未接线把时间轨也带走了" ) . not . toBeNull ( ) ;
142180 } ) ;
143181} ) ;
144182
@@ -149,13 +187,13 @@ describe("I5 音频槽在视图侧的落点与降级可用性(无音频 ⇒
149187 expect ( withAudio . container . querySelectorAll ( '[data-kind="error"]' ) ) . toHaveLength ( 0 ) ;
150188 cleanup ( ) ;
151189
152- const noAudio = mountView ( { audio : NO_AUDIO } ) ;
190+ const noAudio = mountControlled ( { audio : NO_AUDIO } ) ;
153191 const root = q ( noAudio . container , RAIL ) ;
154192 expect ( root . getAttribute ( "data-degraded" ) , "无音频没有进降级态" ) . toBe ( "true" ) ;
155193 expect ( noAudio . container . querySelector ( '[data-testid="session-timerail-viewport"]' ) , "降级把时间轨带走了" ) . not . toBeNull ( ) ;
156194 expect ( noAudio . container . querySelectorAll ( '[data-kind="error"]' ) ) . toHaveLength ( 1 ) ;
157195 expect ( noAudio . container . querySelector ( "audio" ) , "无音频却渲染了媒体元素" ) . toBeNull ( ) ;
158- // 降级下「时间轨仍可用」的可判部分:点时间码照旧驱动播放头
196+ // 降级下「时间轨仍可用」的可判部分:点时间码照旧驱动播放头(受控回路)
159197 fireEvent . click ( codeAt ( noAudio . container , 9_000 ) ) ;
160198 expect ( q ( noAudio . container , PLAYHEAD ) . dataset . ms ) . toBe ( "9000" ) ;
161199 } ) ;
0 commit comments