view_test.v

10.0 KB · raw

1module view23import src.events4import src.fmt5import src.history6import src.snapshot7import src.storage89fn save(id string, message string, checkpoint bool) history.Save {10	return history.Save{11		id:            id12		message:       message13		created_at:    179000000014		size_bytes:    102415		is_checkpoint: checkpoint16	}17}1819fn line_map(ids []string) map[string]bool {20	mut out := map[string]bool{}21	for id in ids {22		out[id] = true23	}24	return out25}2627fn plain(lines []string) []string {28	return lines.map(fmt.strip_ansi(it))29}3031// never_act stands in for the command layer where no action may happen yet.32fn never_act(_ &storage.Store, action Action) string {33	panic('an action ran without the strict confirm: ${action.kind}')34}3536fn list_session(items []history.Save) Session {37	return Session{38		screen: Screen{39			tab:     .all40			items:   items41			on_line: line_map(items.map(it.id))42			rows:    3043			cols:    8044		}45	}46}4748fn test_tabs_filter_by_what_is_on_the_line() {49	// A combine replays the local save `bbb` as `ddd`: the copy is on the line,50	// the original is not.51	on_the_line := line_map(['aaa', 'ccc', 'ddd'])52	mut copy := save('ddd', 'my work, replayed', false)53	copy.replayed_from = 'bbb'54	items := [save('aaa', 'first', false), save('bbb', 'my work', false),55		save('ccc', 'before things', true), copy]5657	assert filter_saves(items, .all, on_the_line, '').len == 458	// The replayed-away original exists in history but is on neither live list;59	// the copy that replaced it is an ordinary save on the line.60	assert filter_saves(items, .saves, on_the_line, '').map(it.id) == ['aaa', 'ccc', 'ddd']61	assert filter_saves(items, .checkpoints, on_the_line, '').map(it.id) == ['ccc']62}6364fn test_search_is_case_insensitive_across_the_fields() {65	on_the_line := line_map(['aaa', 'bbb', 'ccc'])66	mut with_agent := save('bbb', 'fix chunk boundary', false)67	with_agent.agent = 'DeepSeek/Flash'68	mut with_session := save('ccc', 'other work', false)69	with_session.session = 'phase one'70	items := [save('aaa', 'add storage', false), with_agent, with_session]7172	assert filter_saves(items, .all, on_the_line, 'STORAGE').map(it.id) == ['aaa']73	assert filter_saves(items, .all, on_the_line, 'chunk').map(it.id) == ['bbb']74	assert filter_saves(items, .all, on_the_line, 'deepseek').map(it.id) == ['bbb']75	assert filter_saves(items, .all, on_the_line, 'PHASE').map(it.id) == ['ccc']76	assert filter_saves(items, .all, on_the_line, 'nothing here').len == 077}7879fn test_move_selection_clamps_at_both_ends() {80	assert move_selection(0, 1, 3) == 181	assert move_selection(2, 1, 3) == 282	assert move_selection(0, -1, 3) == 083	assert move_selection(0, 0, 0) == 084	assert move_selection(5, 1, 0) == 085	assert move_selection(3, -1, 3) == 286}8788// A long message used to wrap, which scrolled the terminal and pushed the tab89// bar off the top: every line of a frame must fit, and the frame must fill90// the screen exactly.91fn test_frame_fills_the_screen_and_never_wraps() {92	mut items := []history.Save{}93	for i in 0 .. 20 {94		items << save('id${i:09}', 'a very long message '.repeat(12), i % 3 == 0)95	}96	for tab in [Tab.all, .saves, .checkpoints] {97		mut session := list_session(items)98		session.screen.tab = tab99		session.screen.cols = 60100		session.screen.selected = 7101		lines := frame_lines(session)102		assert lines.len == 30103		for line in lines {104			assert fmt.visible_width(line) <= 60105		}106		assert plain(lines)[0].contains('checkpoints')107	}108	frame := render(frame_lines(list_session(items)))109	assert frame.starts_with('\x1b[H')110	assert frame.count('\x1b[2K') == 30111	// No newline after the last row, or the terminal scrolls one line.112	assert !frame.ends_with('\n')113}114115fn test_head_has_its_own_mark_and_every_other_save_a_dot() {116	items := [save('aaa', 'newest', false), save('bbb', 'a checkpoint', true),117		save('ccc', 'older', false)]118	mut session := list_session(items)119	session.screen.head = 'aaa'120	session.screen.selected = 2121	text := plain(frame_lines(session)).join('\n')122	assert text.contains('▸ aaa  HEAD')123	assert text.contains('· bbb')124	assert text.contains('· ccc')125	assert !text.contains('◐')126}127128fn test_the_selected_save_adds_size_agent_and_session() {129	mut first := save('aaa', 'first', false)130	first.agent = 'claude-opus-5-5'131	first.session = 'history view'132	mut session := list_session([first, save('bbb', 'second', false)])133	text := plain(frame_lines(session)).join('\n')134	assert text.contains('1.0 KiB · claude-opus-5-5 · history view')135	session.screen.selected = 1136	unselected := plain(frame_lines(session)).join('\n')137	assert !unselected.contains('claude-opus-5-5')138	assert unselected.contains('unknown · no session')139}140141fn test_flagged_and_replayed_away_saves_are_marked() {142	mut session := list_session([save('aaa', 'risky', false), save('bbb', 'my work', false)])143	session.screen.flags['aaa'] = events.Flag{144		save_id: 'aaa'145		flagged: true146	}147	session.screen.on_line = line_map(['aaa'])148	text := plain(frame_lines(session)).join('\n')149	assert text.contains('⚠ risky')150	assert text.contains('[flagged]')151	assert text.contains('bbb  [replayed away]')152}153154fn test_typing_searches_only_after_a_slash() {155	mut session := list_session([save('aaa', 'first', false)])156	assert !handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)157	assert session.mode == .list158	assert session.screen.query == ''159	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)160	assert session.mode == .search161	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)162	assert session.screen.query == 'x'163}164165fn test_q_quits_from_the_list_and_while_searching() {166	mut session := list_session([save('aaa', 'first', false)])167	assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)168	session.mode = .search169	assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)170}171172fn test_the_search_bar_shows_whether_it_is_being_typed() {173	mut session := list_session([save('aaa', 'first', false)])174	for key in ['/', 'f', 'i'] {175		handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)176	}177	typing := plain(frame_lines(session))178	assert typing[0].contains('/fi_')179	assert typing.last().contains('type to filter · enter keep · esc clear')180	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)181	kept := plain(frame_lines(session))182	assert kept[0].contains('/fi') && !kept[0].contains('/fi_')183	assert kept.last().contains('/ search · tab mode')184	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)185	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)186	assert session.screen.query == ''187}188189fn test_g_goes_to_the_oldest_and_shift_g_to_the_newest() {190	mut session := list_session([save('aaa', 'new', false), save('bbb', 'mid', false),191		save('ccc', 'old', false)])192	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'g' }, never_act)193	assert session.screen.selected == 2194	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'G' }, never_act)195	assert session.screen.selected == 0196}197198fn test_tab_cycles_the_tabs_and_starts_at_the_top() {199	mut session := list_session([save('aaa', 'new', false), save('bbb', 'old', false)])200	session.screen.selected = 1201	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)202	assert session.screen.tab == .saves203	assert session.screen.selected == 0204	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)205	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)206	assert session.screen.tab == .all207}208209fn test_flagging_asks_the_strict_confirm_before_anything_happens() {210	mut session := list_session([save('aaa', 'risky', false)])211	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)212	assert session.mode == .detail213	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'f' }, never_act)214	assert session.mode == .reason215	// q is text here: a reason may say "quick hack".216	for key in ['q', 'u', 'i', 'c', 'k'] {217		handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)218	}219	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)220	assert session.mode == .confirm221	assert session.pending.kind == .flag222	assert session.pending.reason == 'quick'223	assert plain(frame_lines(session)).last().contains('flag [aaa]? y yes · n/esc cancel')224	// Only a lowercase y goes ahead; anything else cancels.225	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'Y' }, never_act)226	assert session.mode == .list227	assert session.pending.kind == .none228}229230fn test_detail_lists_files_with_stats_and_notes_binaries() {231	mut target := save('aaa', 'change two files', false)232	target.parent = 'ppp'233	flag := events.Flag{234		save_id: 'aaa'235		flagged: true236		reason:  'sketchy'237	}238	deltas := [239		snapshot.Delta{240			path:    'src/main.v'241			kind:    'modified'242			added:   4243			removed: 2244		},245		snapshot.Delta{246			path:   'logo.png'247			kind:   'added'248			binary: true249		},250	]251	text := plain(detail_lines(target, '', deltas, flag)).join('\n')252	assert text.contains('checksum aaa')253	assert text.contains('flag     sketchy')254	assert text.contains('M src/main.v  +4  -2')255	assert text.contains('A logo.png  (binary)')256	assert text.contains('2 files changed')257}258259fn test_detail_of_the_first_save_says_there_is_no_parent() {260	text := plain(detail_lines(save('aaa', 'first save', false), '', [], events.Flag{261		save_id: 'aaa'262	})).join('\n')263	assert text.contains('initial save — no parent to diff against')264}265266fn test_detail_fits_the_frame_too() {267	mut session := list_session([save('aaa', 'x'.repeat(300), false)])268	session.mode = .detail269	session.target = session.screen.items[0]270	session.detail = detail_lines(session.target, '', [], events.Flag{})271	lines := frame_lines(session)272	assert lines.len == 30273	for line in lines {274		assert fmt.visible_width(line) <= 80275	}276}277278fn test_an_action_result_shows_even_on_a_narrow_screen() {279	mut session := list_session([save('aaa', 'first', false)])280	session.screen.cols = 50281	session.screen.status = 'flagged [70c78decf279]'282	last := plain(frame_lines(session)).last()283	assert last.ends_with('flagged [70c78decf279]')284	assert fmt.visible_width(last) <= 50285}