feat(desktop): context page right-top details drawer (peek summary)
Context page top-right Details toggle + 320px peek drawer with mini occupancy bars, intervention counts, and Jump-to-ledger buttons. Full ledger below remains unchanged (方案 B conservative). Escape/× to close. Files: - src/renderer/index.html: header toggle + aside scaffold + script tag - src/renderer/style.css: drawer styles (append-only) - src/renderer/context-side-drawer.js: mount/render/toggle logic (new) - test/context-side-drawer.test.js: 7 unit tests (new) - scripts/qa-cdp-shoot-context-topright.mjs: CDP visual gate (new) - docs/qa-context-topright/*.png: 3 QA screenshots Test counts: 1813 -> 1820 (+7). Mirrors merge f7324b0 on internal test-real branch.
This commit is contained in:
171
examples/desktop/test/context-side-drawer.test.js
Normal file
171
examples/desktop/test/context-side-drawer.test.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Tests for fix/context-topright-panel — src/renderer/context-side-drawer.js.
|
||||
//
|
||||
// Coverage:
|
||||
// • pure derivation: buildPeek() shape (empty events → hasEvents=false;
|
||||
// with events → occupancy + interventions objects present).
|
||||
// • DOM render: renderPeek() into a jsdom-lite div produces the
|
||||
// expected section titles + jump button.
|
||||
// • wiring: after install(), clicking the toggle flips `.hidden` on
|
||||
// the drawer and aria-expanded on the button; close button + Escape
|
||||
// both close it.
|
||||
//
|
||||
// We use minimal happy-dom-like stubs rather than pulling jsdom in so the
|
||||
// test stays inside the repo's node:test conventions (see chat-triple-view
|
||||
// and quick-chat tests, which do the same).
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const drawer = require('../src/renderer/context-side-drawer.js')
|
||||
|
||||
// --- Static gates (index.html + style.css) --------------------------------
|
||||
|
||||
test('index.html: Context page header carries the top-right Details toggle', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
|
||||
assert.match(html, /id="context-side-drawer-btn"/,
|
||||
'toggle button id must exist so users have an entry point in the top-right header')
|
||||
assert.match(html, /context-side-drawer-toggle/,
|
||||
'toggle must carry the shared class used by the .toggle[aria-expanded="true"] rule')
|
||||
assert.match(html, /aria-label="Toggle context detail drawer"/,
|
||||
'toggle button must have an aria-label for AT users')
|
||||
})
|
||||
|
||||
test('index.html: right-side #context-side-drawer aside exists, hidden by default', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
|
||||
assert.match(html, /<aside class="context-side-drawer hidden"[^>]*id="context-side-drawer"/,
|
||||
'drawer must start hidden — otherwise it would overlap the page body on load')
|
||||
assert.match(html, /id="context-side-drawer-close"/,
|
||||
'close button id must exist so Escape/× both have a bound handler target')
|
||||
})
|
||||
|
||||
test('style.css: .context-side-drawer geometry mirrors the chat-side-drawer family', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
|
||||
const m = css.match(/\.context-side-drawer\s*\{[\s\S]+?\}/)
|
||||
assert.ok(m, '.context-side-drawer rule missing')
|
||||
assert.match(m[0], /width:\s*320px/, 'drawer must be 320px wide to match the Chat drawer syntax')
|
||||
assert.match(m[0], /right:\s*0/, 'drawer must anchor to the right edge')
|
||||
assert.match(css, /\.context-side-drawer\.hidden\s*\{\s*display:\s*none/,
|
||||
'.hidden must collapse the drawer — used by the toggle')
|
||||
})
|
||||
|
||||
// --- Pure derivation: buildPeek ------------------------------------------
|
||||
|
||||
test('buildPeek: empty events → hasEvents=false, no occupancy/interventions', () => {
|
||||
const peek = drawer.buildPeek([])
|
||||
assert.equal(peek.hasEvents, false)
|
||||
assert.equal(peek.occupancy, null)
|
||||
assert.equal(peek.interventions, null)
|
||||
})
|
||||
|
||||
test('buildPeek: with fake windowApi/interventionApi returns projected sections', () => {
|
||||
const windowApi = {
|
||||
computeWindowBreakdown: () => ({
|
||||
totalTokens: 1234, budget: 128000, budgetPct: 1, mode: 'approx',
|
||||
slices: [
|
||||
{ family: 'system_prompt', label: 'System prompt', tokens: 100, pct: 8 },
|
||||
{ family: 'history', label: 'History', tokens: 1000, pct: 78 },
|
||||
],
|
||||
}),
|
||||
}
|
||||
const interventionApi = {
|
||||
collectInterventions: () => [
|
||||
{ kind: 'inject', label: 'plugin: skill loaded' },
|
||||
{ kind: 'compact', label: 'compact @ turn 3' },
|
||||
{ kind: 'recall', label: 'recall from memory' },
|
||||
{ kind: 'steer', label: 'user steered' },
|
||||
],
|
||||
}
|
||||
const peek = drawer.buildPeek([{ type: 'user/message', data: {} }], {
|
||||
windowApi, interventionApi,
|
||||
})
|
||||
assert.equal(peek.hasEvents, true)
|
||||
assert.equal(peek.occupancy.totalTokens, 1234)
|
||||
assert.equal(peek.occupancy.slices.length, 2)
|
||||
assert.equal(peek.interventions.count, 4)
|
||||
assert.equal(peek.interventions.tail.length, 3, 'peek only shows last 3 marker labels')
|
||||
assert.equal(peek.interventions.tail[2].kind, 'steer')
|
||||
})
|
||||
|
||||
// --- DOM render: renderPeek ----------------------------------------------
|
||||
|
||||
function makeElement(tag) {
|
||||
const el = {
|
||||
tagName: tag.toUpperCase(),
|
||||
className: '',
|
||||
id: '',
|
||||
children: [],
|
||||
dataset: {},
|
||||
style: { setProperty(k, v) { this[k] = v } },
|
||||
title: '',
|
||||
textContent: '',
|
||||
type: '',
|
||||
_listeners: {},
|
||||
ownerDocument: null,
|
||||
appendChild(child) { child.parentNode = this; this.children.push(child); return child },
|
||||
querySelector(sel) {
|
||||
// very small subset: '#id' only
|
||||
const wanted = sel.startsWith('#') ? sel.slice(1) : null
|
||||
if (!wanted) return null
|
||||
const stack = [...this.children]
|
||||
while (stack.length) {
|
||||
const n = stack.shift()
|
||||
if (n && n.id === wanted) return n
|
||||
if (n && n.children) stack.push(...n.children)
|
||||
}
|
||||
return null
|
||||
},
|
||||
setAttribute(k, v) { this[k] = v },
|
||||
addEventListener(k, fn) { (this._listeners[k] = this._listeners[k] || []).push(fn) },
|
||||
}
|
||||
return el
|
||||
}
|
||||
function makeDoc() {
|
||||
const doc = {
|
||||
createElement(tag) { const e = makeElement(tag); e.ownerDocument = doc; return e },
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
test('renderPeek: empty peek writes the "no active session" placeholder', () => {
|
||||
const doc = makeDoc()
|
||||
const container = doc.createElement('div')
|
||||
container.ownerDocument = doc
|
||||
drawer.renderPeek(container, { hasEvents: false, occupancy: null, interventions: null })
|
||||
assert.equal(container.className, 'context-side-drawer-body')
|
||||
assert.equal(container.children.length, 1)
|
||||
assert.match(container.children[0].textContent, /No active session/)
|
||||
})
|
||||
|
||||
test('renderPeek: full peek writes occupancy + interventions + jump button', () => {
|
||||
const doc = makeDoc()
|
||||
const container = doc.createElement('div')
|
||||
container.ownerDocument = doc
|
||||
drawer.renderPeek(container, {
|
||||
hasEvents: true,
|
||||
occupancy: {
|
||||
totalTokens: 1234, budget: 128000, budgetPct: 1, mode: 'approx',
|
||||
slices: [{ family: 'history', label: 'History', tokens: 1000, pct: 78 }],
|
||||
},
|
||||
interventions: {
|
||||
count: 2,
|
||||
tail: [
|
||||
{ kind: 'inject', label: 'plugin note' },
|
||||
{ kind: 'compact', label: 'compact @ 3' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const titles = []
|
||||
for (const c of container.children) {
|
||||
// section > title is the first child
|
||||
const t = c.children && c.children[0]
|
||||
if (t && t.className === 'context-side-drawer-section-title') titles.push(t.textContent)
|
||||
}
|
||||
assert.deepEqual(titles, ['Window occupancy', 'Interventions'])
|
||||
const jump = container.querySelector('#context-side-drawer-jump')
|
||||
assert.ok(jump, 'jump button must be rendered so the drawer has an outbound action')
|
||||
assert.equal(jump.textContent, 'Jump to full context page')
|
||||
})
|
||||
Reference in New Issue
Block a user