mirror of
https://github.com/vesper8/Quest2Frame.git
synced 2026-10-28 00:01:15 +01:00
Add resumable multi-game Quest backup and Frame installation queues
This commit is contained in:
1 parent
2ce46d164b
commit
6883cb86dc
6 files changed
+285
-2
No files matched your search
@@ -4,6 +4,8 @@ Launch `Launch.cmd` or run `python app.py` from the repository root. Local `tool
|
||||
|
||||
## Workflow
|
||||
|
||||
For multiple games, Ctrl/Shift-select packages and choose **Use selected game(s)**. The batch queue supports per-game settings and artwork, sequential conversion/installation, saved progress, and retrying failed entries. See the [batch instructions](README.md#multiple-games--batch-queue). Keep Quest connected until all requested conversions finish, then switch to Frame. Steam integration currently restarts Steam for each installed game.
|
||||
|
||||
0. Enter your Frame SSH host (default `frame`), port, username (`steamos`), and password. Test the connection. Only host/port/username are saved; the password stays in memory. On first connection, attach Frame over USB so the app can verify its SSH host key. Unknown or changed keys are never silently accepted.
|
||||
1. Plug in Quest, unlock it, accept USB debugging, and click **Scan Quest**. Filter the installed third-party package list and select a game.
|
||||
2. Choose a library title, resolution, compatibility options, and optionally the matching Steam store App ID for official artwork. Click **Back up Quest game and convert**. Leave Quest connected until conversion completes. The app copies the APK, OBB expansion files, and accessible external saves; originals remain untouched.
|
||||
|
||||
@@ -133,6 +133,18 @@ Open the new entry in Frame's Steam library. The first launch initializes its Le
|
||||
|
||||
## Change settings or resume later
|
||||
|
||||
### Multiple games / batch queue
|
||||
|
||||
1. On **Select Quest game**, Ctrl-click or Shift-click multiple packages (or use **Select all visible**), then **Use selected game(s)**. Changing the filter clears the selection.
|
||||
2. In the batch window, select each row to edit its title, resolution, controller/foveation options, and artwork App ID. Click **Save game settings** before switching rows. Blank titles use the APK title. New games inherit the main window's resolution/compatibility settings, but never another game's title or artwork ID.
|
||||
3. Select the games to process and click **Back up & convert selected**. Keep the original Quest attached for the entire batch. Games run sequentially; failures are recorded individually and remaining games continue.
|
||||
4. If automatic artwork search is enabled on the main Convert tab, candidates are saved after each conversion. Select each row to review its candidates, choose the correct edition, and save. You can also enter a title and use **Find artwork by title** manually.
|
||||
5. Switch the USB connection to Frame, select ready rows, and click **Install selected ready games**. Each game keeps its own settings/artwork. With Steam integration enabled, Steam currently restarts **once per game**; finish active games first.
|
||||
|
||||
Queues are automatically saved under `games/batches/` without SSH credentials. Use **Open batch / saved games** to reopen a queue after restarting the app. Cancel that file picker to create an empty queue, then use **Add saved conversions** to select existing `game.json` files. Duplicate packages are skipped.
|
||||
|
||||
Retries reuse completed backups/builds and skip already installed rows. Failed installs can be selected and retried; if a shortcut failed after file installation, the retry reinstalls the saved build. Interrupted jobs may need retrying. Select a failed row to see its error; the main activity log contains details. Wait for the active job to finish before closing either window.
|
||||
|
||||
Use **Open a previously converted game** and select `games/<package>/build-.../game.json`.
|
||||
|
||||
- Change resolution/controller options on step 2, then select **Apply resolution / compatibility settings only** on step 3. Restart the game afterward. With a verified host key, this needs SSH but not USB or a rebuild.
|
||||
|
||||
@@ -72,8 +72,12 @@ class Wizard(tk.Tk):
|
||||
ttk.Button(page,text='Scan Quest and list installed packages',command=self.scan).pack(anchor='w',pady=8)
|
||||
entry=ttk.Entry(page,textvariable=self.vars['filter']);entry.pack(fill='x')
|
||||
self.vars['filter'].trace_add('write',lambda *_:self.filter_packages())
|
||||
self.listbox=tk.Listbox(page,height=8,exportselection=False);self.listbox.pack(fill='both',expand=True,pady=8)
|
||||
ttk.Button(page,text='Use selected game →',command=self.select_game).pack(anchor='w')
|
||||
self.listbox=tk.Listbox(page,height=8,exportselection=False,selectmode='extended');self.listbox.pack(fill='both',expand=True,pady=8)
|
||||
ttk.Label(page,text='Ctrl-click or Shift-click to select multiple games. Filtering clears selection.').pack(anchor='w')
|
||||
controls=ttk.Frame(page);controls.pack(fill='x')
|
||||
ttk.Button(controls,text='Use selected game(s) →',command=self.select_game).pack(side='left')
|
||||
ttk.Button(controls,text='Select all visible',command=lambda:self.listbox.selection_set(0,'end')).pack(side='left',padx=8)
|
||||
ttk.Button(controls,text='Open batch / saved games…',command=self.open_batch).pack(side='left')
|
||||
|
||||
def convert_page(self):
|
||||
page=self.pages[2]
|
||||
@@ -162,6 +166,8 @@ class Wizard(tk.Tk):
|
||||
if self.busy:return
|
||||
indexes=self.listbox.curselection()
|
||||
if not indexes: messagebox.showinfo('Select a game','Choose an installed package first.');return
|
||||
if len(indexes)>1:
|
||||
self.show_batch([self.listbox.get(i) for i in indexes]);return
|
||||
self.package=self.listbox.get(indexes[0]);self.selected.set(self.package)
|
||||
self.vars['title'].set('Tetris Effect: Connected (Quest)' if self.package=='com.enhanceexperience.tetriseffect' else '')
|
||||
self.vars['steam_id'].set('')
|
||||
@@ -169,6 +175,19 @@ class Wizard(tk.Tk):
|
||||
self.tabs.select(2)
|
||||
if self.auto_art.get():self.search_artwork(query=self.vars['title'].get() or suggested_title(self.package))
|
||||
|
||||
def show_batch(self,packages=(),path=None):
|
||||
if self.busy:return
|
||||
if getattr(self,'batch_window',None) and self.batch_window.winfo_exists():
|
||||
self.batch_window.lift();return
|
||||
from batch import BatchWindow
|
||||
try:self.batch_window=BatchWindow(self,packages,self.serial,path)
|
||||
except Exception as exc:messagebox.showerror('Batch queue',str(exc))
|
||||
|
||||
def open_batch(self):
|
||||
if self.busy:return
|
||||
path=filedialog.askopenfilename(title='Open saved batch (Cancel to create an empty queue)',initialdir=ROOT/'games/batches',filetypes=[('Batch queue','*.json')])
|
||||
self.show_batch(path=path or None)
|
||||
|
||||
def search_artwork(self, query=None):
|
||||
if self.busy:return
|
||||
query = query or self.vars['title'].get().strip()
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
"""Sequential, resumable multi-game work; no concurrent OVR Port workspaces."""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
from bridge import ROOT, write_json, settings_text, valid_package, artwork_candidates
|
||||
|
||||
def run_batch(items, action, bridge, serial, credentials, add_steam, save, auto_art=False):
|
||||
for index, item in enumerate(items, 1):
|
||||
bridge.log(f'Batch {index}/{len(items)}: {item["package"]} — {action}')
|
||||
item['error']=''
|
||||
try:
|
||||
item['status']='Working';save()
|
||||
if action=='convert':
|
||||
backup=item.get('backup')
|
||||
if not backup:
|
||||
backup=str(bridge.backup(serial,item['package']))
|
||||
item['backup']=backup;save()
|
||||
# Successful builds are reused, not reconverted on retry.
|
||||
if not item.get('build'):
|
||||
item['build']=str(bridge.convert(backup,**item['options']))
|
||||
item['status']='Ready'
|
||||
if auto_art and not item['options']['steam_id']:
|
||||
try:
|
||||
game=json.loads((Path(item['build'])/'game.json').read_text())
|
||||
item['candidates']=artwork_candidates(game['title'])
|
||||
if not item['options']['title']:item['options']['title']=game['title']
|
||||
except Exception as exc:bridge.log('Artwork search unavailable: '+str(exc))
|
||||
else:
|
||||
folder=Path(item['build'])
|
||||
game=json.loads((folder/'game.json').read_text())
|
||||
if game['package']!=item['package']:raise ValueError('Build/package mismatch')
|
||||
game.update({k:v for k,v in item['options'].items() if k!='title' or v.strip()})
|
||||
write_json(folder/'game.json',game)
|
||||
with bridge.connect(**credentials) as client:
|
||||
bridge.install(folder,client,add_steam,item['options']['steam_id'])
|
||||
item['status']='Installed'
|
||||
except Exception as exc:
|
||||
item['status']='Failed';item['error']=str(exc)
|
||||
bridge.log(f'FAILED {item["package"]}: {exc}')
|
||||
save()
|
||||
return items
|
||||
|
||||
class BatchWindow(tk.Toplevel):
|
||||
def __init__(self, parent, packages=(), serial=None, path=None):
|
||||
super().__init__(parent);self.parent=parent;self.serial=serial
|
||||
self.title('Quest → Frame — batch queue');self.geometry('1050x760');self.minsize(850,700)
|
||||
self.path=Path(path) if path else ROOT/'games/batches'/f'{uuid.uuid4().hex}.json'
|
||||
defaults=parent.options();self.items=[];self.active=None
|
||||
if path:
|
||||
data=json.loads(self.path.read_text());self.items=data['items'];self.serial=data.get('serial')
|
||||
for item in self.items:
|
||||
valid_package(item['package']);settings_text(item['options']['scale'])
|
||||
if item['status']=='Working':item['status']='Interrupted'
|
||||
else:
|
||||
for package in packages:
|
||||
opts=dict(defaults,title='',steam_id='')
|
||||
self.items.append(dict(package=package,options=opts,status='Queued',error='',backup='',build=''))
|
||||
box=ttk.Frame(self,padding=16);box.pack(fill='both',expand=True)
|
||||
ttk.Label(box,text='1. Configure games → 2. Back up & convert → 3. Switch to Frame → 4. Install',font=('Segoe UI',12,'bold')).pack(anchor='w')
|
||||
ttk.Label(box,text='Ctrl/Shift selects multiple rows. Settings below apply only to the highlighted editor game.').pack(anchor='w',pady=5)
|
||||
self.tree=ttk.Treeview(box,columns=('package','status'),show='headings',selectmode='extended',height=8)
|
||||
self.tree.heading('package',text='Quest package');self.tree.heading('status',text='Status')
|
||||
self.tree.column('package',width=630);self.tree.column('status',width=150)
|
||||
self.tree.pack(fill='both',expand=True)
|
||||
self.tree.bind('<<TreeviewSelect>>',self.selected)
|
||||
ttk.Button(box,text='Select all games',command=lambda:self.tree.selection_set(self.tree.get_children())).pack(anchor='w',pady=5)
|
||||
form=ttk.LabelFrame(box,text='Per-game settings — Save before switching games',padding=10);form.pack(fill='x')
|
||||
self.editing=tk.StringVar(value='Select a game to edit');ttk.Label(form,textvariable=self.editing).grid(row=0,column=0,columnspan=4,sticky='w')
|
||||
self.values={name:tk.StringVar() for name in ('title','scale','steam_id')}
|
||||
for row,(name,label) in enumerate((('title','Title (blank = APK title)'),('scale','Resolution 50–200%'),('steam_id','Steam artwork App ID')),1):
|
||||
ttk.Label(form,text=label).grid(row=row,column=0,sticky='w',padx=5)
|
||||
ttk.Entry(form,textvariable=self.values[name],width=45).grid(row=row,column=1,sticky='ew',pady=3)
|
||||
form.columnconfigure(1,weight=1)
|
||||
self.controllers=tk.BooleanVar();self.foveation=tk.BooleanVar()
|
||||
ttk.Checkbutton(form,text='Physical controllers',variable=self.controllers).grid(row=1,column=2,padx=10)
|
||||
ttk.Checkbutton(form,text='Quest foveation fix',variable=self.foveation).grid(row=2,column=2,padx=10)
|
||||
ttk.Button(form,text='Save game settings',command=self.save_editor).grid(row=3,column=2,padx=10)
|
||||
ttk.Button(form,text='Find artwork by title',command=self.search_art).grid(row=4,column=0,pady=5)
|
||||
self.matches=ttk.Combobox(form,state='readonly',width=50);self.matches.grid(row=4,column=1,columnspan=2,sticky='ew')
|
||||
self.candidates=[];self.matches.bind('<<ComboboxSelected>>',self.choose_art)
|
||||
self.add_steam=tk.BooleanVar(value=parent.add_steam.get())
|
||||
ttk.Checkbutton(box,text='Add each installed game to Steam (Steam restarts for each game)',variable=self.add_steam).pack(anchor='w',pady=8)
|
||||
buttons=ttk.Frame(box);buttons.pack(fill='x')
|
||||
ttk.Button(buttons,text='Back up & convert selected',command=lambda:self.start('convert')).pack(side='left',padx=3)
|
||||
ttk.Button(buttons,text='Install selected ready games',command=lambda:self.start('install')).pack(side='left',padx=3)
|
||||
ttk.Button(buttons,text='Add saved conversions…',command=self.add_builds).pack(side='left',padx=3)
|
||||
self.message=tk.StringVar(value='Keep Quest connected throughout the conversion batch.')
|
||||
ttk.Label(box,textvariable=self.message,wraplength=980).pack(anchor='w',pady=8)
|
||||
self.protocol('WM_DELETE_WINDOW',self.close)
|
||||
self.refresh();self.tree.selection_set(self.tree.get_children());self.save();self.poll_id=self.after(250,self.poll)
|
||||
|
||||
def save(self):write_json(self.path,dict(serial=self.serial,items=self.items))
|
||||
def refresh(self):
|
||||
for index,item in enumerate(self.items):
|
||||
ident=str(index);values=(item['package'],item['status'])
|
||||
if self.tree.exists(ident):self.tree.item(ident,values=values)
|
||||
else:self.tree.insert('', 'end',iid=ident,values=values)
|
||||
def poll(self):self.refresh();self.poll_id=self.after(250,self.poll)
|
||||
def selected(self,event=None):
|
||||
if self.parent.busy:return
|
||||
selection=self.tree.selection()
|
||||
if not selection:return
|
||||
focus=self.tree.focus();ident=focus if focus in selection else selection[0]
|
||||
self.active=int(ident);item=self.items[self.active]
|
||||
self.editing.set(item['package'])
|
||||
for k,v in self.values.items():v.set(str(item['options'][k]))
|
||||
self.controllers.set(item['options']['controllers']);self.foveation.set(item['options']['foveation'])
|
||||
self.candidates=item.get('candidates',[])
|
||||
self.matches.configure(values=[f"{m['title']} — {m['id']}" for m in self.candidates])
|
||||
self.matches.set('Choose a candidate and Save' if self.candidates else 'Enter a title to search for artwork')
|
||||
self.message.set(item['error'] or item['status'])
|
||||
def save_editor(self):
|
||||
if self.parent.busy or self.active is None:return False
|
||||
try:
|
||||
opts={k:v.get().strip() for k,v in self.values.items()};opts['scale']=float(opts['scale'])
|
||||
settings_text(opts['scale'])
|
||||
if opts['steam_id'] and not opts['steam_id'].isdigit():raise ValueError('Steam App ID must be numeric')
|
||||
opts.update(controllers=self.controllers.get(),foveation=self.foveation.get())
|
||||
except ValueError as exc:messagebox.showerror('Invalid settings',str(exc),parent=self);return False
|
||||
self.items[self.active]['options']=opts;self.save();return True
|
||||
def search_art(self):
|
||||
if not self.save_editor():return
|
||||
title=self.values['title'].get().strip()
|
||||
if not title:self.message.set('Enter a title to search. Blank titles use APK labels during conversion.');return
|
||||
active=self.active
|
||||
def work():
|
||||
try:return artwork_candidates(title),None
|
||||
except Exception as exc:return [],str(exc)
|
||||
def done(result):
|
||||
matches,error=result
|
||||
if active!=self.active:return
|
||||
self.candidates=matches;self.matches.configure(values=[f"{m['title']} — {m['id']}" for m in matches])
|
||||
self.matches.set('Choose the matching edition' if matches else 'No matches')
|
||||
self.message.set(error or 'Choose a candidate, then Save game settings.')
|
||||
self.parent.run_job('Searching Steam artwork…',work,done)
|
||||
def choose_art(self,event=None):
|
||||
index=self.matches.current()
|
||||
if not self.parent.busy and 0<=index<len(self.candidates):self.values['steam_id'].set(self.candidates[index]['id'])
|
||||
def add_builds(self):
|
||||
if self.parent.busy:return
|
||||
paths=filedialog.askopenfilenames(parent=self,title='Select saved game manifests',initialdir=ROOT/'games',filetypes=[('Game manifests','game.json')])
|
||||
for path in paths:
|
||||
try:
|
||||
game=json.loads(Path(path).read_text());package=valid_package(game['package'])
|
||||
if any(i['package']==package for i in self.items):continue
|
||||
self.items.append(dict(package=package,options={k:game[k] for k in ('title','scale','steam_id','controllers','foveation')},build=str(Path(path).parent),backup=game['backup'],status='Ready',error=''))
|
||||
except Exception as exc:messagebox.showerror('Invalid manifest',str(exc),parent=self)
|
||||
self.refresh();self.save()
|
||||
def start(self,action):
|
||||
if self.parent.busy:return
|
||||
if self.active is not None and not self.save_editor():return
|
||||
items=[self.items[int(i)] for i in self.tree.selection()]
|
||||
items=[i for i in items if (not i.get('build') if action=='convert' else i.get('build') and i['status']!='Installed')]
|
||||
if not items:self.message.set('Select unconverted games or ready builds; completed items are skipped.');return
|
||||
credentials=self.parent.credentials();add=self.add_steam.get();auto_art=self.parent.auto_art.get()
|
||||
def work():
|
||||
if action=='convert':
|
||||
if self.serial not in [s for s,_ in self.parent.bridge.quests()]:raise RuntimeError('Reconnect the original Quest and allow USB debugging.')
|
||||
return run_batch(items,action,self.parent.bridge,self.serial,credentials,add,self.save,auto_art)
|
||||
def done(result):
|
||||
failed=sum(i['status']=='Failed' for i in result)
|
||||
self.refresh();self.selected()
|
||||
self.message.set(f'Batch finished: {len(result)-failed} completed, {failed} failed. '+('Switch to Frame, select ready games, then Install.' if action=='convert' else 'See the log for details.'))
|
||||
self.parent.run_job(f'Batch {action}: {len(items)} games. Keep the headset connected…',work,done)
|
||||
def close(self):
|
||||
if self.parent.busy:messagebox.showinfo('Working','Wait for the batch to finish.',parent=self);return
|
||||
if self.active is not None and not self.save_editor():return
|
||||
self.after_cancel(self.poll_id)
|
||||
self.destroy()
|
||||
+20
@@ -2,6 +2,9 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import tkinter as tk
|
||||
import tempfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from app import Wizard
|
||||
|
||||
class ArtworkUITests(unittest.TestCase):
|
||||
@@ -39,3 +42,20 @@ class ArtworkUITests(unittest.TestCase):
|
||||
with patch('app.artwork_candidates',side_effect=search):self.app.search_artwork()
|
||||
self.assertEqual(self.app.art_candidates,[])
|
||||
self.assertEqual(self.app.vars['steam_id'].get(),'')
|
||||
|
||||
def test_batch_settings_and_queue_reload(self):
|
||||
from batch import BatchWindow
|
||||
with tempfile.TemporaryDirectory() as tmp, patch('batch.ROOT',Path(tmp)):
|
||||
batch=BatchWindow(self.app,['com.test.one','com.test.two'],'quest')
|
||||
batch.tree.selection_set('0');batch.tree.focus('0');batch.selected()
|
||||
batch.values['scale'].set('75');batch.values['steam_id'].set('123');batch.save_editor()
|
||||
batch.tree.selection_set('1');batch.tree.focus('1');batch.selected()
|
||||
self.assertEqual(batch.values['scale'].get(),'100.0')
|
||||
batch.values['scale'].set('125');batch.save_editor()
|
||||
path=batch.path;batch.close()
|
||||
saved=json.loads(path.read_text())
|
||||
self.assertNotIn('password',saved)
|
||||
reopened=BatchWindow(self.app,path=path)
|
||||
self.assertEqual([i['options']['scale'] for i in reopened.items],[75,125])
|
||||
self.assertEqual(reopened.items[0]['options']['steam_id'],'123')
|
||||
reopened.close()
|
||||
@@ -0,0 +1,58 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from batch import run_batch
|
||||
|
||||
def item(package):
|
||||
return dict(package=package,options=dict(title='',scale=100,steam_id='',controllers=True,foveation=True),status='Queued',error='',backup='',build='')
|
||||
|
||||
class BatchTests(unittest.TestCase):
|
||||
def test_partial_failure_continues_and_saved_backup_is_reused(self):
|
||||
items=[item('com.test.one'),item('com.test.two')];bridge=MagicMock();save=MagicMock()
|
||||
bridge.backup.side_effect=['backup-one','backup-two']
|
||||
bridge.convert.side_effect=[RuntimeError('bad APK'),'build-two']
|
||||
run_batch(items,'convert',bridge,'quest',{},False,save)
|
||||
self.assertEqual([i['status'] for i in items],['Failed','Ready'])
|
||||
self.assertEqual(items[0]['backup'],'backup-one')
|
||||
bridge.backup.reset_mock();bridge.convert.side_effect=None;bridge.convert.return_value='build-one'
|
||||
run_batch(items,'convert',bridge,'quest',{},False,save)
|
||||
bridge.backup.assert_not_called()
|
||||
self.assertEqual([i['build'] for i in items],['build-one','build-two'])
|
||||
self.assertGreater(save.call_count,4)
|
||||
|
||||
def test_install_uses_distinct_options_and_continues(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
items=[item('com.test.one'),item('com.test.two')]
|
||||
for index,entry in enumerate(items):
|
||||
folder=Path(tmp)/str(index);folder.mkdir();entry['build']=str(folder)
|
||||
(folder/'game.json').write_text(json.dumps({'package':entry['package'],'title':'APK title'}))
|
||||
entry['options']['scale']=75+index*50
|
||||
entry['options']['steam_id']=str(100+index)
|
||||
bridge=MagicMock();bridge.install.side_effect=[RuntimeError('connection lost'),('remote',1)]
|
||||
run_batch(items,'install',bridge,None,{'host':'frame'},True,lambda:None)
|
||||
self.assertEqual([i['status'] for i in items],['Failed','Installed'])
|
||||
self.assertEqual([c.args[3] for c in bridge.install.call_args_list],['100','101'])
|
||||
for index,entry in enumerate(items):
|
||||
game=json.loads((Path(entry['build'])/'game.json').read_text())
|
||||
self.assertEqual(game['scale'],75+index*50)
|
||||
self.assertEqual(game['title'],'APK title')
|
||||
|
||||
def test_package_mismatch_not_installed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
entry=item('com.test.one');entry['build']=tmp
|
||||
(Path(tmp)/'game.json').write_text(json.dumps({'package':'com.test.other'}))
|
||||
bridge=MagicMock();run_batch([entry],'install',bridge,None,{},False,lambda:None)
|
||||
bridge.install.assert_not_called();self.assertEqual(entry['status'],'Failed')
|
||||
|
||||
def test_artwork_failure_does_not_fail_conversion(self):
|
||||
bridge=MagicMock();bridge.backup.return_value='backup'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bridge.convert.return_value=tmp
|
||||
(Path(tmp)/'game.json').write_text(json.dumps({'title':'Test'}))
|
||||
entry=item('com.test.one')
|
||||
with patch('batch.artwork_candidates',side_effect=TimeoutError()):
|
||||
run_batch([entry],'convert',bridge,'quest',{},False,lambda:None,True)
|
||||
self.assertEqual(entry['status'],'Ready')
|
||||
Reference in new issue
Block a user