Merge PR #500 into CI optimization

This commit is contained in:
Tianyi Cui
2026-07-22 18:31:28 +08:00
382 changed files with 33688 additions and 419 deletions

View File

@@ -0,0 +1,208 @@
// @vitest-environment jsdom
/**
* AppFrame interaction spec: drag sequences (pointer capture + rAF flush),
* concession response to viewport change, details stays mounted at zero
* width. jsdom has no layout engine, so the frame width comes from a mocked
* getBoundingClientRect and resizes are driven through the ResizeObserver
* stub; assertions read the inline grid template.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client'
import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/client'
/** Observer stub: captures the callback so tests can fire resizes manually. */
let fireResize: (() => void) | null = null
class ResizeObserverStub {
#cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
unobserve(): void {}
disconnect(): void { fireResize = null }
}
let frameWidth = 1920
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const sidebar = createSnapshotStore<PanelState>({ open: true, width: 300 })
const details = createSnapshotStore<PanelState>({ open: true, width: 360 })
const utils = render(
<AppFrame
useSidebar={sidebar.useSelector}
useDetails={details.useSelector}
setSidebarWidth={(px) => { sidebar.update((d) => { d.width = clampWidth(px, 240, 420) }) }}
setDetailsWidth={(px) => { details.update((d) => { d.width = clampWidth(px, 300, 520) }) }}
sidebar={<div data-testid="sidebar-content" />}
>
<CenterColumn><div data-testid="center-content" /></CenterColumn>
<DetailsColumn><div data-testid="details-content" /></DetailsColumn>
</AppFrame>,
)
const frame = utils.container.firstElementChild as HTMLElement
return { sidebar, details, frame, ...utils }
}
function tracks(frame: HTMLElement): number[] {
const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
return [Number(m[1]), Number(m[2])]
}
function drag(handle: Element, fromX: number, toX: number): void {
const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
act(() => { handle.dispatchEvent(down) })
act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
act(() => { handle.dispatchEvent(up) })
}
beforeEach(() => {
frameWidth = 1920
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
window.innerWidth = frameWidth
Element.prototype.getBoundingClientRect = function () {
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
}
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
const captured = new WeakSet<Element>()
Element.prototype.setPointerCapture = function () { captured.add(this) }
Element.prototype.releasePointerCapture = function () { captured.delete(this) }
Element.prototype.hasPointerCapture = function () { return captured.has(this) }
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('AppFrame', () => {
it('renders three tracks from panel state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([300, 360])
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[0]!, 300, 350)
expect(tracks(frame)[0]).toBe(350)
})
it('details drag widens leftward (negative dx grows the panel)', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 1560, 1500)
expect(tracks(frame)[1]).toBe(420)
})
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
const { frame, details } = mountFrame()
expect(tracks(frame)).toEqual([300, 310])
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
expect(details.getSnapshot().width).toBe(300)
})
it('details column stays mounted at zero width', () => {
const { frame, details, getByTestId } = mountFrame()
act(() => { details.update((d) => { d.open = false }) })
expect(tracks(frame)).toEqual([300, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
})
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 310])
frameWidth = 1920
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 360])
})
it('drag handles disappear for collapsed columns', () => {
const { frame, details, sidebar } = mountFrame()
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
act(() => { details.update((d) => { d.open = false }) })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { sidebar.update((d) => { d.open = false }) })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
})
})
describe('AppFrame — guard branches', () => {
it('pointer moves without capture are ignored (no width write)', () => {
const { frame, sidebar } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
const before = sidebar.getSnapshot().width
// Move + up without a preceding pointerdown: hasPointerCapture is false.
act(() => {
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
vi.advanceTimersByTime(20)
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
})
expect(sidebar.getSnapshot().width).toBe(before)
})
it('two moves inside one frame coalesce through the pending rAF', () => {
const { frame, sidebar } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
// Two moves before the frame flushes: the second must ride the pending
// rAF (frame.current ??= guard), and the flush sees the latest x.
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
vi.advanceTimersByTime(20)
})
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
expect(sidebar.getSnapshot().width).toBe(340)
})
it('pointerup with a pending rAF cancels it and commits the final position', () => {
const { frame, sidebar } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
// No timer advance: the rAF is still pending when pointerup arrives.
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
})
expect(sidebar.getSnapshot().width).toBe(360)
})
it('zero-width resize reports are ignored (display:none window)', () => {
const { frame } = mountFrame()
frameWidth = 0
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
// Track template still reflects the last non-zero viewport.
expect(tracks(frame)).toEqual([300, 360])
})
})
describe('AppFrame — unmount with an in-flight resize frame', () => {
it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
const { unmount } = mountFrame()
frameWidth = 800
act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
unmount()
// Flushing after unmount must be a no-op (the frame was cancelled).
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
})
it('double resize inside one frame rides the pending rAF (?"?= guard)', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 310])
})
})

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
// Client apply wiring: ctx.layout provided, the four layout-owned slots
// defined, teardown cascades (service unprovided + slot specs removed + list
// subscription dropped). Node half and the invariant companion ride along —
// they are one-line surfaces the aggregate coverage gate still requires
// exercised.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
ctx.provide('sessions', { list })
return { ctx, slots: ctx.get('slots') as SlotsService }
}
describe('ui-layout client apply', () => {
it('declares its service dependencies', () => {
expect(inject).toContain('slots')
})
it('provides ctx.layout and defines the four layout-owned slots', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session' })
expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
})
it('teardown unwinds service, slot specs, and the prune subscription', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
const layout = ctx.get('layout') as LayoutService
const disposeSpy = vi.spyOn(layout, 'dispose')
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()
expect(slots.spec('sidebar')).toBeUndefined()
expect(slots.spec('conversation.empty')).toBeUndefined()
expect(disposeSpy).toHaveBeenCalledTimes(1)
})
})
describe('node half + invariant companion', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('invariant companion registers under the package name', async () => {
const register = vi.fn().mockReturnValue(() => {})
const ctx = { invariants: { register } } as never
// The /invariant subpath types live in lib/types (build product); assert
// the surface so the call stays typed where lint runs without a build.
const dispose = await (invariant as { apply: (ctx: never) => Promise<() => void> }).apply(ctx)
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-client-ui-layout', expect.any(Function))
// The installer is the declared no-op — calling it must not throw.
expect(() => { (register.mock.calls[0]![1] as (c: never) => void)(undefined as never) }).not.toThrow()
expect(dispose).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
CENTER_MIN, clampWidth, computeColumns,
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/client'
const open = (width: number) => ({ open: true, width })
const closed = (width: number) => ({ open: false, width })
describe('clampWidth', () => {
it('clamps into the range and rounds', () => {
expect(clampWidth(250.4, 240, 420)).toBe(250)
expect(clampWidth(100, 240, 420)).toBe(240)
expect(clampWidth(9999, 240, 420)).toBe(420)
})
})
describe('computeColumns', () => {
it('step 1: everything fits at preferred widths', () => {
const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
})
it('closed panels contribute zero width', () => {
expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 })
})
it('preferences beyond the clamp range are clamped before solving', () => {
const cols = computeColumns(1920, open(9999), open(1))
expect(cols.sidebar).toBe(420)
expect(cols.details).toBe(300)
})
it('step 2: details shrinks first, center pinned at min', () => {
// 300 + 360 + 640 = 1300 > 1250; details concedes to 1250-300-640 = 310.
const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 310 })
})
it('boundary: exactly at the step-1/step-2 seam', () => {
const cols = computeColumns(300 + 360 + CENTER_MIN, open(300), open(360))
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 360 })
const one = computeColumns(300 + 360 + CENTER_MIN - 1, open(300), open(360))
expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 })
})
it('step 3: sidebar concedes after details hits its min', () => {
// details floor 300: sidebar = 1220-300-640 = 280.
const cols = computeColumns(1220, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: DETAILS_MIN })
})
it('step 4: details auto-closes when both panels are at min and center still starves', () => {
// 240 + 300 + 640 = 1180 > 1100 → details 0; sidebar preference (300) fits: 1100-300 = 800 center.
const cols = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 300, center: 800, details: 0 })
})
it('step 4 keeps squeezing sidebar when preference no longer fits', () => {
// 900 < 300+640: sidebar = max(240, 900-640) = 260.
const cols = computeColumns(900, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 260, center: CENTER_MIN, details: 0 })
})
it('step 5: center absorbs the deficit as last resort (details closed)', () => {
// 700 < 240+640: sidebar floors at 240, center takes 460 < CENTER_MIN.
const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: SIDEBAR_MIN, center: 460, details: 0 })
})
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN })
const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 })
})
it('tiny viewport: both panels yield everything to center', () => {
const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols.details).toBe(0)
expect(cols.sidebar).toBe(SIDEBAR_MIN)
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_MIN))
})
it('recovery is pure: re-widening restores preferred widths untouched', () => {
const squeezed = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(squeezed.details).toBe(0)
const restored = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(restored.details).toBe(DETAILS_DEFAULT)
expect(restored.sidebar).toBe(SIDEBAR_DEFAULT)
})
})
describe('computeColumns — degenerate viewports', () => {
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => {
// Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm).
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
.toEqual({ sidebar: 0, center: 500, details: 0 })
})
})

View File

@@ -0,0 +1,138 @@
// @vitest-environment jsdom
/**
* LayoutService over the real snapshot-store engine (persist rides jsdom
* localStorage). ctx is faked down to the one surface the service reads:
* ctx.sessions.list as a real store, so prune subscriptions are exercised
* for real.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { LayoutService, DETAILS_DEFAULT, SIDEBAR_DEFAULT } from '@deepseek-ai/dsh-client-ui-layout/client'
function makeCtx() {
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
// The service resolves sessions via ctx.get (typed merge suspended, see service).
const ctx = { get: (name: string) => (name === 'sessions' ? { list } : undefined) } as unknown as Context
return { ctx, list }
}
/** Test-side brand: specs mint ids the wire would normally brand. */
const sid = (s: string): SessionId => s as SessionId
const summary = (id: SessionId) => ({ id, title: id as string, running: false, updatedAt: 1 })
beforeEach(() => { localStorage.clear() })
describe('LayoutService', () => {
it('defaults: sidebar open 300, details closed 360, empty nav', () => {
const svc = new LayoutService(makeCtx().ctx)
expect(svc.sidebar.getSnapshot()).toEqual({ open: true, width: SIDEBAR_DEFAULT })
expect(svc.details.getSnapshot()).toEqual({ open: false, width: DETAILS_DEFAULT })
expect(svc.current.getSnapshot()).toEqual({ viewFor: {} })
svc.dispose()
})
it('open validates against sessions.list and selects', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
expect(() => { svc.open(sid('nope')) }).toThrow(/unknown session/)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
expect(svc.current.getSnapshot().sessionId).toBe('s1')
svc.dispose()
})
it('width setters clamp into contract ranges', () => {
const svc = new LayoutService(makeCtx().ctx)
svc.setSidebarWidth(10)
expect(svc.sidebar.getSnapshot().width).toBe(240)
svc.setSidebarWidth(10_000)
expect(svc.sidebar.getSnapshot().width).toBe(420)
svc.setDetailsWidth(10)
expect(svc.details.getSnapshot().width).toBe(300)
svc.setDetailsWidth(10_000)
expect(svc.details.getSnapshot().width).toBe(520)
svc.dispose()
})
it('toggle and open/close flip flags without touching widths', () => {
const svc = new LayoutService(makeCtx().ctx)
svc.toggleSidebar()
expect(svc.sidebar.getSnapshot()).toEqual({ open: false, width: SIDEBAR_DEFAULT })
svc.openDetails()
expect(svc.details.getSnapshot().open).toBe(true)
svc.closeDetails()
expect(svc.details.getSnapshot().open).toBe(false)
svc.dispose()
})
it('prune clears viewFor entries and the current selection of removed sessions', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => {
d.ids.push(sid('s1'), sid('s2'))
d.byId[sid('s1')] = summary(sid('s1'))
d.byId[sid('s2')] = summary(sid('s2'))
})
svc.open(sid('s1'))
svc.openView(sid('s1'), 'chat')
svc.openView(sid('s2'), 'chat')
list.update((d) => { d.ids = [sid('s2')]; d.byId = { [sid('s2')]: d.byId[sid('s2')]! } })
expect(svc.current.getSnapshot().sessionId).toBeUndefined()
expect(svc.current.getSnapshot().viewFor).toEqual({ s2: 'chat' })
svc.dispose()
})
it('prune leaves untouched state alone (no gratuitous store writes)', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
const before = svc.current.getSnapshot()
list.update((d) => { d.byId[sid('s1')] = { ...d.byId[sid('s1')]!, title: 'renamed' } })
expect(svc.current.getSnapshot()).toBe(before)
svc.dispose()
})
it('persists panel state and nav across instances (fresh service, same storage)', () => {
const first = new LayoutService(makeCtx().ctx)
first.setSidebarWidth(320)
first.openDetails()
first.dispose()
const second = new LayoutService(makeCtx().ctx)
expect(second.sidebar.getSnapshot().width).toBe(320)
expect(second.details.getSnapshot().open).toBe(true)
second.dispose()
})
it('dispose stops pruning', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
svc.dispose()
list.update((d) => { d.ids = []; d.byId = {} })
expect(svc.current.getSnapshot().sessionId).toBe('s1')
})
})
describe('LayoutService — construction and prune edge branches', () => {
it('throws loud when the sessions service is absent', () => {
const bare = { get: () => undefined } as unknown as Context
expect(() => new LayoutService(bare)).toThrow(/sessions service unavailable/)
})
it('prunes stale viewFor while the current selection stays valid', () => {
// Covers the prune branch where staleView holds but staleCurrent does not.
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1'), sid('s2')); d.byId[sid('s1')] = summary(sid('s1')); d.byId[sid('s2')] = summary(sid('s2')) })
svc.open(sid('s1'))
svc.openView(sid('s2'), 'chat')
list.update((d) => { d.ids = [sid('s1')]; d.byId = { [sid('s1')]: d.byId[sid('s1')]! } })
expect(svc.current.getSnapshot().sessionId).toBe('s1')
expect(svc.current.getSnapshot().viewFor).toEqual({})
svc.dispose()
})
})