@@ -54,6 +54,11 @@ beforeEach(() => {
5454 case "create_topic_group" : return { id : 99 , name : args ?. name ?? "新组" , terrain : "container" , kind : "topic" , domainTag : args ?. domainTag ?? "beauty" , source : "manual" , seriesKey : null , routeReason : null , routeOverridden : 0 , noteCount : 0 , createdAt : 0 , updatedAt : 0 } ;
5555 case "update_group_color" : return true ;
5656 case "rename_note_group" : return true ;
57+ // REQ-315(批 6):组排序/置顶命令(默认空序行/成功)
58+ case "note_group_order_list" : return [ ] ;
59+ case "update_note_group_pin" : return true ;
60+ case "note_group_order_save" : return null ;
61+ case "note_group_order_clear" : return true ;
5762 case "get_group_delete_impact" :
5863 return { notes : 1 , fragments : 0 , cards : 0 , settlements : 0 , contracts : 0 , systemRefs : 0 } ;
5964 case "delete_note_group" : return true ;
@@ -356,3 +361,137 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => { beforeEach(() => loca
356361 expect ( onGroupFilterChange ) . not . toHaveBeenCalled ( ) ;
357362 } ) ;
358363} ) ;
364+
365+ describe ( "GroupSidebar REQ-315 排序/置顶" , ( ) => {
366+ beforeEach ( ( ) => localStorage . clear ( ) ) ;
367+ const courseA : NoteGroup = {
368+ id : 1 , name : "课程 A" , terrain : "container" , kind : "course" , domainTag : null ,
369+ source : "route" , seriesKey : null , routeReason : null , routeOverridden : 0 ,
370+ noteCount : 1 , createdAt : 0 , updatedAt : 10 ,
371+ } ;
372+ const courseC : NoteGroup = {
373+ id : 3 , name : "课程 C" , terrain : "container" , kind : "course" , domainTag : null ,
374+ source : "route" , seriesKey : null , routeReason : null , routeOverridden : 0 ,
375+ noteCount : 1 , createdAt : 0 , updatedAt : 20 ,
376+ } ;
377+ const renderWith = ( list : NoteGroup [ ] , rows : [ number , number ] [ ] = [ ] ) => {
378+ invokeMock . mockImplementation ( async ( cmd : string ) => {
379+ switch ( cmd ) {
380+ case "list_note_groups" : return list ;
381+ case "list_fragments" : return [ ] ;
382+ case "get_feature_flags" : return { feedCapture : true } ;
383+ case "note_group_order_list" : return rows ;
384+ case "list_knowledge_systems" : return [ ] ;
385+ case "list_knowledge_links" : return [ ] ;
386+ case "week_contract_status" : return { contract : null , weekStart : 0 , actualDays : 0 , actualCards : 0 , minimalDayMet : false } ;
387+ case "update_note_group_pin" : return true ;
388+ case "note_group_order_save" : return null ;
389+ case "note_group_order_clear" : return true ;
390+ case "list_group_cards" : return [ ] ;
391+ default : return null ;
392+ }
393+ } ) ;
394+ const onChanged = vi . fn ( ) ;
395+ render (
396+ < GroupSidebar
397+ groupFilter = { null }
398+ onGroupFilterChange = { vi . fn ( ) }
399+ onChanged = { onChanged }
400+ onOpenReview = { vi . fn ( ) }
401+ selectedNoteId = { null }
402+ onOpenInbox = { vi . fn ( ) }
403+ inboxActive = { false }
404+ refreshToken = { 0 }
405+ onOpenSystem = { vi . fn ( ) }
406+ /> ,
407+ ) ;
408+ return onChanged ;
409+ } ;
410+
411+ it ( "分区行按 置顶(updatedAt 降序)→手排 seq→自动 渲染" , async ( ) => {
412+ const pinned = { ...courseC , pin : 1 , id : 3 } ;
413+ const old = { ...courseA , id : 1 } ;
414+ const fresh = { ...courseA , id : 2 , name : "课程 B" , updatedAt : 5 } ;
415+ renderWith ( [ old , fresh , pinned ] , [ [ 1 , 0 ] ] ) ; // 手排区只有课程 A(seq0)
416+ await screen . findByTestId ( "group-row-3" ) ;
417+ const rows = Array . from ( screen . getByTestId ( "group-section-course" ) . querySelectorAll ( '[data-testid^="group-row-"]' ) ) ;
418+ // 置顶课程C → 手排课程A → 自动区课程B(其余按 updatedAt 降序)
419+ expect ( rows . map ( ( r ) => r . getAttribute ( "data-testid" ) ) ) . toEqual ( [ "group-row-3" , "group-row-1" , "group-row-2" ] ) ;
420+ // 置顶标记
421+ expect ( screen . getByTestId ( "group-pin-3" ) ) . toBeTruthy ( ) ;
422+ } ) ;
423+
424+ it ( "右键组行 = 操作菜单(替代直开弹层);「打开信息」保留 ⓘ 入口" , async ( ) => {
425+ renderWith ( [ courseA , courseC ] ) ;
426+ await screen . findByTestId ( "group-row-1" ) ;
427+ fireEvent . contextMenu ( screen . getByTestId ( "group-row-3" ) , { clientX : 120 , clientY : 130 } ) ;
428+ const menu = await screen . findByTestId ( "group-row-menu" ) ;
429+ expect ( menu ) . toBeTruthy ( ) ;
430+ expect ( screen . getByTestId ( "ctx-group-pin" ) . textContent ) . toContain ( "置顶" ) ;
431+ expect ( screen . queryByTestId ( "route-popover" ) ) . toBeNull ( ) ; // 右键不再直开弹层
432+ fireEvent . click ( screen . getByTestId ( "ctx-group-info" ) ) ;
433+ expect ( await screen . findByTestId ( "route-popover" ) ) . toBeTruthy ( ) ;
434+ } ) ;
435+
436+ it ( "菜单置顶 → update_note_group_pin;置顶行菜单切「取消置顶」且移动禁用" , async ( ) => {
437+ const onChanged = renderWith ( [ { ...courseA , pin : 1 } ] ) ;
438+ await screen . findByTestId ( "group-row-1" ) ;
439+ fireEvent . contextMenu ( screen . getByTestId ( "group-row-1" ) , { clientX : 10 , clientY : 10 } ) ;
440+ const menu = await screen . findByTestId ( "group-row-menu" ) ;
441+ expect ( menu ) . toBeTruthy ( ) ;
442+ expect ( screen . getByTestId ( "ctx-group-pin" ) . textContent ) . toContain ( "取消置顶" ) ;
443+ // 置顶区按更新时间定序——上移/下移禁用
444+ expect ( ( screen . getByTestId ( "ctx-group-up" ) as HTMLButtonElement ) . disabled ) . toBe ( true ) ;
445+ expect ( ( screen . getByTestId ( "ctx-group-down" ) as HTMLButtonElement ) . disabled ) . toBe ( true ) ;
446+ fireEvent . click ( screen . getByTestId ( "ctx-group-pin" ) ) ;
447+ await waitFor ( ( ) => expect ( invokeMock ) . toHaveBeenCalledWith ( "update_note_group_pin" , { id : 1 , pin : 0 } ) ) ;
448+ await waitFor ( ( ) => expect ( onChanged ) . toHaveBeenCalled ( ) ) ;
449+ } ) ;
450+
451+ it ( "分区内上移 = 可见序换位整表快照覆写(自动分区首移即转手排)" , async ( ) => {
452+ const onChanged = renderWith ( [ courseA , courseC ] ) ; // 自动区:C(20) 前 A(10) 后
453+ await screen . findByTestId ( "group-row-1" ) ;
454+ const rows0 = Array . from ( screen . getByTestId ( "group-section-course" ) . querySelectorAll ( '[data-testid^="group-row-"]' ) ) ;
455+ expect ( rows0 . map ( ( r ) => r . getAttribute ( "data-testid" ) ) ) . toEqual ( [ "group-row-3" , "group-row-1" ] ) ;
456+ // 右键课程 A(可上移)→ 上移
457+ fireEvent . contextMenu ( screen . getByTestId ( "group-row-1" ) , { clientX : 10 , clientY : 10 } ) ;
458+ expect ( ( screen . getByTestId ( "ctx-group-up" ) as HTMLButtonElement ) . disabled ) . toBe ( false ) ;
459+ fireEvent . click ( screen . getByTestId ( "ctx-group-up" ) ) ;
460+ await waitFor ( ( ) => expect ( invokeMock ) . toHaveBeenCalledWith ( "note_group_order_save" , { kind : "course" , groupIds : [ 1 , 3 ] } ) ) ;
461+ await waitFor ( ( ) => expect ( onChanged ) . toHaveBeenCalled ( ) ) ;
462+ } ) ;
463+
464+ it ( "分区「手排 ↺」徽标:有手动位才显示,点击=空快照整体回自动" , async ( ) => {
465+ const onChanged = renderWith ( [ courseA , courseC ] , [ [ 1 , 0 ] , [ 3 , 1 ] ] ) ;
466+ await screen . findByTestId ( "group-row-1" ) ;
467+ const resetBtn = await screen . findByTestId ( "group-reset-course" ) ;
468+ expect ( resetBtn . textContent ) . toContain ( "手排" ) ;
469+ fireEvent . click ( resetBtn ) ;
470+ await waitFor ( ( ) => expect ( invokeMock ) . toHaveBeenCalledWith ( "note_group_order_save" , { kind : "course" , groupIds : [ ] } ) ) ;
471+ await waitFor ( ( ) => expect ( onChanged ) . toHaveBeenCalled ( ) ) ;
472+ } ) ;
473+
474+ it ( "菜单「回自动排序」仅组有手动位时显示;点击清单组手动位" , async ( ) => {
475+ const onChanged = renderWith ( [ courseA , courseC ] , [ [ 3 , 0 ] ] ) ; // 仅课程 C 有手动位
476+ await screen . findByTestId ( "group-row-1" ) ;
477+ // 课程 A 无手动位 → 无该项
478+ fireEvent . contextMenu ( screen . getByTestId ( "group-row-1" ) , { clientX : 10 , clientY : 10 } ) ;
479+ expect ( screen . queryByTestId ( "ctx-group-reset" ) ) . toBeNull ( ) ;
480+ fireEvent . keyDown ( window , { key : "Escape" } ) ;
481+ // 课程 C 有手动位 → 显示并可清
482+ fireEvent . contextMenu ( screen . getByTestId ( "group-row-3" ) , { clientX : 10 , clientY : 10 } ) ;
483+ fireEvent . click ( await screen . findByTestId ( "ctx-group-reset" ) ) ;
484+ await waitFor ( ( ) => expect ( invokeMock ) . toHaveBeenCalledWith ( "note_group_order_clear" , { groupId : 3 } ) ) ;
485+ await waitFor ( ( ) => expect ( onChanged ) . toHaveBeenCalled ( ) ) ;
486+ } ) ;
487+
488+ it ( "序行空(全自动分区)→ 无「手排」徽标;ESC 关闭右键菜单" , async ( ) => {
489+ renderWith ( [ courseA , courseC ] ) ;
490+ await screen . findByTestId ( "group-row-1" ) ;
491+ expect ( screen . queryByTestId ( "group-reset-course" ) ) . toBeNull ( ) ;
492+ fireEvent . contextMenu ( screen . getByTestId ( "group-row-1" ) , { clientX : 10 , clientY : 10 } ) ;
493+ expect ( await screen . findByTestId ( "group-row-menu" ) ) . toBeTruthy ( ) ;
494+ fireEvent . keyDown ( window , { key : "Escape" } ) ;
495+ expect ( screen . queryByTestId ( "group-row-menu" ) ) . toBeNull ( ) ;
496+ } ) ;
497+ } ) ;
0 commit comments