fix(conversation): align TodoWrite task projection and disclosure lifecycle
This commit is contained in:
1 parent
8d10743fb2
commit
d423d59b08
9 files changed
+335
-66
No files matched your search
@@ -0,0 +1,97 @@
|
||||
/** B05 real React TodoWrite + CompletedFold DOM tests. Synthetic data only, no native UI claim. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import type { ConversationToolCall } from '../src/renderer/src/workbuddy/conversation/types'
|
||||
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
|
||||
const dom = new JSDOM('<html><body><main id="root"></main></body></html>', { pretendToBeVisual:true, url:'http://localhost/' })
|
||||
for (const key of ['window','document','Node','Element','HTMLElement','MouseEvent','KeyboardEvent','Event','CustomEvent']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
|
||||
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true})
|
||||
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)})
|
||||
const { act } = require('react') as typeof import('react')
|
||||
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
|
||||
const { CompletedFold } = require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
|
||||
const { ToolCallRenderer } = require('../src/renderer/src/workbuddy/conversation/ToolCallRenderer') as typeof import('../src/renderer/src/workbuddy/conversation/ToolCallRenderer')
|
||||
const { ThinkingBlock } = require('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') as typeof import('../src/renderer/src/workbuddy/conversation/SpecializedBlocks')
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
let checks = 0
|
||||
function equal(actual:unknown, expected:unknown, label:string) { assert.deepEqual(actual,expected,label);checks++ }
|
||||
const tasks = (count=7, active=4) => Array.from({length:count},(_,i)=>({id:String(i+1),content:`Task ${i+1}`,activeForm:`Working ${i+1}`,status:i===active?'in_progress':i<active?'completed':'pending'}))
|
||||
const makeTool = (id:string,last:boolean,args:Record<string,unknown>={todos:tasks()}): ConversationToolCall => ({id,name:'TodoWrite',status:'executed',isLastContent:last,args,result:'Todos have been modified successfully...',output:'Todos have been modified successfully...'})
|
||||
const calls: Array<{toolCallId:string;toolName:string;taskId:string|number}> = []
|
||||
interface Options { session?:string; finished?:boolean; last?:boolean; remount?:number; second?:boolean; callback?:boolean; active?:number; count?:number }
|
||||
function Fixture({session='session-a',finished=true,last=true,remount=0,second=false,callback=false,active=4,count=7}: Options) {
|
||||
const parts = [{type:'tool',toolId:'todo-1'},{type:'reasoning',id:'thought',text:'Saved thought'},{type:'tool',toolId:'ordinary'},{type:'tool',toolId:'question'},...(second?[{type:'tool',toolId:'todo-2'}]:[]),{type:'text',text:'Answer'}]
|
||||
return <CompletedFold foldId={`${session}:message`} parts={parts} turnFinished={finished}
|
||||
classifyToolCall={part=>part.toolId==='question'?{role:'inline-question'}:undefined}
|
||||
renderUnit={part=>part.type==='reasoning'?<ThinkingBlock text="Saved thought" isFinished />:part.type==='tool'?<div data-fixture-tool={part.toolId}>
|
||||
<ToolCallRenderer key={`${part.toolId}:${remount}`} sessionId={session}
|
||||
tool={part.toolId==='todo-1'?makeTool('todo-1',last,{todos:tasks(count,active)}):part.toolId==='todo-2'?makeTool('todo-2',true,{todos:tasks(6,1)}):part.toolId==='question'?{id:'question',name:'AskUserQuestion',status:'executed',metaData:{questionAnswer:{questions:[{question:'Choose?',answers:['Yes']}]}}}:{id:'ordinary',name:'UnknownOrdinaryTool',status:'executed',args:{value:1},result:'Ordinary result'}}
|
||||
onTodoTaskClick={callback?request=>calls.push(request):undefined} />
|
||||
</div>:<p>Answer</p>} />
|
||||
}
|
||||
const find=(selector:string)=>{const el=document.querySelector(selector);assert.ok(el,selector);return el}
|
||||
const todo='[data-fixture-tool="todo-1"] .cr-tool-head__main'
|
||||
const secondTodo='[data-fixture-tool="todo-2"] .cr-tool-head__main'
|
||||
const ordinary='[data-fixture-tool="ordinary"] .cr-tool-head__main'
|
||||
const question='[data-fixture-tool="question"] .cr-tool-head__main'
|
||||
const thought='.cr-reasoning > .cr-collapse__header'
|
||||
const outer='.cr-collapse--completed > .cr-collapse__header'
|
||||
const expanded=(selector:string)=>find(selector).getAttribute('aria-expanded')
|
||||
const rows=()=>Array.from(document.querySelectorAll('[data-fixture-tool="todo-1"] .cr-tool-plan-task__row')).map(el=>el.getAttribute('data-task-id'))
|
||||
async function render(options:Options={}) { await act(async()=>root.render(<Fixture {...options} />)) }
|
||||
async function click(selector:string) { await act(async()=>find(selector).dispatchEvent(new MouseEvent('click',{bubbles:true}))) }
|
||||
async function key(selector:string,value:string) { await act(async()=>find(selector).dispatchEvent(new KeyboardEvent('keydown',{key:value,bubbles:true,cancelable:true}))) }
|
||||
async function main() {
|
||||
await render()
|
||||
equal(expanded(outer),'false','finished outer starts closed')
|
||||
await click(outer)
|
||||
equal(expanded(todo),'true','last Todo initially open')
|
||||
equal(rows(),['3','4','5','6','7'],'real component displays five centered rows')
|
||||
equal(document.querySelector('[data-fixture-tool="todo-1"]')!.textContent!.includes('Todos have been modified successfully'),false,'SDK success hidden')
|
||||
for(const selector of [ordinary,question,thought]) {equal(expanded(selector),'false',`${selector} starts independent`); await click(selector)}
|
||||
await click(todo); equal(expanded(todo),'false','manual Todo close')
|
||||
await render(); equal(expanded(todo),'false','same isLastContent preserves manual choice')
|
||||
await click(outer); await click(outer)
|
||||
equal(expanded(todo),'false','outer collapse/reopen preserves manual close')
|
||||
for(const selector of [ordinary,question,thought]) equal(expanded(selector),'true','Todo state does not alter other disclosure')
|
||||
await render({remount:1}); equal(expanded(todo),'false','same-value inner remount preserves manual choice')
|
||||
await render({last:false,remount:1}); equal(expanded(todo),'false','true to false closes')
|
||||
await render({last:true,remount:1}); equal(expanded(todo),'true','false to true opens')
|
||||
await render({last:false,remount:2}); equal(expanded(todo),'false','real true-false edge concurrent with remount closes')
|
||||
await click(todo); equal(expanded(todo),'true','manual open while not last')
|
||||
await render({last:false,remount:3}); equal(expanded(todo),'true','same false remount does not overwrite manual open')
|
||||
await click(todo); equal(expanded(todo),'false','manual close before next real edge')
|
||||
await render({last:true,remount:4}); equal(expanded(todo),'true','false-true remount opens despite retained manual close')
|
||||
await render({session:'session-b',last:false}); equal(expanded(outer),'false','session identity resets completed outer')
|
||||
await click(outer); equal(expanded(todo),'false','same tool id in other session does not inherit')
|
||||
for(const selector of [ordinary,question,thought]) equal(expanded(selector),'false','other disclosures isolated per session')
|
||||
await render({session:'live-session',finished:false,last:true})
|
||||
equal(expanded(todo),'true','live Todo open')
|
||||
await render({session:'live-session',finished:true,last:false})
|
||||
equal(expanded(todo),'false','live-to-completed reparent plus actual edge closes')
|
||||
await click(outer); await click(todo)
|
||||
await render({session:'live-session',finished:true,last:false,second:true})
|
||||
equal(expanded(todo),'true','older manually opened snapshot stays open after later call')
|
||||
equal(expanded(secondTodo),'true','later call has own disclosure')
|
||||
equal(document.querySelectorAll('.cr-tool-head--todo-write').length,2,'distinct historical calls are not merged')
|
||||
equal(document.querySelectorAll('[data-fixture-tool="todo-2"] .cr-tool-plan-task__row').length,5,'second snapshot independently projected')
|
||||
await click(secondTodo); equal(expanded(todo),'true','closing later Todo cannot close older snapshot')
|
||||
await render({session:'live-session',finished:true,last:false,second:true,active:6})
|
||||
equal(rows(),['3','4','5','6','7'],'same call update refreshes window without data merge')
|
||||
assert.match(find('[data-fixture-tool="todo-1"]').textContent!,/Working 7/); checks++
|
||||
await render({session:'callbacks',callback:true})
|
||||
await click(outer)
|
||||
const row='[data-fixture-tool="todo-1"] [data-task-id="3"]'
|
||||
equal(find(row).getAttribute('role'),'button','host callback exposes button semantics')
|
||||
equal(find(row).getAttribute('tabindex'),'0','host callback exposes Tab target')
|
||||
await click(row); await key(row,'Enter'); await key(row,' ')
|
||||
equal(calls,[0,1,2].map(()=>({toolCallId:'todo-1',toolName:'TodoWrite',taskId:'3'})),'click and keyboard preserve exact event identity')
|
||||
equal(expanded(todo),'true','row activation does not toggle parent')
|
||||
await render({session:'callbacks',callback:false})
|
||||
equal(find(row).getAttribute('role'),null,'no fake role without actual host target')
|
||||
equal(find(row).getAttribute('tabindex'),null,'no fake keyboard navigation without target')
|
||||
await click(row); await key(row,'Enter'); equal(calls.length,3,'callback removed disables interaction')
|
||||
await act(async()=>root.unmount());dom.window.close()
|
||||
console.log(`todo-write-dom: ${checks} actual TodoWrite/CompletedFold checks passed; no Electron/pixel claim`)
|
||||
}
|
||||
main().then(()=>process.exit(0)).catch(error=>{console.error(error);dom.window.close();process.exit(1)})
|
||||
Reference in new issue
Block a user