Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 205 additions & 123 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@
"jsdom": "^24.0.0",
"sharp": "^0.34.5",
"vite": "^6.4.1",
"vitest": "^4.1.0"
"vitest": "^3.2.4"
}
}
26 changes: 0 additions & 26 deletions src/composables/useHistory.js

This file was deleted.

89 changes: 89 additions & 0 deletions src/composables/useResultStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Single module at the route seam for the result handoff.
//
// Two storage adapters sit behind this interface:
// - sessionStorage carries the fresh handoff from TestView to ResultView,
// read-and-consume so a stale session entry can never shadow the
// persisted history on later visits (e.g. arriving via HomeView's
// "view last result" link).
// - localStorage persists the latest result as history.
//
// Keys, JSON encoding/decoding, completedAt stamping, entry validation
// (type letters and finite percents), and fresh-then-history precedence
// all live here; views never touch storage directly.

const SESSION_KEY = 'mbti_result'
const HISTORY_KEY = 'mbti_lab_result'

// Position-ordered valid letters for an MBTI type. Absorbs the validation
// the deleted src/utils/formatMbtiType.js used to encode (and test in isolation).
const TYPE_POSITIONS = [
['E', 'I'], // energy
['N', 'S'], // information
['T', 'F'], // decisions
['J', 'P'], // lifestyle
]

// Dimension keys of `result.percents` with the letters each may hold.
const DIMENSIONS = [
{ key: 'EI', letters: ['E', 'I'] },
{ key: 'NS', letters: ['N', 'S'] },
{ key: 'FT', letters: ['F', 'T'] },
{ key: 'JP', letters: ['J', 'P'] },
]

// Canonicalize a type string ('infp' -> 'INFP'); '' when any letter is
// invalid for its position, the length is wrong, or it is not a string.
function normalizeType(value) {
if (typeof value !== 'string') return ''
const letters = value.trim().toUpperCase().split('')
if (letters.length !== TYPE_POSITIONS.length) return ''
return TYPE_POSITIONS.every((options, i) => options.includes(letters[i]))
? letters.join('')
: ''
}

// Every dimension must be present with finite numbers for both letters.
function hasFinitePercents(percents) {
if (!percents || typeof percents !== 'object') return false
return DIMENSIONS.every(({ key, letters }) => {
const dim = percents[key]
if (!dim || typeof dim !== 'object') return false
return letters.every((letter) => Number.isFinite(dim[letter]))
})
}

// Decode one stored entry; null for anything malformed.
function parseEntry(raw) {
if (!raw) return null
let value
try {
value = JSON.parse(raw)
} catch {
return null
}
if (!value || typeof value !== 'object') return null
const type = normalizeType(value.type)
if (!type || !hasFinitePercents(value.percents)) return null
return { ...value, type }
}

export function useResultStore() {
// Persist a completed result: stamp it once and write the same entry to
// the fresh handoff (session) and the history (local).
function submit(result) {
const entry = { ...result, completedAt: Date.now() }
sessionStorage.setItem(SESSION_KEY, JSON.stringify(entry))
localStorage.setItem(HISTORY_KEY, JSON.stringify(entry))
}

// Resolve the result to display: the fresh handoff first (consumed on
// read), then the persisted history. null when nothing valid remains.
function load() {
const fresh = parseEntry(sessionStorage.getItem(SESSION_KEY))
sessionStorage.removeItem(SESSION_KEY)
if (fresh) return fresh
return parseEntry(localStorage.getItem(HISTORY_KEY))
}

return { submit, load }
}
28 changes: 0 additions & 28 deletions src/utils/formatMbtiType.js

This file was deleted.

6 changes: 3 additions & 3 deletions src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import LangToggle from '../components/LangToggle.vue'
import SharePanel from '../components/SharePanel.vue'
import { useHistory } from '../composables/useHistory.js'
import { useResultStore } from '../composables/useResultStore.js'

const router = useRouter()
const { loadResult } = useHistory()
const { load } = useResultStore()
const lastResult = ref(null)
const showPanel = ref(false)

onMounted(() => { lastResult.value = loadResult() })
onMounted(() => { lastResult.value = load() })

function start(isShort) {
sessionStorage.setItem('mbti_short', isShort ? '1' : '0')
Expand Down
10 changes: 3 additions & 7 deletions src/views/ResultView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,15 @@ import LangToggle from '../components/LangToggle.vue'
import ResultCard from '../components/ResultCard.vue'
import ShareCard from '../components/ShareCard.vue'
import SharePanel from '../components/SharePanel.vue'
import { useHistory } from '../composables/useHistory.js'
import { useResultStore } from '../composables/useResultStore.js'

const { loadResult } = useHistory()
const { load } = useResultStore()
const result = ref(null)
const shareCardRef = ref(null)
const showPanel = ref(false)

onMounted(() => {
const fresh = sessionStorage.getItem('mbti_result')
if (fresh) {
try { result.value = JSON.parse(fresh) } catch { /* ignore */ }
}
if (!result.value) result.value = loadResult()
result.value = load()
})
</script>

Expand Down
7 changes: 3 additions & 4 deletions src/views/TestView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ import ProgressBar from '../components/ProgressBar.vue'
import QuestionCard from '../components/QuestionCard.vue'
import LangToggle from '../components/LangToggle.vue'
import { useTest } from '../composables/useTest.js'
import { useHistory } from '../composables/useHistory.js'
import { useResultStore } from '../composables/useResultStore.js'
import { questions as allQuestions } from '../data/questions.js'

const router = useRouter()
const { saveResult } = useHistory()
const { submit } = useResultStore()
const { questions, currentIndex, answers, loadQuestions, answer, goBack, isComplete, result } = useTest()

onMounted(() => {
Expand All @@ -49,8 +49,7 @@ function onAnswer(choice) {

watch(isComplete, (val) => {
if (val && result.value) {
saveResult(result.value)
sessionStorage.setItem('mbti_result', JSON.stringify(result.value))
submit(result.value)
router.push('/result')
}
})
Expand Down
11 changes: 11 additions & 0 deletions tests/ResultView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ describe('ResultView empty state', () => {
expect(wrapper.find('.no-result').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'ResultCard' }).exists()).toBe(true)
})

it('renders the empty state for a corrupted session entry instead of crashing ResultCard', async () => {
sessionStorage.setItem('mbti_result', JSON.stringify({
type: 'XNFJ', // invalid letter for position 1
percents: { EI: { E: 30, I: 70 }, NS: { N: 65, S: 35 }, FT: { F: 60, T: 40 }, JP: { J: 55, P: 45 } },
}))
await mountView('zh', { stubs: { ResultCard: true, ShareCard: true } })

expect(wrapper.find('.no-result').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'ResultCard' }).exists()).toBe(false)
})
})

function linkHref(wrapper) {
Expand Down
40 changes: 0 additions & 40 deletions tests/formatMbtiType.test.js

This file was deleted.

Loading
Loading