up
This commit is contained in:
+9
-3
@@ -592,11 +592,11 @@ app
|
|||||||
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
|
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
|
||||||
try {
|
try {
|
||||||
const result = await online!.act(intent)
|
const result = await online!.act(intent)
|
||||||
if (['download', 'check', 'remove'].includes(String(action)))
|
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
|
||||||
diagnostics?.record('package', { action, status: 'success' })
|
diagnostics?.record('package', { action, status: 'success' })
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (['download', 'check', 'remove'].includes(String(action)))
|
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
|
||||||
diagnostics?.record('package', { action, status: 'failed' })
|
diagnostics?.record('package', { action, status: 'failed' })
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
@@ -622,7 +622,13 @@ app
|
|||||||
requireControl(event)
|
requireControl(event)
|
||||||
return packages
|
return packages
|
||||||
.list()
|
.list()
|
||||||
.map((pkg) => ({ key: pkg.key, version: pkg.version, name: pkg.package.game.name }))
|
.map((pkg) => ({
|
||||||
|
key: pkg.key,
|
||||||
|
version: pkg.version,
|
||||||
|
name: pkg.package.game.name,
|
||||||
|
downloadedFiles: pkg.package.assets.filter((asset) => packages.canRead(pkg.key, asset.id)).length,
|
||||||
|
totalFiles: pkg.package.assets.length,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
ipcMain.handle('select-package', async (event, key: unknown) => {
|
ipcMain.handle('select-package', async (event, key: unknown) => {
|
||||||
requireControl(event)
|
requireControl(event)
|
||||||
|
|||||||
+113
-1
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { test, type TestContext } from 'node:test'
|
import { test, type TestContext } from 'node:test'
|
||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { Controller } from './controller.js'
|
import { Controller } from './controller.js'
|
||||||
@@ -443,6 +443,79 @@ test('список материалов показывает отдельную
|
|||||||
assert.deepEqual(f.online.snapshot().materials, [])
|
assert.deepEqual(f.online.snapshot().materials, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('полная очистка без сети удаляет игры и локальные данные, сохраняя вход после перезапуска', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const directory = join(f.directory, 'connection')
|
||||||
|
const key = f.controller.state.catalog.key!
|
||||||
|
await writeFile(join(directory, `plan-${key}.json`), '{}')
|
||||||
|
await writeFile(join(directory, `results-${'a'.repeat(64)}.json.tmp`), '{}')
|
||||||
|
const credentials = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
|
||||||
|
f.model.offline = true
|
||||||
|
const requests = f.requests.length
|
||||||
|
const revision = f.online.snapshot().packagesRevision
|
||||||
|
const result = await f.online.act({ action: 'clear-games' })
|
||||||
|
assert.equal(result.authenticated, true)
|
||||||
|
assert.equal(result.screenId, null)
|
||||||
|
assert.equal(result.game, null)
|
||||||
|
assert.deepEqual(result.materials, [])
|
||||||
|
assert.equal(result.packagesRevision, revision + 1)
|
||||||
|
assert.equal(result.notice, 'Все сохранённые игры удалены')
|
||||||
|
assert.equal(f.controller.state.catalog.key, null)
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
assert.equal(f.requests.length, requests)
|
||||||
|
assert.ok(!(await readdir(directory)).some((name) => /^(plan|results)-/.test(name)))
|
||||||
|
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
|
||||||
|
assert.equal(saved.access, credentials.access)
|
||||||
|
assert.equal(saved.screen, null)
|
||||||
|
await f.online.close()
|
||||||
|
await f.store.initialize()
|
||||||
|
await f.online.initialize()
|
||||||
|
assert.equal(f.controller.state.catalog.key, null)
|
||||||
|
assert.equal(f.online.snapshot().authenticated, true)
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
await f.online.act({ action: 'logout' })
|
||||||
|
await f.online.act({ action: 'clear-games' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ошибка очистки обновляет список и позволяет повторить удаление', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const clear = f.store.clear.bind(f.store)
|
||||||
|
f.store.clear = async () => {
|
||||||
|
assert.equal(f.online.blocksOutput, true)
|
||||||
|
throw new Error('disk failure')
|
||||||
|
}
|
||||||
|
const revision = f.online.snapshot().packagesRevision
|
||||||
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Очистка не завершена/)
|
||||||
|
assert.equal(f.online.blocksOutput, false)
|
||||||
|
assert.equal(f.online.snapshot().packagesRevision, revision + 1)
|
||||||
|
assert.equal(f.controller.state.catalog.key, null)
|
||||||
|
assert.equal(f.store.list().length, 1)
|
||||||
|
assert.equal(f.online.snapshot().notice, '')
|
||||||
|
f.store.clear = clear
|
||||||
|
await f.online.act({ action: 'clear-games' })
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('очистка отклоняется во время показа и подготовки материалов', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const key = f.controller.state.catalog.key
|
||||||
|
f.controller.state.outputOpen = true
|
||||||
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Закройте показ/)
|
||||||
|
assert.equal(f.controller.state.catalog.key, key)
|
||||||
|
assert.equal(f.store.list().length, 1)
|
||||||
|
f.controller.state.outputOpen = false
|
||||||
|
f.online.state.preparation.active = true
|
||||||
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
|
||||||
|
assert.equal(f.store.list().length, 1)
|
||||||
|
f.online.state.preparation.active = false
|
||||||
|
f.online.state.busy = true
|
||||||
|
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
|
||||||
|
f.online.state.busy = false
|
||||||
|
})
|
||||||
|
|
||||||
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
|
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
|
||||||
const f = await fixture(t)
|
const f = await fixture(t)
|
||||||
await f.prepare()
|
await f.prepare()
|
||||||
@@ -451,6 +524,45 @@ test('готовый пакет показывает все материалы
|
|||||||
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('локальную игру можно выбрать без сети и входа, но нельзя сменить во время показа', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const key = f.controller.state.catalog.key!
|
||||||
|
await f.online.act({ action: 'logout' })
|
||||||
|
await f.online.select(null)
|
||||||
|
f.model.offline = true
|
||||||
|
const requests = f.requests.length
|
||||||
|
await f.online.select(key)
|
||||||
|
assert.equal(f.controller.state.catalog.key, key)
|
||||||
|
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
||||||
|
assert.equal(f.requests.length, requests)
|
||||||
|
f.controller.state.outputOpen = true
|
||||||
|
await assert.rejects(f.online.select(null), /Закройте показ/)
|
||||||
|
assert.equal(f.controller.state.catalog.key, key)
|
||||||
|
f.controller.state.outputOpen = false
|
||||||
|
})
|
||||||
|
|
||||||
|
test('частично скачанная игра выбирается без сети после перезапуска', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.login()
|
||||||
|
await f.online.act({ action: 'connect', gameId: 7 })
|
||||||
|
const key = f.controller.state.catalog.key!
|
||||||
|
const asset = f.online.snapshot().materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!
|
||||||
|
await f.online.act({ action: 'download-material', key, assetId: asset.id })
|
||||||
|
await f.online.act({ action: 'logout' })
|
||||||
|
await f.online.select(null)
|
||||||
|
await f.online.close()
|
||||||
|
f.model.offline = true
|
||||||
|
await f.store.initialize()
|
||||||
|
await f.online.initialize()
|
||||||
|
const requests = f.requests.length
|
||||||
|
await f.online.select(key)
|
||||||
|
const materials = f.online.snapshot().materials
|
||||||
|
assert.equal(materials.find((m) => m.id === asset.id)!.status, 'ready')
|
||||||
|
assert.ok(materials.some((m) => m.status === 'missing'))
|
||||||
|
assert.equal(f.requests.length, requests)
|
||||||
|
})
|
||||||
|
|
||||||
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
|
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
|
||||||
const f = await fixture(t)
|
const f = await fixture(t)
|
||||||
await f.login()
|
await f.login()
|
||||||
|
|||||||
+37
-2
@@ -2,7 +2,7 @@ import { requestErrorContext } from './request-error.js'
|
|||||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||||
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
|
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
|
||||||
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
||||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
DEFAULT_SERVER,
|
DEFAULT_SERVER,
|
||||||
@@ -916,8 +916,9 @@ export class Online {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private clearingGames = false
|
||||||
get blocksOutput() {
|
get blocksOutput() {
|
||||||
return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key
|
return this.clearingGames || (this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key)
|
||||||
}
|
}
|
||||||
get busy() {
|
get busy() {
|
||||||
return this.state.busy
|
return this.state.busy
|
||||||
@@ -1054,6 +1055,7 @@ export class Online {
|
|||||||
}
|
}
|
||||||
async select(key: string | null, persist = true) {
|
async select(key: string | null, persist = true) {
|
||||||
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
|
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
|
||||||
|
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед выбором игры')
|
||||||
let pkg
|
let pkg
|
||||||
try {
|
try {
|
||||||
pkg =
|
pkg =
|
||||||
@@ -1238,6 +1240,8 @@ export class Online {
|
|||||||
return this.snapshot()
|
return this.snapshot()
|
||||||
}
|
}
|
||||||
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
||||||
|
if (data.action === 'clear-games' && (this.state.preparation.active || this.resultsBusy || this.planBusy > 0))
|
||||||
|
throw new Error('Подождите завершения подготовки материалов и результатов')
|
||||||
const isBackgroundRefresh = data.action === 'playlists'
|
const isBackgroundRefresh = data.action === 'playlists'
|
||||||
this.state.busy = true
|
this.state.busy = true
|
||||||
const messages: Record<OnlineIntent['action'], string> = {
|
const messages: Record<OnlineIntent['action'], string> = {
|
||||||
@@ -1263,6 +1267,7 @@ export class Online {
|
|||||||
check: 'Проверка материалов игры…',
|
check: 'Проверка материалов игры…',
|
||||||
'retry-materials': 'Подготовка материалов игры…',
|
'retry-materials': 'Подготовка материалов игры…',
|
||||||
remove: 'Удаление локальных материалов…',
|
remove: 'Удаление локальных материалов…',
|
||||||
|
'clear-games': 'Удаление всех сохранённых игр…',
|
||||||
sound: 'Изменение назначения звука…',
|
sound: 'Изменение назначения звука…',
|
||||||
results: 'Загрузка результатов игры…',
|
results: 'Загрузка результатов игры…',
|
||||||
cancel: 'Отмена загрузки…',
|
cancel: 'Отмена загрузки…',
|
||||||
@@ -1453,6 +1458,36 @@ export class Online {
|
|||||||
this.state.packagesRevision++
|
this.state.packagesRevision++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case 'clear-games': {
|
||||||
|
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед очисткой игр')
|
||||||
|
this.clearingGames = true
|
||||||
|
this.detach()
|
||||||
|
this.controller.selectPackage(null)
|
||||||
|
this.controller.setResults(null)
|
||||||
|
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
|
||||||
|
this.cachedPlan = ''
|
||||||
|
this.planNotice = ''
|
||||||
|
try {
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.materialRefresh, this.materialWork, this.mainWork, this.musicWork, this.planWrites,
|
||||||
|
])
|
||||||
|
await this.saveSelection()
|
||||||
|
await this.persistSecrets()
|
||||||
|
await this.packages.clear()
|
||||||
|
for (const name of await readdir(this.directory)) {
|
||||||
|
if (/^(plan|results)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
|
||||||
|
await rm(join(this.directory, name), { force: true })
|
||||||
|
}
|
||||||
|
this.state.notice = 'Все сохранённые игры удалены'
|
||||||
|
} catch {
|
||||||
|
await this.packages.initialize().catch(() => {})
|
||||||
|
throw new Error('Очистка не завершена. Повторите удаление оставшихся данных')
|
||||||
|
} finally {
|
||||||
|
this.clearingGames = false
|
||||||
|
this.state.packagesRevision++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
case 'retry-materials':
|
case 'retry-materials':
|
||||||
this.lastMaterials = -Infinity
|
this.lastMaterials = -Infinity
|
||||||
await this.refreshMaterials()
|
await this.refreshMaterials()
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ async function fixture(t: TestContext) {
|
|||||||
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
|
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('полная очистка удаляет пакеты, частичный кеш, повреждённые остатки и ссылки, сохраняя внешние файлы', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.store.download(f.pkg)
|
||||||
|
const partial = description('https://partial.test')
|
||||||
|
await f.store.remember(partial)
|
||||||
|
await f.store.ensureAsset(partial, partial.assets[0]!.id, new AbortController().signal)
|
||||||
|
const broken = join(f.root, 'a'.repeat(64))
|
||||||
|
await mkdir(broken)
|
||||||
|
await writeFile(join(broken, 'current.json'), 'broken')
|
||||||
|
const outside = join(f.dir, 'outside')
|
||||||
|
await mkdir(outside)
|
||||||
|
await writeFile(join(outside, 'keep'), 'keep')
|
||||||
|
await symlink(outside, join(f.root, 'b'.repeat(64)))
|
||||||
|
await f.store.clear()
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
assert.deepEqual(await readdir(f.root), [])
|
||||||
|
assert.equal(await readFile(join(outside, 'keep'), 'utf8'), 'keep')
|
||||||
|
await assert.rejects(f.store.localFile(f.key, f.pkg.assets[0]!.id))
|
||||||
|
await f.store.initialize()
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
await f.store.clear()
|
||||||
|
})
|
||||||
|
|
||||||
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
|
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
|
||||||
const f = await fixture(t)
|
const f = await fixture(t)
|
||||||
const other = description('https://other.test')
|
const other = description('https://other.test')
|
||||||
|
|||||||
@@ -304,6 +304,10 @@ export class PackageStore {
|
|||||||
await this.exclusive(async () => {
|
await this.exclusive(async () => {
|
||||||
const dir = await this.path(key)
|
const dir = await this.path(key)
|
||||||
await rm(dir, { recursive: true, force: true })
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
this.forget(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
private forget(key: string) {
|
||||||
this.ready.delete(key)
|
this.ready.delete(key)
|
||||||
this.descriptions.delete(key)
|
this.descriptions.delete(key)
|
||||||
for (const token of this.checked.keys())
|
for (const token of this.checked.keys())
|
||||||
@@ -312,6 +316,17 @@ export class PackageStore {
|
|||||||
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
|
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
|
||||||
for (const token of this.assetErrors.keys())
|
for (const token of this.assetErrors.keys())
|
||||||
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
|
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
|
||||||
|
}
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
await this.exclusive(async () => {
|
||||||
|
await Promise.allSettled([...this.loading.values(), ...this.remembering.values()])
|
||||||
|
const root = await this.path()
|
||||||
|
for (const key of await readdir(root)) {
|
||||||
|
if (!hex.test(key)) continue
|
||||||
|
// Удаляется сама ссылка, без перехода к её цели.
|
||||||
|
await rm(join(root, key), { recursive: true, force: true })
|
||||||
|
this.forget(key)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
|
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
|
||||||
|
|||||||
+30
-1
@@ -219,6 +219,25 @@ app.whenReady().then(async () => {
|
|||||||
(await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')),
|
(await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')),
|
||||||
'control UI',
|
'control UI',
|
||||||
)
|
)
|
||||||
|
if (process.argv.includes('--clear-games')) {
|
||||||
|
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
||||||
|
await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button')
|
||||||
|
const clickDialog = (label) => evaluate(`Array.from(document.querySelectorAll('.n-dialog__action button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}).click()`)
|
||||||
|
await evaluate('document.querySelector("[data-testid=clear-games]").click()')
|
||||||
|
await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation')
|
||||||
|
await clickDialog('Отмена')
|
||||||
|
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
||||||
|
await poll(() => evaluate('!document.querySelector(".n-dialog")'), 'clear games cancelled')
|
||||||
|
await evaluate('document.querySelector("[data-testid=clear-games]").click()')
|
||||||
|
await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation again')
|
||||||
|
await clickDialog('Удалить всё')
|
||||||
|
await poll(async () => (await evaluate('window.screenBridge.packages()')).length === 0, 'packages cleared')
|
||||||
|
await poll(() => evaluate('document.body.textContent.includes("Скачанных игр пока нет")'), 'empty local games UI')
|
||||||
|
assert.equal((await evaluate('window.screenBridge.state()')).catalog.key, null)
|
||||||
|
console.log('SCREEN_CLEAR_GAMES_PASS')
|
||||||
|
app.exit(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (manual) {
|
if (manual) {
|
||||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
||||||
console.log(
|
console.log(
|
||||||
@@ -293,7 +312,17 @@ app.whenReady().then(async () => {
|
|||||||
await command({ action: 'pause', channel: 'main' })
|
await command({ action: 'pause', channel: 'main' })
|
||||||
assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false)
|
assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false)
|
||||||
assert.equal(await evaluate('document.body.textContent.includes("Вступление и видео")'), false)
|
assert.equal(await evaluate('document.body.textContent.includes("Вступление и видео")'), false)
|
||||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
await assert.rejects(evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`))
|
||||||
|
await evaluate('window.screenBridge.closeOutput()')
|
||||||
|
const localPackages = await evaluate('window.screenBridge.packages()')
|
||||||
|
assert.equal(localPackages[0].downloadedFiles, localPackages[0].totalFiles)
|
||||||
|
await poll(() => evaluate('!!document.querySelector("[data-testid=local-game-select] .n-base-selection")'), 'local game selector')
|
||||||
|
await evaluate('document.querySelector("[data-testid=local-game-select] .n-base-selection").click()')
|
||||||
|
await poll(() => evaluate('!!document.querySelector(".n-base-select-option")'), 'local game option')
|
||||||
|
await evaluate('document.querySelector(".n-base-select-option").click()')
|
||||||
|
await evaluate('document.querySelector("[data-testid=local-game-confirm]").click()')
|
||||||
|
await poll(async () => (await state()).catalog.key === offlinePackage.key, 'local game selected')
|
||||||
|
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||||||
await poll(async () => {
|
await poll(async () => {
|
||||||
const s = await state()
|
const s = await state()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
NTag,
|
NTag,
|
||||||
NTable,
|
NTable,
|
||||||
useMessage,
|
useMessage,
|
||||||
|
useDialog,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { PackageSummary } from '../types/screen'
|
||||||
import ScreenConnectionPanel from './ScreenConnectionPanel.vue'
|
import ScreenConnectionPanel from './ScreenConnectionPanel.vue'
|
||||||
import GameControlPanel from './GameControlPanel.vue'
|
import GameControlPanel from './GameControlPanel.vue'
|
||||||
import {
|
import {
|
||||||
@@ -29,10 +31,50 @@ const props = defineProps<{
|
|||||||
selectedKey: string | null
|
selectedKey: string | null
|
||||||
selectedName: string | null
|
selectedName: string | null
|
||||||
canConnectCurrent: boolean
|
canConnectCurrent: boolean
|
||||||
|
outputOpen: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: [] }>()
|
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: []; selectLocal: [key: string] }>()
|
||||||
const bridge = window.screenBridge
|
const bridge = window.screenBridge
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const cannotClearGames = computed(() => props.isBusy || props.state.busy || props.outputOpen || props.state.preparation.active)
|
||||||
|
function clearGames() {
|
||||||
|
if (cannotClearGames.value) return
|
||||||
|
dialog.warning({
|
||||||
|
title: 'Удалить все скачанные игры?',
|
||||||
|
content: 'Удалить все сохранённые игры, материалы, локальные планы экранов и снимки результатов? Для повторной загрузки потребуется сеть. Данные на сервере сохранятся.',
|
||||||
|
positiveText: 'Удалить всё',
|
||||||
|
negativeText: 'Отмена',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
if (!cannotClearGames.value) run({ action: 'clear-games' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const localPackages = ref<PackageSummary[]>([])
|
||||||
|
const localKey = ref<string | null>(null)
|
||||||
|
const localPackagesLoading = ref(false)
|
||||||
|
const localPackagesError = ref('')
|
||||||
|
const localOptions = computed(() => localPackages.value.map((pkg) => ({
|
||||||
|
label: `${pkg.name} · скачано ${pkg.downloadedFiles} из ${pkg.totalFiles}`,
|
||||||
|
value: pkg.key,
|
||||||
|
})))
|
||||||
|
watch(
|
||||||
|
[() => props.isReady, () => props.state.packagesRevision, () => props.state.busy, () => props.selectedKey],
|
||||||
|
async () => {
|
||||||
|
if (!bridge || !props.isReady || props.state.busy) return
|
||||||
|
localPackagesLoading.value = true
|
||||||
|
localPackagesError.value = ''
|
||||||
|
try {
|
||||||
|
localPackages.value = await bridge.packages()
|
||||||
|
if (!localPackages.value.some((pkg) => pkg.key === localKey.value)) localKey.value = null
|
||||||
|
} catch (error) {
|
||||||
|
localPackagesError.value = error instanceof Error ? error.message : 'Не удалось прочитать скачанные игры'
|
||||||
|
} finally {
|
||||||
|
localPackagesLoading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
async function openMaterialsFolder() {
|
async function openMaterialsFolder() {
|
||||||
if (!bridge || !props.selectedKey) return
|
if (!bridge || !props.selectedKey) return
|
||||||
try {
|
try {
|
||||||
@@ -223,8 +265,34 @@ const gameOptions = computed(() => {
|
|||||||
<div class="connection-content">
|
<div class="connection-content">
|
||||||
<div v-if="selectedKey" class="current-game">
|
<div v-if="selectedKey" class="current-game">
|
||||||
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
|
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
|
||||||
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')">Сменить игру</n-button>
|
<n-button :disabled="isBusy || state.busy || outputOpen" @click="emit('resetGame')">Сменить игру</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="!selectedKey">
|
||||||
|
<span>Скачанные игры</span>
|
||||||
|
<n-alert v-if="localPackagesError" type="warning">{{ localPackagesError }}</n-alert>
|
||||||
|
<div class="game-selection">
|
||||||
|
<n-select
|
||||||
|
v-model:value="localKey"
|
||||||
|
data-testid="local-game-select"
|
||||||
|
class="game-select"
|
||||||
|
clearable
|
||||||
|
:options="localOptions"
|
||||||
|
:loading="localPackagesLoading"
|
||||||
|
:disabled="isBusy || state.busy || outputOpen || localPackagesLoading"
|
||||||
|
placeholder="Выберите скачанную игру"
|
||||||
|
:input-props="{ 'aria-label': 'Скачанные игры' }"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
data-testid="local-game-confirm"
|
||||||
|
:disabled="!localKey || isBusy || state.busy || outputOpen || localPackagesLoading"
|
||||||
|
@click="emit('selectLocal', localKey!)"
|
||||||
|
>Выбрать скачанную игру</n-button>
|
||||||
|
</div>
|
||||||
|
<span v-if="!localPackagesLoading && !localPackagesError && !localPackages.length">Скачанных игр пока нет</span>
|
||||||
|
</template>
|
||||||
|
<p v-if="(selectedKey && loadedMaterials < state.materials.length) || (!selectedKey && localPackages.some((pkg) => pkg.key === localKey && pkg.downloadedFiles < pkg.totalFiles))">
|
||||||
|
Без сети доступны только скачанные материалы.
|
||||||
|
</p>
|
||||||
<template v-if="state.authenticated">
|
<template v-if="state.authenticated">
|
||||||
<div v-if="!selectedKey" class="game-selection">
|
<div v-if="!selectedKey" class="game-selection">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -256,14 +324,12 @@ const gameOptions = computed(() => {
|
|||||||
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
|
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
|
||||||
</svg>
|
</svg>
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="!selectedKey"
|
|
||||||
class="game-confirm"
|
|
||||||
:disabled="gameId === null || isBusy || state.busy"
|
:disabled="gameId === null || isBusy || state.busy"
|
||||||
@click="run({ action: 'select-game', gameId: gameId! })"
|
@click="run({ action: 'select-game', gameId: gameId! })"
|
||||||
>Выбрать игру</n-button
|
>Выбрать игру</n-button
|
||||||
>
|
>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||||
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
||||||
@@ -389,6 +455,15 @@ const gameOptions = computed(() => {
|
|||||||
</n-collapse-item>
|
</n-collapse-item>
|
||||||
</n-collapse>
|
</n-collapse>
|
||||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||||
|
<n-button
|
||||||
|
class="clear-games"
|
||||||
|
data-testid="clear-games"
|
||||||
|
text
|
||||||
|
type="error"
|
||||||
|
size="small"
|
||||||
|
:disabled="cannotClearGames"
|
||||||
|
@click="clearGames"
|
||||||
|
>Удалить все скачанные игры</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
<GameControlPanel v-if="bridge && isReady" :state="state" :is-busy="isBusy" :selected-key="selectedKey" :selected-name="selectedName" @action="run">
|
<GameControlPanel v-if="bridge && isReady" :state="state" :is-busy="isBusy" :selected-key="selectedKey" :selected-name="selectedName" @action="run">
|
||||||
@@ -405,6 +480,10 @@ const gameOptions = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.clear-games {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.materials-list {
|
.materials-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -525,10 +604,6 @@ const gameOptions = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.connection-content > .auth-action,
|
.connection-content > .auth-action,
|
||||||
.connection-content > .game-confirm {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-error {
|
.connection-error {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -198,6 +198,14 @@ async function resetGame() {
|
|||||||
onlineState.value = await bridge.onlineState()
|
onlineState.value = await bridge.onlineState()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
async function selectLocalGame(key: string) {
|
||||||
|
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
|
||||||
|
await run(async () => {
|
||||||
|
acceptState(await bridge.selectPackage(key))
|
||||||
|
onlineState.value = await bridge.onlineState()
|
||||||
|
await refreshHost()
|
||||||
|
})
|
||||||
|
}
|
||||||
async function refreshHost() {
|
async function refreshHost() {
|
||||||
if (!bridge) return
|
if (!bridge) return
|
||||||
host.value = await bridge.host()
|
host.value = await bridge.host()
|
||||||
@@ -249,6 +257,8 @@ onUnmounted(() => {
|
|||||||
@action="runOnline"
|
@action="runOnline"
|
||||||
@cancel="cancelOnline"
|
@cancel="cancelOnline"
|
||||||
@reset-game="resetGame"
|
@reset-game="resetGame"
|
||||||
|
@select-local="selectLocalGame"
|
||||||
|
:output-open="state?.outputOpen ?? false"
|
||||||
:selected-key="state?.catalog.key ?? null"
|
:selected-key="state?.catalog.key ?? null"
|
||||||
:selected-name="state?.catalog.name ?? null"
|
:selected-name="state?.catalog.name ?? null"
|
||||||
:can-connect-current="
|
:can-connect-current="
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ export type OnlineIntent =
|
|||||||
| { action: 'login'; server: string; email: string; password: string }
|
| { action: 'login'; server: string; email: string; password: string }
|
||||||
| { action: 'retry-materials' }
|
| { action: 'retry-materials' }
|
||||||
| { action: 'connect'; gameId?: number }
|
| { action: 'connect'; gameId?: number }
|
||||||
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' | 'clear-games' }
|
||||||
| { action: 'select-game' | 'download' | 'check'; gameId: number }
|
| { action: 'select-game' | 'download' | 'check'; gameId: number }
|
||||||
| { action: 'remove'; key: string }
|
| { action: 'remove'; key: string }
|
||||||
export interface OnlineState {
|
export interface OnlineState {
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export interface PackageSummary {
|
|||||||
key: string
|
key: string
|
||||||
version: string
|
version: string
|
||||||
name: string
|
name: string
|
||||||
|
downloadedFiles: number
|
||||||
|
totalFiles: number
|
||||||
}
|
}
|
||||||
export interface ScreenPlan {
|
export interface ScreenPlan {
|
||||||
steps: ScreenStep[]
|
steps: ScreenStep[]
|
||||||
|
|||||||
Reference in New Issue
Block a user