From dd0293d2a66462e195db6f8947a385607d311929 Mon Sep 17 00:00:00 2001 From: pa Date: Wed, 4 Mar 2026 00:32:43 +0900 Subject: [PATCH] add ut --- .../__tests__/useActivityDataFilter.test.js | 93 ++++++++++ .../__tests__/useActivityStats.test.js | 40 ++++ .../__tests__/useChartHelpers.test.js | 148 +++++++++++++++ .../__tests__/useDateNavigation.test.js | 172 ++++++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 src/views/Charts/composables/__tests__/useActivityDataFilter.test.js create mode 100644 src/views/Charts/composables/__tests__/useActivityStats.test.js create mode 100644 src/views/Charts/composables/__tests__/useChartHelpers.test.js create mode 100644 src/views/Charts/composables/__tests__/useDateNavigation.test.js diff --git a/src/views/Charts/composables/__tests__/useActivityDataFilter.test.js b/src/views/Charts/composables/__tests__/useActivityDataFilter.test.js new file mode 100644 index 00000000..a0afe4f5 --- /dev/null +++ b/src/views/Charts/composables/__tests__/useActivityDataFilter.test.js @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; + +import { useActivityDataFilter } from '../useActivityDataFilter'; + +function setup({ + detailData = [], + isDetailVisible = true, + isSoloInstanceVisible = true, + isNoFriendInstanceVisible = true +} = {}) { + return useActivityDataFilter( + ref(detailData), + ref(isDetailVisible), + ref(isSoloInstanceVisible), + ref(isNoFriendInstanceVisible) + ); +} + +describe('useActivityDataFilter', () => { + it('returns empty array when isDetailVisible is false', () => { + const { filteredActivityDetailData } = setup({ + detailData: [[{ isFriend: true }]], + isDetailVisible: false + }); + expect(filteredActivityDetailData.value).toEqual([]); + }); + + it('returns all data when all filters are enabled', () => { + const data = [ + [{ isFriend: true }], + [{ isFriend: false }], + [{ isFriend: true }, { isFriend: false }] + ]; + const { filteredActivityDetailData } = setup({ detailData: data }); + expect(filteredActivityDetailData.value).toHaveLength(3); + }); + + it('filters solo instances when isSoloInstanceVisible is false', () => { + const data = [ + [{ isFriend: true }], // solo — filtered + [{ isFriend: true }, { isFriend: false }] // not solo — kept + ]; + const { filteredActivityDetailData } = setup({ + detailData: data, + isSoloInstanceVisible: false + }); + expect(filteredActivityDetailData.value).toHaveLength(1); + expect(filteredActivityDetailData.value[0]).toHaveLength(2); + }); + + it('filters no-friend instances when isNoFriendInstanceVisible is false', () => { + const data = [ + [{ isFriend: false }, { isFriend: false }], // no friends — filtered + [{ isFriend: true }, { isFriend: false }] // has friend — kept + ]; + const { filteredActivityDetailData } = setup({ + detailData: data, + isNoFriendInstanceVisible: false + }); + expect(filteredActivityDetailData.value).toHaveLength(1); + }); + + it('keeps solo instances even when isNoFriendInstanceVisible is false', () => { + const data = [ + [{ isFriend: false }] // solo — special case, kept + ]; + const { filteredActivityDetailData } = setup({ + detailData: data, + isNoFriendInstanceVisible: false + }); + expect(filteredActivityDetailData.value).toHaveLength(1); + }); + + it('combines solo and no-friend filters', () => { + const data = [ + [{ isFriend: false }], // solo — filtered by solo filter + [{ isFriend: false }, { isFriend: false }], // no friends — filtered + [{ isFriend: true }, { isFriend: false }] // kept + ]; + const { filteredActivityDetailData } = setup({ + detailData: data, + isSoloInstanceVisible: false, + isNoFriendInstanceVisible: false + }); + expect(filteredActivityDetailData.value).toHaveLength(1); + }); + + it('returns empty array for empty input data', () => { + const { filteredActivityDetailData } = setup({ detailData: [] }); + expect(filteredActivityDetailData.value).toEqual([]); + }); +}); diff --git a/src/views/Charts/composables/__tests__/useActivityStats.test.js b/src/views/Charts/composables/__tests__/useActivityStats.test.js new file mode 100644 index 00000000..11262b98 --- /dev/null +++ b/src/views/Charts/composables/__tests__/useActivityStats.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; + +import { useActivityStats } from '../useActivityStats'; + +describe('useActivityStats', () => { + it('sums all time values from activityData', () => { + const data = ref([{ time: 1000 }, { time: 2000 }, { time: 3000 }]); + const { totalOnlineTime } = useActivityStats(data); + expect(totalOnlineTime.value).toBe(6000); + }); + + it('returns 0 for empty array', () => { + const data = ref([]); + const { totalOnlineTime } = useActivityStats(data); + expect(totalOnlineTime.value).toBe(0); + }); + + it('returns undefined when activityData is null', () => { + const data = ref(null); + const { totalOnlineTime } = useActivityStats(data); + expect(totalOnlineTime.value).toBeUndefined(); + }); + + it('handles single item', () => { + const data = ref([{ time: 42 }]); + const { totalOnlineTime } = useActivityStats(data); + expect(totalOnlineTime.value).toBe(42); + }); + + it('reacts to changes in activityData', () => { + const data = ref([{ time: 100 }]); + const { totalOnlineTime } = useActivityStats(data); + + expect(totalOnlineTime.value).toBe(100); + + data.value = [{ time: 200 }, { time: 300 }]; + expect(totalOnlineTime.value).toBe(500); + }); +}); diff --git a/src/views/Charts/composables/__tests__/useChartHelpers.test.js b/src/views/Charts/composables/__tests__/useChartHelpers.test.js new file mode 100644 index 00000000..1bf3a534 --- /dev/null +++ b/src/views/Charts/composables/__tests__/useChartHelpers.test.js @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { + findMatchingDetailData, + formatWorldName, + generateYAxisLabel, + isDetailDataFiltered +} from '../useChartHelpers'; + +describe('isDetailDataFiltered', () => { + it('returns false when both filters are enabled', () => { + const detailData = [{ isFriend: true }, { isFriend: false }]; + expect(isDetailDataFiltered(detailData, true, true)).toBe(false); + }); + + it('returns false when detailData is null/undefined', () => { + expect(isDetailDataFiltered(null, false, false)).toBe(false); + expect(isDetailDataFiltered(undefined, true, false)).toBe(false); + }); + + it('filters solo instance when isSoloInstanceVisible is false and only 1 entry', () => { + const detailData = [{ isFriend: false }]; + expect(isDetailDataFiltered(detailData, false, true)).toBe(true); + }); + + it('does not filter solo when isSoloInstanceVisible is true', () => { + const detailData = [{ isFriend: false }]; + expect(isDetailDataFiltered(detailData, true, true)).toBe(false); + }); + + it('filters no-friend instance when isNoFriendInstanceVisible is false', () => { + const detailData = [{ isFriend: false }, { isFriend: false }]; + expect(isDetailDataFiltered(detailData, true, false)).toBe(true); + }); + + it('does not filter when at least one friend exists', () => { + const detailData = [{ isFriend: true }, { isFriend: false }]; + expect(isDetailDataFiltered(detailData, true, false)).toBe(false); + }); +}); + +describe('findMatchingDetailData', () => { + const currentUser = { id: 'user1' }; + + it('returns null when activityItem is null', () => { + expect(findMatchingDetailData(null, [], currentUser)).toBeNull(); + }); + + it('returns null when currentUser is null', () => { + expect( + findMatchingDetailData({ location: 'loc1' }, [], null) + ).toBeNull(); + }); + + it('finds matching detail data by location and joinTime', () => { + const joinTime = { isSame: (other) => other === 100 }; + const activityItem = { location: 'wrld_abc', joinTime: 100 }; + const detailData = [ + [ + { location: 'wrld_abc', user_id: 'user1', joinTime }, + { + location: 'wrld_abc', + user_id: 'user2', + joinTime: { isSame: () => false } + } + ], + [ + { + location: 'wrld_xyz', + user_id: 'user1', + joinTime: { isSame: () => false } + } + ] + ]; + + const result = findMatchingDetailData( + activityItem, + detailData, + currentUser + ); + expect(result).toBe(detailData[0]); + }); + + it('returns undefined when no match is found', () => { + const activityItem = { location: 'wrld_abc', joinTime: 100 }; + const detailData = [ + [ + { + location: 'wrld_xyz', + user_id: 'user1', + joinTime: { isSame: () => false } + } + ] + ]; + + const result = findMatchingDetailData( + activityItem, + detailData, + currentUser + ); + expect(result).toBeUndefined(); + }); +}); + +describe('generateYAxisLabel', () => { + it('returns filtered label format for filtered data', () => { + expect(generateYAxisLabel('TestWorld', true)).toBe( + '{filtered|TestWorld}' + ); + }); + + it('returns normal label format for non-filtered data', () => { + expect(generateYAxisLabel('TestWorld', false)).toBe( + '{normal|TestWorld}' + ); + }); + + it('truncates long world names', () => { + const longName = 'A'.repeat(30); + const result = generateYAxisLabel(longName, false); + expect(result).toBe(`{normal|${'A'.repeat(20)}...}`); + }); + + it('respects custom maxLength', () => { + const result = generateYAxisLabel('Hello World!', false, 5); + expect(result).toBe('{normal|Hello...}'); + }); +}); + +describe('formatWorldName', () => { + it('returns name as-is when within maxLength', () => { + expect(formatWorldName('Short')).toBe('Short'); + }); + + it('truncates and adds ellipsis when name exceeds maxLength', () => { + const longName = 'A'.repeat(25); + expect(formatWorldName(longName)).toBe(`${'A'.repeat(20)}...`); + }); + + it('respects custom maxLength', () => { + expect(formatWorldName('Hello World', 5)).toBe('Hello...'); + }); + + it('does not truncate at exact maxLength boundary', () => { + const exactName = 'A'.repeat(20); + expect(formatWorldName(exactName)).toBe(exactName); + }); +}); diff --git a/src/views/Charts/composables/__tests__/useDateNavigation.test.js b/src/views/Charts/composables/__tests__/useDateNavigation.test.js new file mode 100644 index 00000000..ccb569c7 --- /dev/null +++ b/src/views/Charts/composables/__tests__/useDateNavigation.test.js @@ -0,0 +1,172 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; + +import dayjs from 'dayjs'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +import { useDateNavigation } from '../useDateNavigation'; + +beforeAll(() => { + dayjs.extend(utc); + dayjs.extend(timezone); + dayjs.extend(isSameOrAfter); + dayjs.tz.setDefault('UTC'); +}); + +function makeDates(...strings) { + return ref(new Set(strings)); +} + +function setup(dateStrings, reloadData = vi.fn()) { + const allDates = makeDates(...dateStrings); + const result = useDateNavigation(allDates, reloadData); + return { ...result, reloadData }; +} + +describe('useDateNavigation', () => { + describe('changeSelectedDateFromBtn', () => { + it('navigates to previous date', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { selectedDate, changeSelectedDateFromBtn, reloadData } = + setup(dates); + + // Start at the latest date + selectedDate.value = dayjs('2025-01-03').toDate(); + + changeSelectedDateFromBtn(false); // go prev + expect(dayjs(selectedDate.value).format('YYYY-MM-DD')).toBe( + '2025-01-02' + ); + expect(reloadData).toHaveBeenCalled(); + }); + + it('navigates to next date', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { selectedDate, changeSelectedDateFromBtn, reloadData } = + setup(dates); + + selectedDate.value = dayjs('2025-01-02').toDate(); + + changeSelectedDateFromBtn(true); // go next + expect(dayjs(selectedDate.value).format('YYYY-MM-DD')).toBe( + '2025-01-03' + ); + expect(reloadData).toHaveBeenCalled(); + }); + + it('does nothing when allDateOfActivity is empty', () => { + const { selectedDate, changeSelectedDateFromBtn, reloadData } = + setup([]); + const original = selectedDate.value; + + changeSelectedDateFromBtn(false); + expect(selectedDate.value).toBe(original); + expect(reloadData).not.toHaveBeenCalled(); + }); + + it('finds nearest previous date when current date is not in array', () => { + const dates = ['2025-01-05', '2025-01-03', '2025-01-01']; + const { selectedDate, changeSelectedDateFromBtn, reloadData } = + setup(dates); + + // Set to a date not in the array + selectedDate.value = dayjs('2025-01-04').toDate(); + + changeSelectedDateFromBtn(false); + // Should find 2025-01-03 as the closest previous date + expect(dayjs(selectedDate.value).format('YYYY-MM-DD')).toBe( + '2025-01-03' + ); + expect(reloadData).toHaveBeenCalled(); + }); + + it('falls back to last date when going prev at boundary', () => { + const dates = ['2025-01-03', '2025-01-01']; + const { selectedDate, changeSelectedDateFromBtn } = setup(dates); + + selectedDate.value = dayjs('2025-01-01').toDate(); + changeSelectedDateFromBtn(false); + // Should stay at or fallback to the last date + expect(dayjs(selectedDate.value).format('YYYY-MM-DD')).toBe( + '2025-01-01' + ); + }); + + it('falls back to first date when going next at boundary', () => { + const dates = ['2025-01-03', '2025-01-01']; + const { selectedDate, changeSelectedDateFromBtn } = setup(dates); + + selectedDate.value = dayjs('2025-01-03').toDate(); + changeSelectedDateFromBtn(true); + // Already at the latest, should fallback to first + expect(dayjs(selectedDate.value).format('YYYY-MM-DD')).toBe( + '2025-01-03' + ); + }); + }); + + describe('isNextDayBtnDisabled', () => { + it('is true when selected date is the latest', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { selectedDate, isNextDayBtnDisabled } = setup(dates); + + selectedDate.value = dayjs('2025-01-03').toDate(); + expect(isNextDayBtnDisabled.value).toBe(true); + }); + + it('is false when selected date is not the latest', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { selectedDate, isNextDayBtnDisabled } = setup(dates); + + selectedDate.value = dayjs('2025-01-02').toDate(); + expect(isNextDayBtnDisabled.value).toBe(false); + }); + }); + + describe('isPrevDayBtnDisabled', () => { + it('is true when selected date is the earliest', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { selectedDate, isPrevDayBtnDisabled } = setup(dates); + + selectedDate.value = dayjs('2025-01-01').toDate(); + expect(isPrevDayBtnDisabled.value).toBe(true); + }); + + it('is false when selected date is not the earliest', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { selectedDate, isPrevDayBtnDisabled } = setup(dates); + + selectedDate.value = dayjs('2025-01-02').toDate(); + expect(isPrevDayBtnDisabled.value).toBe(false); + }); + }); + + describe('getDatePickerDisabledDate', () => { + it('disables future dates', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { getDatePickerDisabledDate } = setup(dates); + + const futureDate = new Date(Date.now() + 86400000); + expect(getDatePickerDisabledDate(futureDate)).toBe(true); + }); + + it('disables dates not in the activity set', () => { + const dates = ['2025-01-03', '2025-01-01']; + const { getDatePickerDisabledDate } = setup(dates); + + // 2025-01-02 is not in the set + const missingDate = dayjs('2025-01-02').toDate(); + expect(getDatePickerDisabledDate(missingDate)).toBe(true); + }); + + it('enables dates that are in the activity set', () => { + const dates = ['2025-01-03', '2025-01-02', '2025-01-01']; + const { getDatePickerDisabledDate } = setup(dates); + + const validDate = dayjs('2025-01-02').toDate(); + expect(getDatePickerDisabledDate(validDate)).toBe(false); + }); + }); +});