First Commit
This commit is contained in:
commit
3796ec5cd2
146 files changed
+4825
No files matched your search
@@ -0,0 +1,33 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(RaffleGroups VERSION 1.0 LANGUAGES CXX)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_CXX_STANDARD 14)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets)
|
||||
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets)
|
||||
if(QT_VERSION_MAJOR EQUAL 5 AND Qt5Core_VERSION VERSION_LESS 5.14.2)
|
||||
message(FATAL_ERROR "Qt 5.14.2 or newer is required.")
|
||||
endif()
|
||||
if(QT_VERSION_MAJOR EQUAL 6)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
endif()
|
||||
if(MSVC)
|
||||
add_compile_options(/utf-8)
|
||||
else()
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
add_executable(RaffleGroups WIN32 main.cpp appearance.h mainwindow.cpp mainwindow.h roster.cpp roster.h mainwindow.ui)
|
||||
target_link_libraries(RaffleGroups PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)
|
||||
option(BUILD_TEACHING_TESTS "Build teaching project tests" OFF)
|
||||
if(BUILD_TEACHING_TESTS)
|
||||
enable_testing()
|
||||
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test)
|
||||
add_executable(test_RaffleGroups tests/test_RaffleGroups.cpp mainwindow.cpp mainwindow.h
|
||||
roster.cpp roster.h mainwindow.ui)
|
||||
target_include_directories(test_RaffleGroups PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(test_RaffleGroups PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Test)
|
||||
add_test(NAME RaffleGroups COMMAND test_RaffleGroups -platform offscreen)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 项目三:随机抽签与分组工具
|
||||
|
||||
适合第3—4章讲解 QStringList、QSet、随机数、下拉框和按钮状态。
|
||||
|
||||
## 名单的含义
|
||||
|
||||
每行代表一位学生。行首和行尾空白会被去掉,空行会被忽略。相同条目不允许重复;同名学生可以写成“01 张三”和“02 张三”。程序不根据姓名猜测是否为同一个人。
|
||||
|
||||
打开程序时填入一份虚构的 12 人示例名单,但还没有应用。检查后点击“应用名单”,才能抽签和分组。每次应用都会开始新一轮并清空旧结果。修改名单后,操作按钮暂时禁用,避免操作的实际名单与编辑框不一致。
|
||||
|
||||
名单上限为 5000 人,每条最多 100 个字符。这个限制用于及时发现错误粘贴,普通班级无需调整。
|
||||
|
||||
## 不重复抽签
|
||||
|
||||
选择本次人数后点击“抽取”。程序保存两份列表:完整名单和剩余候选池。抽到一位学生后,立即从候选池中移除,后续抽签只能从剩余的人中选择。
|
||||
|
||||
本轮全部抽完后,抽取按钮不可用。点击“重置抽签”会重新填满候选池并清除抽签记录;已生成的分组保持不变。
|
||||
|
||||
课堂上可以先用 A、B、C 三人演示:抽出 B 后候选池只剩 A、C。让学生解释为什么不能每次都从完整名单随机选择。
|
||||
|
||||
## 随机分组
|
||||
|
||||
分组始终使用全部已应用名单,与抽签结果无关。
|
||||
|
||||
有两种参数:
|
||||
|
||||
- 每组最多人数:先用向上取整计算需要几组,再平均分配。
|
||||
- 分成几组:直接指定组数,组数不能超过人数。
|
||||
|
||||
例如 12 人、每组最多 5 人,需要 3 组。为了平衡人数,结果是每组 4 人。这个参数表示人数上限,不要求先装满一组再分下一组。
|
||||
|
||||
程序先用 Fisher–Yates 算法打乱名单,再按组号轮流分配。每人恰好出现一次,各组人数最多相差 1。再次点击“生成分组”会重新分组,结果不保证与上次不同。
|
||||
|
||||
## 课堂任务
|
||||
|
||||
第一步只读名单,使用 QStringList 保存,显示有效人数。再加入空行过滤和重复检查,观察同名但学号不同的两条记录应怎样处理。
|
||||
|
||||
第二步实现抽签:先抽一人,再抽多人。让学生跟踪 takeAt 执行前后候选池的大小。
|
||||
|
||||
第三步实现分组。先按原顺序轮流分组,确认没有遗漏,再加入洗牌。把“随机顺序”和“完整分配”分开验证,便于找错。
|
||||
|
||||
第四步处理界面状态:名单编辑后禁用操作,应用成功后启用,名单重复时保留错误原因。
|
||||
|
||||
## 学生练习
|
||||
|
||||
1. 增加“复制本轮抽签记录”按钮。
|
||||
2. 保存分组结果到文本文件,作为第8章的延伸任务。
|
||||
3. 增加“撤销上一次抽签”,明确如何把人员放回候选池,以及怎样修改历史记录。
|
||||
|
||||
## 验收用例
|
||||
|
||||
| 输入或操作 | 预期 |
|
||||
|---|---|
|
||||
| 空名单 | 显示提示,不允许开始 |
|
||||
| A 与前后带空格的 A | 判断重复并指出行号 |
|
||||
| 01 张三、02 张三 | 作为两位学生接受 |
|
||||
| 4 人分两次各抽 2 人 | 四人各出现一次,随后不能继续抽 |
|
||||
| 12 人,每组最多 5 人 | 3 组,每组 4 人 |
|
||||
| 10 人,分成 3 组 | 人数为 4、3、3,顺序可变 |
|
||||
| 3 人,要求分成 4 组 | 显示组数过多的提示 |
|
||||
| 先抽签,再分组 | 分组仍包含全部名单 |
|
||||
| 已生成结果后编辑名单 | 旧结果清空,重新应用前不能操作 |
|
||||
|
||||
自动化测试用固定种子得到可重复的随机序列,并对 1—100 人的两种分组方式检查总人数、唯一性和组间人数差。界面使用全局随机生成器,正常抽签不使用固定种子。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
QT += widgets
|
||||
CONFIG += c++14
|
||||
greaterThan(QT_MAJOR_VERSION, 5): CONFIG += c++17
|
||||
TEMPLATE = app
|
||||
TARGET = RaffleGroups
|
||||
SOURCES += main.cpp mainwindow.cpp roster.cpp
|
||||
HEADERS += mainwindow.h roster.h
|
||||
FORMS += mainwindow.ui
|
||||
msvc: QMAKE_CXXFLAGS += /utf-8
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <QApplication>
|
||||
#include <QFont>
|
||||
#include <QStyleFactory>
|
||||
|
||||
inline void applyAppearance(QApplication &app)
|
||||
{
|
||||
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
|
||||
app.setFont(QFont(QStringLiteral("Microsoft YaHei UI"), 11));
|
||||
app.setStyleSheet(QStringLiteral(
|
||||
"QMainWindow { background: #f5f7fb; }"
|
||||
"QLabel { color: #23364c; }"
|
||||
"QLabel#titleLabel { font-size: 27px; font-weight: bold; color: #173b63; }"
|
||||
"QLabel#subtitleLabel, QLabel#helpLabel { color: #52657a; }"
|
||||
"QGroupBox { background: white; border: 1px solid #d6e0eb;"
|
||||
" border-radius: 8px; margin-top: 12px; padding: 18px 12px 12px; }"
|
||||
"QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 6px; }"
|
||||
"QLineEdit, QSpinBox, QComboBox { min-height: 32px; padding: 3px 8px; }"
|
||||
"QLineEdit, QSpinBox, QComboBox, QPlainTextEdit, QListWidget"
|
||||
" { background: white; color: #23364c; }"
|
||||
"QPushButton { min-height: 34px; padding: 4px 14px; }"
|
||||
"QPushButton#convertButton, QPushButton#startButton, QPushButton#drawButton,"
|
||||
" QPushButton#applyButton, QPushButton#groupsButton { background: #255c93;"
|
||||
" color: white; border: 1px solid #255c93; border-radius: 5px; }"
|
||||
"QPushButton:disabled, QPushButton#convertButton:disabled,"
|
||||
" QPushButton#startButton:disabled, QPushButton#drawButton:disabled,"
|
||||
" QPushButton#applyButton:disabled, QPushButton#groupsButton:disabled"
|
||||
" { color: #8995a3; background: #e6ebf1; border-color: #d6e0eb; }"
|
||||
"QLabel#statusLabel { color: #185376; }"
|
||||
"QLineEdit#resultEdit { font-size: 23px; min-height: 45px; color: #175e53; }"
|
||||
"QLabel#timeLabel { font: bold 76px 'Consolas'; color: #173b63; }"
|
||||
"QProgressBar { border: 1px solid #d6e0eb; border-radius: 6px; height: 14px; }"
|
||||
"QProgressBar::chunk { background: #2b8a7d; border-radius: 5px; }"));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "mainwindow.h"
|
||||
#include "appearance.h"
|
||||
#include <QApplication>
|
||||
#include <QFont>
|
||||
#include <QStyleFactory>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
|
||||
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
|
||||
QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
|
||||
#endif
|
||||
QApplication app(argc, argv);
|
||||
applyAppearance(app);
|
||||
MainWindow window;
|
||||
window.show();
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "mainwindow.h"
|
||||
#include "ui_mainwindow.h"
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent), ui(new Ui::MainWindow)
|
||||
{
|
||||
ui->setupUi(this);
|
||||
ui->groupModeCombo->addItems({tr("每组最多人数"), tr("分成几组")});
|
||||
connect(ui->namesEdit, &QPlainTextEdit::textChanged, this, &MainWindow::markDirty);
|
||||
connect(ui->applyButton, &QPushButton::clicked, this, &MainWindow::applyNames);
|
||||
connect(ui->sampleButton, &QPushButton::clicked, this, [this] {
|
||||
ui->namesEdit->setPlainText(QStringLiteral(
|
||||
"01 陈晨\n02 林悦\n03 王宁\n04 李然\n05 周航\n06 赵晴\n"
|
||||
"07 孙瑞\n08 吴桐\n09 郑宇\n10 许诺\n11 何欣\n12 方舟"));
|
||||
});
|
||||
connect(ui->drawButton, &QPushButton::clicked, this, &MainWindow::drawNames);
|
||||
connect(ui->groupsButton, &QPushButton::clicked, this, &MainWindow::makeGroups);
|
||||
connect(ui->resetButton, &QPushButton::clicked, this, [this] {
|
||||
m_session.resetDraw();
|
||||
m_round = 0;
|
||||
ui->historyList->clear();
|
||||
ui->chosenEdit->clear();
|
||||
ui->statusLabel->setText(tr("抽签已重置,所有人重新进入候选池。"));
|
||||
refreshControls();
|
||||
});
|
||||
connect(ui->copyGroupsButton, &QPushButton::clicked, this, [this] {
|
||||
QApplication::clipboard()->setText(ui->groupsEdit->toPlainText());
|
||||
ui->statusLabel->setText(tr("分组结果已复制。"));
|
||||
});
|
||||
connect(ui->groupModeCombo, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged),
|
||||
this, [this] { ui->groupsEdit->clear(); ui->copyGroupsButton->setEnabled(false); });
|
||||
connect(ui->groupValueSpin, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged),
|
||||
this, [this] { ui->groupsEdit->clear(); ui->copyGroupsButton->setEnabled(false); });
|
||||
ui->sampleButton->click();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() { delete ui; }
|
||||
|
||||
void MainWindow::markDirty()
|
||||
{
|
||||
m_dirty = true;
|
||||
// 名单有改动时清除旧展示,避免把旧名单的结果当成新名单的结果。
|
||||
ui->chosenEdit->clear();
|
||||
ui->historyList->clear();
|
||||
ui->groupsEdit->clear();
|
||||
ui->copyGroupsButton->setEnabled(false);
|
||||
ui->statusLabel->setText(tr("名单尚未应用。检查后点击“应用名单”开始新一轮。"));
|
||||
refreshControls();
|
||||
}
|
||||
|
||||
void MainWindow::applyNames()
|
||||
{
|
||||
const auto parsed = Roster::parse(ui->namesEdit->toPlainText());
|
||||
if (!parsed.ok()) {
|
||||
ui->statusLabel->setText(parsed.error);
|
||||
return;
|
||||
}
|
||||
m_session.setNames(parsed.names);
|
||||
m_dirty = false;
|
||||
m_round = 0;
|
||||
ui->historyList->clear();
|
||||
ui->chosenEdit->clear();
|
||||
ui->groupsEdit->clear();
|
||||
ui->copyGroupsButton->setEnabled(false);
|
||||
ui->statusLabel->setText(tr("已应用 %1 人。抽签与分组现在使用这份名单。").arg(parsed.names.size()));
|
||||
refreshControls();
|
||||
}
|
||||
|
||||
void MainWindow::refreshControls()
|
||||
{
|
||||
const int remaining = m_session.remainingCount();
|
||||
ui->drawButton->setEnabled(!m_dirty && remaining > 0);
|
||||
ui->resetButton->setEnabled(!m_dirty);
|
||||
ui->groupsButton->setEnabled(!m_dirty && !m_session.names().isEmpty());
|
||||
ui->drawCountSpin->setEnabled(!m_dirty && remaining > 0);
|
||||
ui->drawCountSpin->setMaximum(qMax(1, remaining));
|
||||
ui->countLabel->setText(m_dirty ? tr("名单待应用")
|
||||
: tr("名单 %1 人 · 尚未抽到 %2 人").arg(m_session.names().size()).arg(remaining));
|
||||
}
|
||||
|
||||
void MainWindow::drawNames()
|
||||
{
|
||||
if (m_dirty) return;
|
||||
const QStringList selected = m_session.draw(ui->drawCountSpin->value(),
|
||||
*QRandomGenerator::global());
|
||||
if (selected.isEmpty()) return;
|
||||
++m_round;
|
||||
ui->chosenEdit->setPlainText(selected.join(QStringLiteral("\n")));
|
||||
ui->historyList->addItem(tr("第 %1 次:%2").arg(m_round).arg(selected.join(QStringLiteral("、"))));
|
||||
ui->historyList->scrollToBottom();
|
||||
ui->statusLabel->setText(m_session.remainingCount() == 0
|
||||
? tr("所有人都已抽到。点击“重置抽签”可以开始下一轮。")
|
||||
: tr("本次抽取 %1 人,已从候选池中移除。").arg(selected.size()));
|
||||
refreshControls();
|
||||
}
|
||||
|
||||
void MainWindow::makeGroups()
|
||||
{
|
||||
if (m_dirty) return;
|
||||
const auto groups = m_session.groups(ui->groupValueSpin->value(),
|
||||
ui->groupModeCombo->currentIndex() == 1, *QRandomGenerator::global());
|
||||
if (groups.isEmpty()) {
|
||||
ui->groupsEdit->clear();
|
||||
ui->copyGroupsButton->setEnabled(false);
|
||||
ui->statusLabel->setText(tr("组数不能超过名单人数,请调整后再分组。"));
|
||||
return;
|
||||
}
|
||||
QStringList lines;
|
||||
for (int i = 0; i < groups.size(); ++i) {
|
||||
lines.append(tr("第 %1 组(%2 人)").arg(i + 1).arg(groups.at(i).size()));
|
||||
lines.append(groups.at(i).join(QStringLiteral("、")));
|
||||
lines.append(QString());
|
||||
}
|
||||
ui->groupsEdit->setPlainText(lines.join(QStringLiteral("\n")));
|
||||
ui->copyGroupsButton->setEnabled(true);
|
||||
ui->statusLabel->setText(tr("已将全部 %1 人分为 %2 组,每人出现一次,各组人数最多相差 1。")
|
||||
.arg(m_session.names().size()).arg(groups.size()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <QMainWindow>
|
||||
#include "roster.h"
|
||||
namespace Ui { class MainWindow; }
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow() override;
|
||||
private:
|
||||
Ui::MainWindow *ui;
|
||||
Roster::Session m_session;
|
||||
bool m_dirty = true;
|
||||
int m_round = 0;
|
||||
void markDirty();
|
||||
void applyNames();
|
||||
void refreshControls();
|
||||
void drawNames();
|
||||
void makeGroups();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="windowTitle">
|
||||
<string>随机抽签与分组工具</string>
|
||||
</property>
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1120</width>
|
||||
<height>780</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>24</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>24</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>24</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>24</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="titleLabel">
|
||||
<property name="text">
|
||||
<string>随机抽签与分组</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="subtitleLabel">
|
||||
<property name="text">
|
||||
<string>先应用名单,再抽签或分组。同名学生请使用“学号 姓名”区分。</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="countLabel">
|
||||
<property name="text">
|
||||
<string>名单待应用</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="bodyLayout">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="namesGroup">
|
||||
<property name="title">
|
||||
<string>班级名单</string>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>310</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="namesLayout">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="namesHelp">
|
||||
<property name="text">
|
||||
<string>每行一人,空行会被忽略。修改名单后需重新应用。</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="namesEdit">
|
||||
<property name="placeholderText">
|
||||
<string>01 张三
|
||||
02 李四</string>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>250</width>
|
||||
<height>320</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="applyButton">
|
||||
<property name="text">
|
||||
<string>应用名单(开始新一轮)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="sampleButton">
|
||||
<property name="text">
|
||||
<string>填入示例名单</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="resultTabs">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>570</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<widget class="QWidget" name="drawTab">
|
||||
<attribute name="title">
|
||||
<string>不重复抽签</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="drawLayout">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="drawControls">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="drawCountLabel">
|
||||
<property name="text">
|
||||
<string>本次抽取人数</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="drawCountSpin">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>5000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="drawButton">
|
||||
<property name="text">
|
||||
<string>抽取</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="chosenLabel">
|
||||
<property name="text">
|
||||
<string>本次抽取</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="chosenEdit">
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>100</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>145</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="historyLabel">
|
||||
<property name="text">
|
||||
<string>本轮抽签记录</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="historyList" />
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="resetButton">
|
||||
<property name="text">
|
||||
<string>重置抽签</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="groupTab">
|
||||
<attribute name="title">
|
||||
<string>随机分组</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="groupLayout">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="groupHelp">
|
||||
<property name="text">
|
||||
<string>分组使用全部已应用名单,与已抽到哪些人无关。每次点击都会重新分组。</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="groupControls">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QComboBox" name="groupModeCombo" />
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="groupValueSpin">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>5000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="groupsButton">
|
||||
<property name="text">
|
||||
<string>生成分组</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="groupsEdit">
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="copyGroupsButton">
|
||||
<property name="text">
|
||||
<string>复制分组结果</string>
|
||||
</property>
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="statusLabel">
|
||||
<property name="text">
|
||||
<string />
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>55</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<resources />
|
||||
<connections />
|
||||
</ui>
|
||||
@@ -0,0 +1,72 @@
|
||||
#include "roster.h"
|
||||
#include <QSet>
|
||||
|
||||
namespace Roster {
|
||||
ParsedNames parse(const QString &text)
|
||||
{
|
||||
ParsedNames result;
|
||||
QSet<QString> seen;
|
||||
const QStringList lines = text.split(QLatin1Char('\n'));
|
||||
int lineNumber = 0;
|
||||
for (const QString &line : lines) {
|
||||
++lineNumber;
|
||||
const QString name = line.trimmed();
|
||||
if (name.isEmpty()) continue;
|
||||
if (seen.contains(name)) {
|
||||
result.error = QStringLiteral("第 %1 行与前面的条目重复:%2。"
|
||||
"同名学生请加上学号后再应用名单。").arg(lineNumber).arg(name);
|
||||
return result;
|
||||
}
|
||||
if (name.size() > 100) {
|
||||
result.error = QStringLiteral("第 %1 行超过 100 个字符,请检查是否粘贴了整段文字。")
|
||||
.arg(lineNumber);
|
||||
return result;
|
||||
}
|
||||
seen.insert(name);
|
||||
result.names.append(name);
|
||||
}
|
||||
if (result.names.isEmpty())
|
||||
result.error = QStringLiteral("名单为空,请每行输入一位学生。");
|
||||
else if (result.names.size() > 5000)
|
||||
result.error = QStringLiteral("名单超过 5000 人,请分批操作。");
|
||||
return result;
|
||||
}
|
||||
|
||||
void Session::setNames(const QStringList &names)
|
||||
{
|
||||
m_names = names;
|
||||
resetDraw();
|
||||
}
|
||||
|
||||
void Session::resetDraw() { m_pool = m_names; }
|
||||
|
||||
QStringList Session::draw(int count, QRandomGenerator &random)
|
||||
{
|
||||
QStringList selected;
|
||||
if (count <= 0 || count > m_pool.size()) return selected;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const int index = random.bounded(static_cast<int>(m_pool.size()));
|
||||
// 从候选池中移除已抽到的人,本轮后续抽签就不会重复。
|
||||
selected.append(m_pool.takeAt(index));
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
QList<QStringList> Session::groups(int value, bool valueIsGroupCount,
|
||||
QRandomGenerator &random) const
|
||||
{
|
||||
QList<QStringList> result;
|
||||
const int n = static_cast<int>(m_names.size());
|
||||
if (n == 0 || value <= 0 || (valueIsGroupCount && value > n)) return result;
|
||||
const int count = valueIsGroupCount ? value : (n + value - 1) / value;
|
||||
QStringList shuffled = m_names;
|
||||
// Fisher–Yates 洗牌。每次从尚未固定的位置中均匀选取一个。
|
||||
for (int i = n - 1; i > 0; --i)
|
||||
qSwap(shuffled[i], shuffled[random.bounded(i + 1)]);
|
||||
for (int i = 0; i < count; ++i) result.append(QStringList());
|
||||
// 轮流放入各组,保证每人出现一次,各组人数最多相差 1。
|
||||
for (int i = 0; i < n; ++i) result[i % count].append(shuffled.at(i));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
#include <QStringList>
|
||||
#include <QList>
|
||||
#include <QRandomGenerator>
|
||||
|
||||
namespace Roster {
|
||||
struct ParsedNames {
|
||||
QStringList names;
|
||||
QString error;
|
||||
bool ok() const { return error.isEmpty(); }
|
||||
};
|
||||
ParsedNames parse(const QString &text);
|
||||
|
||||
class Session {
|
||||
public:
|
||||
void setNames(const QStringList &names);
|
||||
void resetDraw();
|
||||
const QStringList &names() const { return m_names; }
|
||||
int remainingCount() const { return static_cast<int>(m_pool.size()); }
|
||||
QStringList draw(int count, QRandomGenerator &random);
|
||||
QList<QStringList> groups(int value, bool valueIsGroupCount,
|
||||
QRandomGenerator &random) const;
|
||||
private:
|
||||
QStringList m_names;
|
||||
QStringList m_pool;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
01 陈晨
|
||||
02 林悦
|
||||
03 王宁
|
||||
04 李然
|
||||
05 周航
|
||||
06 赵晴
|
||||
07 孙瑞
|
||||
08 吴桐
|
||||
09 郑宇
|
||||
10 许诺
|
||||
11 何欣
|
||||
12 方舟
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#include <QtTest>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QTabWidget>
|
||||
#include <QLabel>
|
||||
#include <QSet>
|
||||
#include "roster.h"
|
||||
#include "mainwindow.h"
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include "appearance.h"
|
||||
|
||||
static void savePreview(QWidget &window, const QString &fileName)
|
||||
{
|
||||
const QString folder = QString::fromLocal8Bit(qgetenv("QT_TEACHING_SCREENSHOTS"));
|
||||
if (folder.isEmpty()) return;
|
||||
QDir().mkpath(folder);
|
||||
window.show();
|
||||
QApplication::processEvents();
|
||||
QVERIFY(window.grab().save(QDir(folder).filePath(fileName)));
|
||||
}
|
||||
|
||||
class RaffleTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() { applyAppearance(*qApp); }
|
||||
void parsing()
|
||||
{
|
||||
auto parsed = Roster::parse(QStringLiteral(" 01 张三 \r\n\n02 李四\r\n"));
|
||||
QVERIFY(parsed.ok()); QCOMPARE(parsed.names.size(), 2);
|
||||
QCOMPARE(parsed.names.first(), QStringLiteral("01 张三"));
|
||||
QVERIFY(!Roster::parse(QStringLiteral("张三\n 张三 ")).ok());
|
||||
QVERIFY(!Roster::parse(QStringLiteral(" \n ")).ok());
|
||||
QVERIFY(Roster::parse(QStringLiteral("01 张三\n02 张三")).ok());
|
||||
QVERIFY(!Roster::parse(QString(101, QLatin1Char('x'))).ok());
|
||||
}
|
||||
void drawingWithoutReplacement()
|
||||
{
|
||||
Roster::Session session;
|
||||
const QStringList names = {QStringLiteral("A"), QStringLiteral("B"),
|
||||
QStringLiteral("C"), QStringLiteral("D")};
|
||||
session.setNames(names);
|
||||
QRandomGenerator random(12345);
|
||||
QVERIFY(session.draw(5, random).isEmpty());
|
||||
QCOMPARE(session.remainingCount(), 4);
|
||||
QStringList drawn = session.draw(2, random);
|
||||
drawn.append(session.draw(2, random));
|
||||
QSet<QString> unique;
|
||||
for (const QString &name : drawn) unique.insert(name);
|
||||
QCOMPARE(unique.size(), 4);
|
||||
QCOMPARE(session.remainingCount(), 0);
|
||||
QVERIFY(session.draw(1, random).isEmpty());
|
||||
session.resetDraw();
|
||||
QCOMPARE(session.remainingCount(), 4);
|
||||
}
|
||||
void groupingInvariants()
|
||||
{
|
||||
QRandomGenerator random(54321);
|
||||
for (int n = 1; n <= 100; ++n) {
|
||||
QStringList names;
|
||||
for (int i = 0; i < n; ++i) names.append(QString::number(i));
|
||||
Roster::Session session; session.setNames(names);
|
||||
session.draw(1, random); // 分组仍使用全部名单。
|
||||
for (bool byCount : {false, true}) {
|
||||
const int value = byCount ? qMin(n, 7) : 7;
|
||||
const auto groups = session.groups(value, byCount, random);
|
||||
QCOMPARE(groups.size(), byCount ? value : (n + value - 1) / value);
|
||||
QSet<QString> all; int total = 0, smallest = n, largest = 0;
|
||||
for (const QStringList &group : groups) {
|
||||
QVERIFY(!group.isEmpty());
|
||||
smallest = qMin(smallest, static_cast<int>(group.size()));
|
||||
largest = qMax(largest, static_cast<int>(group.size()));
|
||||
total += static_cast<int>(group.size());
|
||||
for (const QString &name : group) all.insert(name);
|
||||
}
|
||||
QCOMPARE(total, n); QCOMPARE(all.size(), n);
|
||||
QVERIFY(largest - smallest <= 1);
|
||||
if (!byCount) QVERIFY(largest <= value);
|
||||
}
|
||||
QVERIFY(session.groups(n + 1, true, random).isEmpty());
|
||||
}
|
||||
}
|
||||
void uiWorkflow()
|
||||
{
|
||||
MainWindow w; w.show();
|
||||
auto *names = w.findChild<QPlainTextEdit *>(QStringLiteral("namesEdit"));
|
||||
auto *apply = w.findChild<QPushButton *>(QStringLiteral("applyButton"));
|
||||
auto *draw = w.findChild<QPushButton *>(QStringLiteral("drawButton"));
|
||||
auto *chosen = w.findChild<QPlainTextEdit *>(QStringLiteral("chosenEdit"));
|
||||
QVERIFY(!draw->isEnabled());
|
||||
QTest::mouseClick(apply, Qt::LeftButton);
|
||||
QVERIFY(draw->isEnabled());
|
||||
QTest::mouseClick(draw, Qt::LeftButton);
|
||||
QVERIFY(!chosen->toPlainText().isEmpty());
|
||||
savePreview(w, QStringLiteral("03_raffle.png"));
|
||||
w.findChild<QTabWidget *>(QStringLiteral("resultTabs"))->setCurrentIndex(1);
|
||||
QTest::mouseClick(w.findChild<QPushButton *>(QStringLiteral("groupsButton")), Qt::LeftButton);
|
||||
auto *groups = w.findChild<QPlainTextEdit *>(QStringLiteral("groupsEdit"));
|
||||
QVERIFY(groups->toPlainText().contains(QStringLiteral("第 3 组")));
|
||||
savePreview(w, QStringLiteral("03_groups.png"));
|
||||
names->setPlainText(QStringLiteral("A\nA"));
|
||||
QVERIFY(!draw->isEnabled());
|
||||
QVERIFY(groups->toPlainText().isEmpty());
|
||||
QTest::mouseClick(apply, Qt::LeftButton);
|
||||
QVERIFY(w.findChild<QLabel *>(QStringLiteral("statusLabel"))->text().contains(QStringLiteral("重复")));
|
||||
names->setPlainText(QStringLiteral("A\nB"));
|
||||
QTest::mouseClick(apply, Qt::LeftButton);
|
||||
w.findChild<QTabWidget *>(QStringLiteral("resultTabs"))->setCurrentIndex(0);
|
||||
w.findChild<QSpinBox *>(QStringLiteral("drawCountSpin"))->setValue(2);
|
||||
QTest::mouseClick(draw, Qt::LeftButton);
|
||||
QVERIFY(!draw->isEnabled());
|
||||
QTest::mouseClick(w.findChild<QPushButton *>(QStringLiteral("resetButton")), Qt::LeftButton);
|
||||
QVERIFY(draw->isEnabled());
|
||||
}
|
||||
};
|
||||
QTEST_MAIN(RaffleTest)
|
||||
#include "test_RaffleGroups.moc"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
QT += widgets testlib
|
||||
CONFIG += c++14 console testcase
|
||||
greaterThan(QT_MAJOR_VERSION, 5): CONFIG += c++17
|
||||
TEMPLATE = app
|
||||
TARGET = test_RaffleGroups
|
||||
INCLUDEPATH += ..
|
||||
SOURCES += test_RaffleGroups.cpp ../mainwindow.cpp ../roster.cpp
|
||||
HEADERS += ../mainwindow.h ../roster.h
|
||||
FORMS += ../mainwindow.ui
|
||||
msvc: QMAKE_CXXFLAGS += /utf-8
|
||||
|
||||
Reference in new issue
Block a user