feat(tutorial): enhance tutorial flow and add tests for help button functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled

This commit is contained in:
2026-07-25 23:59:51 -04:00
parent 8e4f89f4ec
commit 96992cf918
8 changed files with 174 additions and 18 deletions
@@ -49,7 +49,12 @@
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
Change Parent PIN
</button>
<button type="button" class="btn-link btn-link-space" @click="resetPassword" :disabled="resetting">
<button
type="button"
class="btn-link btn-link-space"
@click="resetPassword"
:disabled="resetting"
>
Change Password
</button>
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
@@ -174,7 +179,7 @@
title="Tutorial Restart"
@close="showRestartSuccess = false"
>
<div class="modal-message">Tutorial mode has been restarted</div>
<div class="modal-message">Tutorial mode has been restarted.</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
</div>
+4 -2
View File
@@ -14,7 +14,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { activeStep, maybeShow, clearChainProgress, modalTutorialStepId, helpButtonHidden } from './controller'
import { activeStep, maybeShow, clearChainProgress, modalTutorialStepId, helpButtonHidden, sessionSkipped } from './controller'
// Map route names to the tutorial step that should re-fire when the user taps `?`.
// Keep this list lean — only routes that have a tutorial step actually wired.
@@ -65,7 +65,9 @@ function onClick() {
if (!id) return
// Clear any active step so the manual re-fire wins.
activeStep.value = null
// Temporarily clear local "seen" for the whole chain so every step replays.
// A previous Cancel/Skip would otherwise permanently block manual help.
sessionSkipped.value = false
// Temporarily clear local "seen" state for the whole chain so every step replays.
// The server state is left alone; on dismiss `markStepSeen` simply no-ops.
clearChainProgress(id)
maybeShow(id, null, true)
@@ -2,7 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import HelpButton from '../HelpButton.vue'
import { tutorialEnabled, tutorialProgress, setHelpButtonHidden } from '../controller'
import {
activeStep,
dismissActive,
sessionSkipped,
setHelpButtonHidden,
tutorialEnabled,
tutorialProgress,
} from '../controller'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({ name: 'ChoreView' })),
@@ -12,11 +19,16 @@ describe('HelpButton', () => {
beforeEach(() => {
tutorialEnabled.value = true
tutorialProgress.value = {}
sessionSkipped.value = false
activeStep.value = null
setHelpButtonHidden(false)
document.body.innerHTML = ''
})
afterEach(() => {
setHelpButtonHidden(false)
activeStep.value = null
document.body.innerHTML = ''
})
it('renders when tutorial is enabled and a step is mapped', () => {
@@ -44,4 +56,54 @@ describe('HelpButton', () => {
expect(wrapper.find('.help-fab').exists()).toBe(true)
})
function setAnchorHtml() {
document.body.innerHTML = `
<div class="fab"></div>
<div class="list-item"></div>
<div class="delete-btn"></div>
`
}
it('clicking the ? FAB when tips are disabled starts the help chain', async () => {
tutorialEnabled.value = false
setAnchorHtml()
const wrapper = mount(HelpButton)
await wrapper.find('.help-fab').trigger('click')
await nextTick()
expect(activeStep.value?.def.id).toBe('list-chore-help')
})
it('continues through the help chain while tips are disabled', async () => {
tutorialEnabled.value = false
setAnchorHtml()
const wrapper = mount(HelpButton)
await wrapper.find('.help-fab').trigger('click')
await nextTick()
expect(activeStep.value?.def.id).toBe('list-chore-help')
dismissActive(true)
await nextTick()
expect(activeStep.value?.def.id).toBe('list-edit-hint')
dismissActive(true)
await nextTick()
expect(activeStep.value?.def.id).toBe('list-delete-hint')
})
it('works even if the user previously skipped the session', async () => {
sessionSkipped.value = true
tutorialEnabled.value = false
setAnchorHtml()
const wrapper = mount(HelpButton)
await wrapper.find('.help-fab').trigger('click')
await nextTick()
expect(sessionSkipped.value).toBe(false)
expect(activeStep.value?.def.id).toBe('list-chore-help')
})
})
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
shouldShowStep,
clearChainProgress,
tutorialEnabled,
tutorialProgress,
sessionSkipped,
} from '../controller'
describe('tutorial controller - shouldShowStep', () => {
beforeEach(() => {
tutorialEnabled.value = true
tutorialProgress.value = {}
sessionSkipped.value = false
})
it('returns true when tips are enabled, the step is unseen, and the session is not skipped', () => {
expect(shouldShowStep('create-chore')).toBe(true)
})
it('returns false when tips are disabled for a normal step', () => {
tutorialEnabled.value = false
expect(shouldShowStep('create-penalty')).toBe(false)
})
it('returns true for a locally-cleared step even when tips are disabled', () => {
tutorialEnabled.value = false
clearChainProgress('create-reward')
expect(shouldShowStep('create-reward')).toBe(true)
})
it('still blocks locally-cleared steps when the session has been skipped', () => {
tutorialEnabled.value = false
sessionSkipped.value = true
clearChainProgress('create-kindness')
expect(shouldShowStep('create-kindness')).toBe(false)
})
it('returns false when the step has already been seen', () => {
tutorialProgress.value = { 'notification-click': true }
expect(shouldShowStep('notification-click')).toBe(false)
})
it('ignores the enabled flag when ignoreEnabled is true', () => {
tutorialEnabled.value = false
expect(shouldShowStep('status-pending', true)).toBe(true)
})
})
+1 -1
View File
@@ -80,7 +80,7 @@ export function hydrateFromProfile(profile: {
}
export function shouldShowStep(id: string, ignoreEnabled = false): boolean {
if (!ignoreEnabled && !tutorialEnabled.value) return false
if (!ignoreEnabled && !tutorialEnabled.value && !locallyCleared.has(id)) return false
if (sessionSkipped.value) return false
if (tutorialProgress.value[id]) return false
return true