favorite.test.js
1 import { describe, expect, it, vi } from 'vitest'; 2 import { getRegistry } from '@jackwener/opencli/registry'; 3 import './favorite.js'; 4 describe('zhihu favorite', () => { 5 it('rejects missing collection selectors before opening the chooser', async () => { 6 const cmd = getRegistry().get('zhihu/favorite'); 7 expect(cmd?.func).toBeTypeOf('function'); 8 const page = { goto: vi.fn(), evaluate: vi.fn() }; 9 await expect(cmd.func(page, { target: 'article:1', execute: true })).rejects.toMatchObject({ 10 code: 'INVALID_INPUT', 11 }); 12 expect(page.goto).not.toHaveBeenCalled(); 13 expect(page.evaluate).not.toHaveBeenCalled(); 14 }); 15 it('requires persisted read-back and preserves previously selected collections', async () => { 16 const cmd = getRegistry().get('zhihu/favorite'); 17 const page = { 18 goto: vi.fn().mockResolvedValue(undefined), 19 evaluate: vi.fn() 20 .mockResolvedValueOnce({ 21 chooserRows: [ 22 { id: 'fav-a', name: '已存在', selected: true }, 23 { id: 'fav-b', name: '默认收藏夹', selected: false }, 24 ], 25 targetRowId: 'fav-b', 26 targetRowName: '默认收藏夹', 27 }) 28 .mockResolvedValueOnce({ 29 persisted: true, 30 readbackSource: 'reopened_chooser', 31 selectedBefore: ['fav-a'], 32 selectedAfter: ['fav-a', 'fav-b'], 33 targetSelected: true, 34 }), 35 }; 36 await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ 37 expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹', target: 'article:1' }), 38 ]); 39 expect(page.evaluate.mock.calls[1][0]).toContain('waitForChooserRows(false)'); 40 expect(page.evaluate.mock.calls[1][0]).toContain("readbackSource"); 41 }); 42 it('requires persisted read-back before returning already_applied', async () => { 43 const cmd = getRegistry().get('zhihu/favorite'); 44 const page = { 45 goto: vi.fn().mockResolvedValue(undefined), 46 evaluate: vi.fn() 47 .mockResolvedValueOnce({ 48 chooserRows: [{ id: 'fav-a', name: '默认收藏夹', selected: true }], 49 targetRowId: 'fav-a', 50 targetRowName: '默认收藏夹', 51 }) 52 .mockResolvedValueOnce({ 53 persisted: true, 54 readbackSource: 'reopened_chooser', 55 selectedAfter: ['fav-a'], 56 targetSelected: true, 57 }), 58 }; 59 await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ 60 expect.objectContaining({ outcome: 'already_applied', collection_name: '默认收藏夹' }), 61 ]); 62 }); 63 it('accepts --collection-id as the stable selector path', async () => { 64 const cmd = getRegistry().get('zhihu/favorite'); 65 const page = { 66 goto: vi.fn().mockResolvedValue(undefined), 67 evaluate: vi.fn() 68 .mockResolvedValueOnce({ 69 chooserRows: [ 70 { id: 'fav-a', name: '默认收藏夹', selected: false }, 71 { id: 'fav-b', name: '同名收藏夹', selected: false }, 72 ], 73 targetRowId: 'fav-b', 74 targetRowName: null, 75 }) 76 .mockResolvedValueOnce({ 77 persisted: true, 78 readbackSource: 'reopened_chooser', 79 selectedAfter: ['fav-b'], 80 targetSelected: true, 81 }), 82 }; 83 await expect(cmd.func(page, { target: 'article:1', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([ 84 expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b' }), 85 ]); 86 }); 87 it('rejects duplicate collection names before selecting any row', async () => { 88 const cmd = getRegistry().get('zhihu/favorite'); 89 const page = { 90 goto: vi.fn().mockResolvedValue(undefined), 91 evaluate: vi.fn().mockResolvedValue({ 92 chooserRows: [ 93 { id: 'fav-a', name: '默认收藏夹', selected: false }, 94 { id: 'fav-b', name: '默认收藏夹', selected: false }, 95 ], 96 }), 97 }; 98 await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' }); 99 }); 100 it('rejects optimistic chooser state that was not re-read from a reopened chooser', async () => { 101 const cmd = getRegistry().get('zhihu/favorite'); 102 const page = { 103 goto: vi.fn().mockResolvedValue(undefined), 104 evaluate: vi.fn() 105 .mockResolvedValueOnce({ 106 chooserRows: [ 107 { id: 'fav-a', name: '已存在', selected: true }, 108 { id: 'fav-b', name: '默认收藏夹', selected: false }, 109 ], 110 targetRowId: 'fav-b', 111 targetRowName: '默认收藏夹', 112 }) 113 .mockResolvedValueOnce({ 114 persisted: true, 115 readbackSource: 'same_modal', 116 selectedAfter: ['fav-a', 'fav-b'], 117 targetSelected: true, 118 }), 119 }; 120 await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'OUTCOME_UNKNOWN' }); 121 }); 122 it('matches unique collection names even when chooser rows include extra UI text', async () => { 123 const cmd = getRegistry().get('zhihu/favorite'); 124 const page = { 125 goto: vi.fn().mockResolvedValue(undefined), 126 evaluate: vi.fn() 127 .mockResolvedValueOnce({ 128 chooserRows: [ 129 { id: 'fav-b', name: '默认收藏夹 12 条内容', selected: false }, 130 ], 131 targetRowId: null, 132 targetRowName: '默认收藏夹', 133 }) 134 .mockResolvedValueOnce({ 135 persisted: true, 136 readbackSource: 'reopened_chooser', 137 selectedAfter: ['fav-b'], 138 targetSelected: true, 139 }), 140 }; 141 await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ 142 expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }), 143 ]); 144 expect(page.evaluate.mock.calls[1][0]).toContain('normalizeCollectionName'); 145 }); 146 it('normalizes id-less row keys during reopened chooser verification', async () => { 147 const cmd = getRegistry().get('zhihu/favorite'); 148 const page = { 149 goto: vi.fn().mockResolvedValue(undefined), 150 evaluate: vi.fn() 151 .mockResolvedValueOnce({ 152 chooserRows: [ 153 { id: '', name: '默认收藏夹 12 条内容', selected: false }, 154 ], 155 targetRowId: null, 156 targetRowName: '默认收藏夹', 157 }) 158 .mockResolvedValueOnce({ 159 persisted: true, 160 readbackSource: 'reopened_chooser', 161 selectedAfter: ['name:默认收藏夹'], 162 targetSelected: true, 163 }), 164 }; 165 await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ 166 expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }), 167 ]); 168 expect(page.evaluate.mock.calls[1][0]).toContain("const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);"); 169 expect(page.evaluate.mock.calls[1][0]).toContain('selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey)'); 170 }); 171 it('reuses data-attribute answer anchoring during reopened chooser verification', async () => { 172 const cmd = getRegistry().get('zhihu/favorite'); 173 const page = { 174 goto: vi.fn().mockResolvedValue(undefined), 175 evaluate: vi.fn() 176 .mockResolvedValueOnce({ 177 chooserRows: [ 178 { id: 'fav-b', name: '默认收藏夹', selected: false }, 179 ], 180 targetRowId: 'fav-b', 181 targetRowName: null, 182 }) 183 .mockResolvedValueOnce({ 184 persisted: true, 185 readbackSource: 'reopened_chooser', 186 selectedAfter: ['fav-b'], 187 targetSelected: true, 188 }), 189 }; 190 await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([ 191 expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b', target: 'answer:1:2' }), 192 ]); 193 expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-answerid')"); 194 expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-zop-question-answer')"); 195 }); 196 });