feat(ui-lab): add first-run and update scenarios
github/actions/ci GitHub Actions CI finished: success
github/actions/ci GitHub Actions CI finished: success
Add separate UI Lab scenarios for true first-run onboarding and post-update What's New checks. Keep empty-library fixtures reusable across mock data, tagger readiness, worker pause defaults, and launch-time version state so browser verification can exercise the real flows.
This commit is contained in:
+6
-1
@@ -40,7 +40,9 @@ UI Lab reads `?scenario=` from the URL. If no scenario is provided, it uses
|
|||||||
| URL | Purpose |
|
| URL | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos |
|
| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos |
|
||||||
| `/?scenario=empty` | First-run state with no folders or media |
|
| `/?scenario=empty` | Empty library with no folders or media (onboarding already completed) |
|
||||||
|
| `/?scenario=new-user` | True first run: onboarding tour open, empty library, no tagger model downloaded |
|
||||||
|
| `/?scenario=just-updated` | Rich library that has just been updated — the "What's new" toast fires on launch |
|
||||||
| `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
|
| `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
|
||||||
| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
|
| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
|
||||||
| `/?scenario=album` | Gallery opened directly into an album |
|
| `/?scenario=album` | Gallery opened directly into an album |
|
||||||
@@ -69,6 +71,9 @@ for small releases, a two-pane section rail for large ones). UI Lab reads
|
|||||||
|
|
||||||
Open the modal via the demo panel: `Ctrl+Shift+D` → Open "What's new" modal.
|
Open the modal via the demo panel: `Ctrl+Shift+D` → Open "What's new" modal.
|
||||||
|
|
||||||
|
Combine with the scenario above to walk the whole post-update greeting for the
|
||||||
|
next release: `/?scenario=just-updated&changelog=unreleased`.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
|
`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
|
||||||
|
|||||||
+16
-7
@@ -11,14 +11,18 @@ import type {
|
|||||||
TaggerRuntimeProbe,
|
TaggerRuntimeProbe,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import { compareImages, createMockDb, mockExif } from './mockFixtures'
|
import { compareImages, createMockDb, mockExif } from './mockFixtures'
|
||||||
import { getMockScenario } from './mockScenarios'
|
import { getMockScenario, isEmptyLibraryScenario } from './mockScenarios'
|
||||||
|
|
||||||
const db = createMockDb(getMockScenario())
|
const db = createMockDb(getMockScenario())
|
||||||
let nextFolderId = 100
|
let nextFolderId = 100
|
||||||
let nextAlbumId = 100
|
let nextAlbumId = 100
|
||||||
let nextTagId = 10_000
|
let nextTagId = 10_000
|
||||||
|
// A true first run ('new-user') has no tagger model downloaded yet, so the
|
||||||
|
// onboarding AI step offers the real model-choice + download flow.
|
||||||
|
const taggerStartsUnready = (scenario: typeof db.scenario) =>
|
||||||
|
scenario === 'unready' || scenario === 'joytag-unready' || scenario === 'new-user'
|
||||||
let mockTaggerModel: TaggerModel = db.scenario === 'joytag-unready' ? 'joytag' : 'wd'
|
let mockTaggerModel: TaggerModel = db.scenario === 'joytag-unready' ? 'joytag' : 'wd'
|
||||||
let mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready'
|
let mockTaggerReady = !taggerStartsUnready(db.scenario)
|
||||||
const mockTaggerThresholds: Record<TaggerModel, number> = {
|
const mockTaggerThresholds: Record<TaggerModel, number> = {
|
||||||
wd: 0.35,
|
wd: 0.35,
|
||||||
joytag: 0.4,
|
joytag: 0.4,
|
||||||
@@ -405,9 +409,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
case 'find_similar_by_region':
|
case 'find_similar_by_region':
|
||||||
return similarImages(payload)
|
return similarImages(payload)
|
||||||
case 'get_visual_clusters':
|
case 'get_visual_clusters':
|
||||||
return db.scenario === 'empty' ? [] : db.visualClusters
|
return isEmptyLibraryScenario(db.scenario) ? [] : db.visualClusters
|
||||||
case 'get_explore_tags':
|
case 'get_explore_tags':
|
||||||
return db.scenario === 'empty'
|
return isEmptyLibraryScenario(db.scenario)
|
||||||
? []
|
? []
|
||||||
: db.scenario === 'extreme'
|
: db.scenario === 'extreme'
|
||||||
? db.exploreTags
|
? db.exploreTags
|
||||||
@@ -607,7 +611,7 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
return mockTaggerModel
|
return mockTaggerModel
|
||||||
case 'set_tagger_model':
|
case 'set_tagger_model':
|
||||||
mockTaggerModel = p.model ?? 'wd'
|
mockTaggerModel = p.model ?? 'wd'
|
||||||
mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready'
|
mockTaggerReady = !taggerStartsUnready(db.scenario)
|
||||||
return mockTaggerModel
|
return mockTaggerModel
|
||||||
case 'prepare_tagger_model':
|
case 'prepare_tagger_model':
|
||||||
mockTaggerReady = true
|
mockTaggerReady = true
|
||||||
@@ -644,8 +648,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
return null
|
return null
|
||||||
case 'get_notifications_paused':
|
case 'get_notifications_paused':
|
||||||
case 'get_worker_pauses_persist':
|
case 'get_worker_pauses_persist':
|
||||||
|
return !isEmptyLibraryScenario(db.scenario)
|
||||||
|
// Only 'new-user' greets with the onboarding tour; 'empty' is a bare
|
||||||
|
// library that has already been through it.
|
||||||
case 'get_onboarding_completed':
|
case 'get_onboarding_completed':
|
||||||
return db.scenario !== 'empty'
|
return db.scenario !== 'new-user'
|
||||||
case 'set_onboarding_completed':
|
case 'set_onboarding_completed':
|
||||||
case 'set_last_seen_version':
|
case 'set_last_seen_version':
|
||||||
return null
|
return null
|
||||||
@@ -659,8 +666,10 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
}))
|
}))
|
||||||
case 'get_build_variant':
|
case 'get_build_variant':
|
||||||
return 'cpu'
|
return 'cpu'
|
||||||
|
// 'just-updated' reports an older last-seen version, so the real
|
||||||
|
// initWhatsNew logic fires the post-update "What's new" toast on launch.
|
||||||
case 'get_last_seen_version':
|
case 'get_last_seen_version':
|
||||||
return '0.1.1-ui'
|
return db.scenario === 'just-updated' ? '0.1.0' : '0.1.1-ui'
|
||||||
case 'get_ffmpeg_status':
|
case 'get_ffmpeg_status':
|
||||||
return { installed: true, downloading: false, failed: false }
|
return { installed: true, downloading: false, failed: false }
|
||||||
case 'retry_ffmpeg_download':
|
case 'retry_ffmpeg_download':
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
VisualClusterEntry,
|
VisualClusterEntry,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import type { MockScenario } from './mockScenarios'
|
import { isEmptyLibraryScenario, type MockScenario } from './mockScenarios'
|
||||||
import { fixtureMediaPath } from './mockMedia'
|
import { fixtureMediaPath } from './mockMedia'
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
@@ -210,7 +210,7 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeFolders(scenario: MockScenario): Folder[] {
|
function makeFolders(scenario: MockScenario): Folder[] {
|
||||||
if (scenario === 'empty') return []
|
if (isEmptyLibraryScenario(scenario)) return []
|
||||||
const names = scenario === 'extreme' ? extremeFolderNames : folderNames
|
const names = scenario === 'extreme' ? extremeFolderNames : folderNames
|
||||||
return names.map((name, index) => ({
|
return names.map((name, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
@@ -224,7 +224,7 @@ function makeFolders(scenario: MockScenario): Folder[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
||||||
if (scenario === 'empty') return []
|
if (isEmptyLibraryScenario(scenario)) return []
|
||||||
const count =
|
const count =
|
||||||
scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72
|
scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72
|
||||||
const images = Array.from({ length: count }, (_, index) =>
|
const images = Array.from({ length: count }, (_, index) =>
|
||||||
@@ -268,7 +268,7 @@ function makeAlbums(
|
|||||||
images: ImageRecord[],
|
images: ImageRecord[],
|
||||||
scenario: MockScenario
|
scenario: MockScenario
|
||||||
): { albums: Album[]; albumImageIds: Record<number, number[]> } {
|
): { albums: Album[]; albumImageIds: Record<number, number[]> } {
|
||||||
if (scenario === 'empty') return { albums: [], albumImageIds: {} }
|
if (isEmptyLibraryScenario(scenario)) return { albums: [], albumImageIds: {} }
|
||||||
const selects = images
|
const selects = images
|
||||||
.filter((image) => image.rating >= 4)
|
.filter((image) => image.rating >= 4)
|
||||||
.slice(0, 36)
|
.slice(0, 36)
|
||||||
@@ -401,7 +401,7 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
||||||
if (scenario === 'empty') return []
|
if (isEmptyLibraryScenario(scenario)) return []
|
||||||
const candidates = images.filter((image) => image.media_kind === 'image')
|
const candidates = images.filter((image) => image.media_kind === 'image')
|
||||||
const groups = [0, 1, 2, 3]
|
const groups = [0, 1, 2, 3]
|
||||||
.map((groupIndex) => ({
|
.map((groupIndex) => ({
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type MockScenario =
|
|||||||
| 'extreme'
|
| 'extreme'
|
||||||
| 'unready'
|
| 'unready'
|
||||||
| 'joytag-unready'
|
| 'joytag-unready'
|
||||||
|
| 'new-user'
|
||||||
|
| 'just-updated'
|
||||||
|
|
||||||
const SCENARIOS = new Set<MockScenario>([
|
const SCENARIOS = new Set<MockScenario>([
|
||||||
'rich',
|
'rich',
|
||||||
@@ -21,8 +23,17 @@ const SCENARIOS = new Set<MockScenario>([
|
|||||||
'extreme',
|
'extreme',
|
||||||
'unready',
|
'unready',
|
||||||
'joytag-unready',
|
'joytag-unready',
|
||||||
|
'new-user',
|
||||||
|
'just-updated',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Scenarios that start with no folders or media. 'empty' is a bare library
|
||||||
|
// with onboarding already behind it; 'new-user' is the true first run and
|
||||||
|
// additionally opens the onboarding tour with no tagger model downloaded.
|
||||||
|
export function isEmptyLibraryScenario(scenario: MockScenario): boolean {
|
||||||
|
return scenario === 'empty' || scenario === 'new-user'
|
||||||
|
}
|
||||||
|
|
||||||
export function getMockScenario(): MockScenario {
|
export function getMockScenario(): MockScenario {
|
||||||
if (typeof window === 'undefined') return 'rich'
|
if (typeof window === 'undefined') return 'rich'
|
||||||
const value = new URLSearchParams(window.location.search).get('scenario')
|
const value = new URLSearchParams(window.location.search).get('scenario')
|
||||||
|
|||||||
Reference in New Issue
Block a user