Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Недавно от знакомых прилетела задачка написать программу для самотестирования. Порылся в инете, думал в лёгкую найду наработки, но ничего кроме платных и бесплатных конструкторов тестов не нашёл (может плохо искал, кто знает…). Мне показалось, что устанавливать какие-то инородные проги, а потом ещё туда все вопросы ручками забивать - совсем некрасиво. Так родилось приложение для самотестирования, написанное на Python с помощью GUI библиотеки Tkinter.
В первую очередь нужно было составить требования к структуре файла, чтобы не пришлось писать всеобъемлющий парсер на регулярках.
Я обозначил следующие требования:
- Каждый вопрос должен начинаться с числа и точки (метка для идентификации вопросов)
- Каждый ответ может начинаться с любого символа, но затем обязательно должна идти скобка (метка для идентификации ответов)
- Правильные варианты ответа должны быть отмечены знаком "+" (метка для идентификации правильных ответов)
- Вариантов ответа должно быть ровно 5:
!Если ответов меньше ПЯТИ, то их НУЖНО добить пустыми, например, если их три то добавить 4) и 5)
!Если вариантов ответа более ПЯТИ, то приложение не будет корректно работать
Ограничения по количеству ответов можно обойти, если при запуске программы попросить пользователя ввести максимальное кол-во ответов в вопросе и в автомате добивать недостающие ответы пустышками посредством алгоритмических ухищрений. Однако пока я не стал этого реализовывать, ибо не увидел великой надобности :)
Пример содержимого txt файла:
Напомню, что построение программы с помощью Tkinter основывается на СОП (событийно-ориентированном программировании). При таком подходе в коде явным образом выделяется главный цикл приложения, тело которого состоит из двух частей: выборки события и обработки события.
В моём случае главный цикл представляет из себя class Block, который вызывается в mainloop():
#########################
# Блок обработки событий.
#########################
class Block:
# Инициализация объектов
def __init__(self, master):
# счетчик количества вопросов
self.qc = 0
# счетчик количества правильных ответов
self.true_points = 0
# *Здесь отображено лишь начало Block
#################
# Основной цыкл.
#################
window = tk.Tk()
window.title('Конструктор тестов (VladislavSoren)')
window.resizable(width=False, height=False)
window.geometry('720x480+400+100')
window['bg'] = 'grey'
first_block = Block(window)
window.mainloop()
Выборки события представляют из себя интерфейс взаимодействия с пользователем, т.е. виджеты (кнопки, чекбоксы и т.п.). Обработчиками событий являются функции класса (методы), которые вызываются, когда произошло то или иное событие, например, пользователь нажал кнопку.
Для большего понимания происходящего пробежимся по алгоритму работы программы, а потом рассмотрим каждый этап в отдельности:
1. Пользователь указывает путь к txt файлу с тестом.
2. Производится парсинг содержимого теста, т.е. формируются список вопросов и список ответов.
3. Пользователь выбирает режим работы (Рандомный порядок вопросов vs обычный порядок).
4. Генерируется список с порядком вопросов.
5. «Правильные ответы» пользователь отмечает в чекбоксах.
6. Затем пользователь нажимает кнопку "Ответить". После данного события система переходит в состояние проверки, где:
- Истинно-правильные ответы подсвечиваются зелёным
- Изменяется статус индикатора. Если ошибок НЕТ, то индикатор примет значение "Всё верно", иначе "Есть ошибки"
- Если "Всё верно", то увеличивается счётчик верных ответов на единицу
7. После того, как пользователь проанализировал свой ответ, он нажимает кнопку "Следующий". После данного события система переходит в состояние смены вопроса, где:
- Счётчик вопросов увеличивается на единицу
- Меняется вопрос и ответы
- Статус индикатора изменяется на исходный «Выберите ответы:»
8. Когда пользователь ответил на последний вопрос и нажал "Следующий", то высвечивается кол-во правильных ответов за весь тест.
А теперь немного поподробнее:
1. Пользователь указывает путь к txt файлу с тестом.
################################
# Загрузка теста и его парсинг
################################
window = tk.Tk()
# Пользователь указывает путь к txt файлу
text_path = filedialog.askopenfilename(title='Выберите тест')
window.destroy()
2. Производится парсинг содержимого теста, т.е. формируются список вопросов, список ответов и вектор меток.
# разделяем файл на строки по пробелам
Text = Text.split(sep='\n')
# отделяем вопросы
pattern = r'^\d{1,3}\.' # строка начинается с 1 или 3 цыфр, а затем идёт точка (ограничение на 999 вопросов!!!)
Text_q = [i for i in Text if len(re.findall(pattern, i)) != 0]
print('Всего вопросов:', len(Text_q))
# отделяем ответы
pattern = r'^.\)' # строка начинается с одного любого символа, а затем идёт скобка
Text_a = [i for i in Text if len(re.findall(pattern, i)) != 0]
print('Всего ответов:', len(Text_a))
Далее проходимся по ответам и создаём отдельный вектор с метками правильных ответов формата [1 0 0 0 1 0…], как раз для этого и были нужны «+» при разметке файла.
После данного шага получаем список вопросов Test_q, список ответов БЕЗ меток Test_a и вектор меток flags.
3. Пользователь выбирает режим работы (Рандомный порядок вопросов vs обычный порядок).
################################################################
# Выбор режима (Рандомный порядок вопросов vs обычный порядок).
################################################################
window = tk.Tk()
window.title('Конструктор тестов (VladislavSoren)')
window.resizable(width=False, height=False)
window.geometry('240x60+600+300')
window['bg'] = 'white'
# функция закрытия окна при выборе рандомного режима
def accept():
window.destroy()
RandomState = tk.IntVar() # в данную переменную записывается состояние box (1 или 0)
box = Checkbutton(window, text='Включить случайный порядок?',
variable=RandomState,
font=('Arial Bold', 10),
relief='solid',
bd='1'
)
box['command']=accept
box.place(x=12, y=20)
window.mainloop()
Здесь главным действующим лицом выступает CheckButton (Чекбокс). Если пользователь ставит в боксе галочку, то переменная RandomState примет значение 1, иначе будет равна 0.
Как только пользователь поставил галку – окно закрывается, а RandomState=1. Если же нам НЕ нужен рандомный режим, то просто можно закрыть всплывающее окно.
4. Генерируется список с порядком вопросов.
#######################################
# Получение списка с порядком вопросов.
#######################################
Text_q_dict = {}
for i, q in enumerate(Text_q):
Text_q_dict[i] = q
np1 = np.arange(len(Text_q))
order_list = np1.tolist()
# Если выбран рандомный режим, то перемешиваем порядок вопросов
if RandomState.get():
random.shuffle(order_list)
При выборе рандомного режима order_list будет вида [2 0 3 1 5 4].
5. «Правильные ответы» пользователь отмечает в чекбоксах.
Тут комментарии излишни.
6. Затем пользователь нажимает кнопку "Ответить". Система переходит в состояние проверки.
Кратко опишу структуру class Block, который вызывается в главном цикле mainloop().
# Инициализация объектов
def __init__(self, master):
# счетчик количества вопросов
self.qc = 0
# счетчик количества правильных ответов
self.true_points = 0
# Инициализация вопроса и ответов
self.quest = scrolledtext.ScrolledText(window, width=75,height=5)
index = order_list[self.qc] # индекс вопроса определяем по order_list
self.quest.insert(tk.INSERT, Text_q[index])
Метод __inite__ выполняется единожды при создании объекта класса, т.е. в нём происходит инициализация всех объектов, создаются виджеты и все необходимые привязки событий.
Вот основные привязки:
# Инициализация лэйблов и кнопок
self.mark = tk.Label(window, text='Выберите ответы: ', font=('Arial Bold', 12), fg='Green', bg='white')
self.ButGiveAns = Button(text='Ответить', font=('Arial Bold', 12)) # кнопка перехода в состояние "ПРОВЕРКА"
self.ButGiveAns['command'] = self.show_res
self.ButNext = Button(text='Следующий', font=('Arial Bold', 12)) # кнопка перехода в состояние "СМЕНА ВОПРОСА"
self.ButNext['command'] = self.next_q
При нажатии кнопки «Ответить» вызовется метод show_res:
# Функция обработки события "ПРОВЕРКА" (нажатие кнопки "Ответить")
def show_res(self):
# определяем текущий индекс вопроса
index = order_list[self.qc]
# создаем вектор таргетов и ответов
targets = flags[5*index : 5*index + 5]
answers = np.zeros(5)
answers[0] = self.check1.get() # записываем состояние box1 (0 или 1) в нулевой бит вектора answers
answers[1] = self.check2.get()
answers[2] = self.check3.get()
answers[3] = self.check4.get()
answers[4] = self.check5.get()
# подсвечиваем истинно верные ответы зелёным цветом (задний фон чекбоксов)
for i, box in enumerate([self.box1, self.box2, self.box3, self.box4, self.box5]):
if targets[i] == 1:
box['bg'] = 'green'
# проверка ответа пользователя (сравнение вектора ответа с вектором таргета)
if (targets == answers).sum() == 5:
self.mark['text'] = 'Всё верно' # меняем текст метки на статус "Всё верно"
self.true_points += 1 # исли всё верно, то накидываем очко
else:
self.mark['text'] = 'Есть ошибки'
Главное на что стоит обратить внимание - за индекс мы берём значение из списка order_list. Например, ели мы на втором вопросе (self.qc=1), а order_list = [2 0 3 1 5 4], то индекс будет равен 0.
При нажатии кнопки «Следующий» вызовется метод next_q. О нём в следующем пункте.
7. Пользователь нажимает кнопку "Следующий". Система переходит в состояние смены вопроса и вызывается метод next_q.
Первый важный момент:
# инкрементируем счётчик вопросов
self.qc += 1
Затем удаляем подсветку боксов и обновляем поля вопросов и ответов:
# определяем текущий индекс вопроса
index = order_list[self.qc]
# удаляем подсветку чекбоксов
for i, box in enumerate([self.box1, self.box2, self.box3, self.box4, self.box5]):
box['bg'] = 'white'
box.deselect()
# смена вопроса
self.quest.delete('1.0', 'end') # очищаем всё поле с индекса "1" до последнего "end"
self.quest.insert(tk.INSERT, Text_q[index]) # выводим следующий вопрос
И не забываем про index!
8. Пользователь ответил на последний вопрос и нажал "Следующий" - высвечивается кол-во правильных ответов за весь тест.
# когда ответили на все вопросы -> подводим итоги
if self.qc >= len(Text_q):
self.FinalScore = tk.Label(window, text=f'Всего правильных ответов: {self.true_points}', font=('Arial Bold', 15), fg='white', bg='grey')
self.FinalScore.place(x=360, y=210)
С логикой работы разобрались)
Подытожим:
- Были обозначены требования к структуре файлов, для их корректной обработки приложением
- Рассмотрены принципы событийно-ориентированного программирования
- Написан GUI на базе библиотеки Tkinter
- Описана логика работы приложения
Всем успехов в написании собственных интересных и полезных APP. Делитесь ими, и кто-то обязательно их заценит ;)