view_test.v

14.1 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. The two lists60	// never overlap: a checkpoint belongs to the checkpoints tab alone.61	assert filter_saves(items, .saves, on_the_line, '').map(it.id) == ['aaa', 'ddd']62	assert filter_saves(items, .checkpoints, on_the_line, '').map(it.id) == ['ccc']63}6465fn test_search_is_case_insensitive_across_the_fields() {66	on_the_line := line_map(['aaa', 'bbb', 'ccc'])67	mut with_agent := save('bbb', 'fix chunk boundary', false)68	with_agent.agent = 'DeepSeek/Flash'69	mut with_session := save('ccc', 'other work', false)70	with_session.session = 'phase one'71	items := [save('aaa', 'add storage', false), with_agent, with_session]7273	assert filter_saves(items, .all, on_the_line, 'STORAGE').map(it.id) == ['aaa']74	assert filter_saves(items, .all, on_the_line, 'chunk').map(it.id) == ['bbb']75	assert filter_saves(items, .all, on_the_line, 'deepseek').map(it.id) == ['bbb']76	assert filter_saves(items, .all, on_the_line, 'PHASE').map(it.id) == ['ccc']77	assert filter_saves(items, .all, on_the_line, 'nothing here').len == 078}7980fn test_move_selection_clamps_at_both_ends() {81	assert move_selection(0, 1, 3) == 182	assert move_selection(2, 1, 3) == 283	assert move_selection(0, -1, 3) == 084	assert move_selection(0, 0, 0) == 085	assert move_selection(5, 1, 0) == 086	assert move_selection(3, -1, 3) == 287}8889// A long message used to wrap, which scrolled the terminal and shoved the tab90// bar off the top: every line of a frame must fit, and the frame must fill91// the screen exactly.92fn test_frame_fills_the_screen_and_never_wraps() {93	mut items := []history.Save{}94	for i in 0 .. 20 {95		items << save('id${i:09}', 'a very long message '.repeat(12), i % 3 == 0)96	}97	for tab in [Tab.all, .saves, .checkpoints] {98		mut session := list_session(items)99		session.screen.tab = tab100		session.screen.cols = 60101		session.screen.selected = 7102		lines := frame_lines(session)103		assert lines.len == 30104		for line in lines {105			assert fmt.visible_width(line) <= 60106		}107		assert plain(lines)[0].contains('checkpoints')108	}109	frame := render(frame_lines(list_session(items)))110	assert frame.starts_with('\x1b[H')111	assert frame.count('\x1b[2K') == 30112	// No newline after the last row, or the terminal scrolls one line.113	assert !frame.ends_with('\n')114}115116fn test_head_has_its_own_mark_and_every_other_save_a_dot() {117	items := [save('aaa', 'newest', false), save('bbb', 'a checkpoint', true),118		save('ccc', 'older', false)]119	mut session := list_session(items)120	session.screen.head = 'aaa'121	session.screen.selected = 2122	text := plain(frame_lines(session)).join('\n')123	assert text.contains('▸ aaa  HEAD')124	assert text.contains('· bbb')125	assert text.contains('· ccc')126	assert !text.contains('◐')127}128129fn test_the_selected_save_adds_size_agent_and_session() {130	mut first := save('aaa', 'first', false)131	first.agent = 'claude-opus-5-5'132	first.session = 'history view'133	mut session := list_session([first, save('bbb', 'second', false)])134	text := plain(frame_lines(session)).join('\n')135	assert text.contains('1.0 KiB · claude-opus-5-5 · history view')136	session.screen.selected = 1137	unselected := plain(frame_lines(session)).join('\n')138	assert !unselected.contains('claude-opus-5-5')139	assert unselected.contains('unknown · no session')140}141142fn test_flagged_and_replayed_away_saves_are_marked() {143	mut session := list_session([save('aaa', 'risky', false), save('bbb', 'my work', false)])144	session.screen.flags['aaa'] = events.Flag{145		save_id: 'aaa'146		flagged: true147	}148	session.screen.on_line = line_map(['aaa'])149	text := plain(frame_lines(session)).join('\n')150	assert text.contains('⚠ risky')151	assert text.contains('[flagged]')152	assert text.contains('bbb  [replayed away]')153}154155fn test_typing_searches_only_after_a_slash() {156	mut session := list_session([save('aaa', 'first', false)])157	assert !handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)158	assert session.mode == .list159	assert session.screen.query == ''160	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)161	assert session.mode == .search162	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)163	assert session.screen.query == 'x'164}165166fn test_q_is_a_letter_while_searching_and_esc_is_the_way_out() {167	mut session := list_session([save('aaa', 'queue the saves', false)])168	assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)169	session.mode = .search170	assert !handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)171	assert session.screen.query == 'q'172	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)173	assert session.mode == .list174	assert session.screen.query == ''175	assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)176}177178fn test_the_search_bar_shows_whether_it_is_being_typed() {179	mut session := list_session([save('aaa', 'first', false)])180	for key in ['/', 'f', 'i'] {181		handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)182	}183	typing := plain(frame_lines(session))184	assert typing[0].contains('/fi_')185	assert typing.last().contains('type to filter · enter keep · esc clear')186	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)187	kept := plain(frame_lines(session))188	assert kept[0].contains('/fi') && !kept[0].contains('/fi_')189	assert kept.last().contains('/ search · tab mode')190	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)191	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)192	assert session.screen.query == ''193}194195fn test_g_goes_to_the_oldest_and_shift_g_to_the_newest() {196	mut session := list_session([save('aaa', 'new', false), save('bbb', 'mid', false),197		save('ccc', 'old', false)])198	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'g' }, never_act)199	assert session.screen.selected == 2200	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'G' }, never_act)201	assert session.screen.selected == 0202}203204fn test_tab_cycles_the_tabs_and_starts_at_the_top() {205	mut session := list_session([save('aaa', 'new', false), save('bbb', 'old', false)])206	session.screen.selected = 1207	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)208	assert session.screen.tab == .saves209	assert session.screen.selected == 0210	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)211	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)212	assert session.screen.tab == .all213}214215fn test_flagging_asks_the_strict_confirm_before_anything_happens() {216	mut session := list_session([save('aaa', 'risky', false)])217	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)218	assert session.mode == .detail219	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'f' }, never_act)220	assert session.mode == .reason221	// q is text here: a reason may say "quick hack".222	for key in ['q', 'u', 'i', 'c', 'k'] {223		handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)224	}225	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)226	assert session.mode == .confirm227	assert session.pending.kind == .flag228	assert session.pending.reason == 'quick'229	assert plain(frame_lines(session)).last().contains('flag [aaa]? y yes · n/esc cancel')230	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)231	assert session.mode == .list232	assert session.pending.kind == .none233}234235fn test_detail_lists_files_with_stats_and_notes_binaries() {236	mut target := save('aaa', 'change two files', false)237	target.parent = 'ppp'238	flag := events.Flag{239		save_id: 'aaa'240		flagged: true241		reason:  'sketchy'242	}243	deltas := [244		snapshot.Delta{245			path:    'src/main.v'246			kind:    'modified'247			added:   4248			removed: 2249		},250		snapshot.Delta{251			path:   'logo.png'252			kind:   'added'253			binary: true254		},255		snapshot.Delta{256			path:  'new.txt'257			kind:  'added'258			added: 3259		},260		snapshot.Delta{261			path:    'old.txt'262			kind:    'removed'263			removed: 5264		},265	]266	text := plain(detail_lines(target, '', deltas, flag)).join('\n')267	assert text.contains('checksum aaa')268	assert text.contains('flag     sketchy')269	assert text.contains('M src/main.v  +4  -2')270	assert text.contains('A logo.png  (binary)')271	assert text.contains('4 files changed')272	assert text.ends_with('\n\n  total  +7  -7')273}274275fn test_detail_of_an_unchanged_checkpoint_shows_zero_line_totals() {276	mut target := save('aaa', 'checkpoint', true)277	target.parent = 'ppp'278	text := plain(detail_lines(target, '', [], events.Flag{})).join('\n')279	assert text.contains('0 files changed')280	assert text.ends_with('\n\n  total  +0  -0')281}282283fn test_detail_of_the_first_save_says_there_is_no_parent() {284	text := plain(detail_lines(save('aaa', 'first save', false), '', [], events.Flag{285		save_id: 'aaa'286	})).join('\n')287	assert text.contains('initial save — no parent to diff against')288}289290fn test_detail_fits_the_frame_too() {291	mut session := list_session([save('aaa', 'x'.repeat(300), false)])292	session.mode = .detail293	session.target = session.screen.items[0]294	session.detail = detail_lines(session.target, '', [], events.Flag{})295	lines := frame_lines(session)296	assert lines.len == 30297	for line in lines {298		assert fmt.visible_width(line) <= 80299	}300}301302fn test_an_action_result_shows_even_on_a_narrow_screen() {303	mut session := list_session([save('aaa', 'first', false)])304	session.screen.cols = 50305	session.screen.status = 'flagged [70c78decf279]'306	last := plain(frame_lines(session)).last()307	assert last.ends_with('flagged [70c78decf279]')308	assert fmt.visible_width(last) <= 50309}310311// Koh 2 spreads six entries over the whole body. June asked for them 15%312// tighter with no empty band left: seven entries, still filling the screen.313fn test_entries_sit_fifteen_percent_tighter_and_still_fill_the_screen() {314	mut items := []history.Save{}315	for i in 0 .. 10 {316		items << save('id${i:09}', 'message ${i}', false)317	}318	mut session := list_session(items)319	session.screen.rows = 51 // a body of 48 rows: Koh 2's blocks are 8 tall320	lines := plain(frame_lines(session))321	assert lines.len == 51322	mut headers := []int{}323	mut separators := []int{}324	for i, line in lines {325		if line.starts_with('  · ') || line.starts_with('  ▸ ') {326			headers << i327		}328		if line.starts_with('─') {329			separators << i330		}331	}332	assert headers.len == 7333	for i in 1 .. headers.len {334		assert headers[i] - headers[i - 1] in [6, 7]335	}336	// The last block ends at the bottom of the body, right above the status bar.337	assert lines.len - 2 - separators.last() <= 1338}339340// Esc always goes back one step, and from the list it leaves the view: first341// it drops a kept search filter, then it exits, just as q does.342fn test_esc_goes_back_or_exits_from_the_list() {343	mut session := list_session([save('aaa', 'first', false)])344	session.screen.query = 'fir'345	assert !handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)346	assert session.screen.query == ''347	assert handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)348}349350fn test_esc_steps_back_from_every_other_mode() {351	mut session := list_session([save('aaa', 'first', false)])352	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)353	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'f' }, never_act)354	assert session.mode == .reason355	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)356	assert session.mode == .detail357	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'l' }, never_act)358	assert session.mode == .confirm359	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)360	assert session.mode == .list361	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)362	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)363	assert session.mode == .list364}365366fn test_manual_pages_jump_between_sections_and_name_the_one_on_top() {367	mut lines := []ManualLine{len: 10, init: ManualLine{368		text: 'body'369	}}370	lines[2] = ManualLine{371		text:    'NAME'372		section: 'NAME'373	}374	lines[7] = ManualLine{375		text:    'FILES'376		section: 'FILES'377	}378	// Pages of three: [0,1,2] [3,4,5] [6,7,8] [9].379	assert section_page(lines, 0, 3, true) == 2380	assert section_page(lines, 2, 3, true) == 2381	assert section_page(lines, 3, 3, false) == 2382	assert section_page(lines, 2, 3, false) == 0383	frame := manual_frame(lines, 3, 3, 80)384	assert frame.len == 4385	assert frame[0] == 'body' && frame[1] == ''386	assert fmt.strip_ansi(frame[3]).starts_with('FILES  ·  page 4 of 4')387}388389fn confirmed_action(_ &storage.Store, action Action) string {390	return 'confirmed ${action.save_id}'391}392393fn test_both_cases_of_y_confirm_view_actions() {394	for answer in ['y', 'Y'] {395		mut session := Session{ mode: .confirm, pending: Action{ kind: .load, save_id: 'aaa' } }396		confirm_key(mut session, unsafe { nil }, Key{ kind: .rune, text: answer }, confirmed_action)397		assert session.screen.status == 'confirmed aaa'398		assert session.mode == .list399	}400}