Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34

Создание программы - ООП модель, MVP

30.10.2018, 11:10. Показов 70532. Ответов 99

Студворк — интернет-сервис помощи студентам
Пример разработки приложения с нуля под WinForms

Рассматриваются такие аспекты разработки как:
  • Создание ООП модели предметной области.
  • Разработка Unit-тестов.
  • Архитектура приложения под WinForms.
  • Разработка пользовательского интерфейса с разделением модели и представления (MVP).
  • Разработка UserControl для построения пользовательского интерфейса.
  • Рефакторинг, непрерывная интеграция.
  • Организация проекта в VisualStudio.

В этом примере НЕ рассматриваются шаблоны типа фабрик, не рассматривается внедрение зависимостей (DI).
Используется простой вариант шаблона Model-View-Presenter, адаптированный под специфику WinForms.

Этот топик перекликается с этим FAQ, и является практическим примером к нему.
Комментарии и вопросы - приветствуются.

Постановка задачи

Требуется создать приложение - опросник.

Опросник будет состоять из нескольких вопросов с различными вариантами ответов.
Вопросы могут быть двух видов - с выбором одного ответа из набора возможных, и открытые вопросы, в которых респондент может указать произвольный ответ в виде текста.
В опроснике нужно предусмотреть возможность задавать некоторые вопросы и альтернативы только при выполнении некоторых условий, например если в предыдущем вопросе респондент выбрал определенный ответ.

Приложение должно состоять из двух частей - конструктора опросника и приложения в котором респондент может ответить на вопросы готового опросника.

Результаты опроса должны выгружаться в виде, пригодном для дальнейшей обработки.
18
Лучшие ответы (1)
Programming
Эксперт
39485 / 9562 / 3019
Регистрация: 12.04.2006
Сообщений: 41,671
Блог
30.10.2018, 11:10
Ответы с готовыми решениями:

Встраивание Google RESTful pattern A в модель MVP
Доброго дня. Всем известна модель MVC и её разновидность MVP, достаточно подробную статью по реализации MVP можно посмотреть здесь А...

Создание дочернего окна в MVP
Продолжаю разбираться с MVP. Не знаю, как правильно в данном шаблоне сделать работу с дочерним окном. Есть главное окно MainForm,...

ООП. Переделать фрагмент программы под ООП
Есть небольшой фрагмент из программы, который необходимо перестроить под ООП(создать класс), для удобного испольщования. Можно ли изменить...

99
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 11:16  [ТС]
Шаг 1: Модель

Первое с чего начнем разработку - продумаем модель предметной области (которая также называется доменной моделью, объектной моделью, ООП моделью).

Нашей целью является создание классов, которые максимально точно отражают предметы внешнего мира, со всеми присущими им свойствами, зависимостями и функционалом.

В данном случае нашей предметной областью является опросник. Внимательно посмотрим на постановку задачи (которое также называется техническим заданием - ТЗ), выпишем существительные, которые там присутствуют. Это такие слова как: опросник, вопросы, варианты ответов, респондент, ответ, текст, альтернативы, условия.

Каждое из этих слов является некоторой сущностью предметной области. Все они - кандидаты на отдельный класс.

Проанализировав эти сущности, можно сформировать структуру данных, которая определяет как данные связаны между собой:
  • Опросник состоит из вопросов.
  • Каждый вопрос состоит из вариантов ответов (или альтернатив, что то же самое).

Кроме того у нас есть еще условия, респонденты и ответы. Эти сущности пока рассматривать не будем.

Смотря на структуру, понимаем, что у нас должны быть три класса:
Опросник - Questionnaire.
Вопрос - Quest.
Вариант ответа (альтернатива) - Alternative.

Поскольку сущности связаны глаголом "состоит" (опросник состоит из вопросов, вопрос состоит из альтернатив), то это является намеком на связь типа композиция или агрегация.
Исходя из структуры данных, можно сформировать уже модель в виде классов. Я буду использовать композицию:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
    /// <summary>
    /// Опросник.
    /// Содержит список вопросов.
    /// </summary>
    [Serializable]
    public class Questionnaire : List<Quest>
    {
    }
 
    /// <summary>
    /// Вопрос.
    /// Содержит список альтернатив.
    /// </summary>
    [Serializable]
    public class Quest : List<Alternative>
    {
        /// <summary>
        /// Идентификатор вопроса
        /// </summary>
        public string Id { get; set; }
 
        /// <summary>
        /// Текст вопроса
        /// </summary>
        public string Title { get; set; }
    }
 
    /// <summary>
    /// Альтернатива вопроса
    /// </summary>
    [Serializable]
    public class Alternative
    {
        /// <summary>
        /// Код альтернативы
        /// </summary>
        public int Code { get; set; }
 
        /// <summary>
        /// Текст альтернативы
        /// </summary>
        public string Title { get; set; }
    }
Класс Questionnaire наследуется от списка Quest, поскольку он является списком вопросов.
В свою очередь, Quest наследуется от списка Alternative, поскольку он является списком альтернатив.
Оба класса являются также композицией: опросник является композицией вопросов, вопрос - композиция альтернатив.

Вместо наследования от списка, мы бы могли использовать агрегацию, типа такой:
C#
1
2
3
4
5
    [Serializable]
    public class Questionnaire
    {
        public List<Quest> Questions { get; set; }
    }
Но я выбрал вариант с наследованием как более простой в использовании.

Далее, в наших классах есть такие поля как Title - текст самого вопроса или альтернативы, и поля Id и Code. Эти поля нужны как идентификаторы вопросов и альтернатив. Идентификаторы позволят в будущем ссылаться на соответствующий вопрос или альтернативу. В отличие от поля Title, идентификаторы не будут меняться и их значение будет уникальным.

Простейшая модель предметной области готова.
Она не окончательная и будет развиваться в процессе разработки.

Шаг 2: Создание проекта, минимального функционала и Unit - теста.

Приступим к программированию.

Создадим в VisualStuio проект типа Windows Forms Application под .NET Framework 4.5. Создадим папку Core (ядро), и разместим в ней классы нашей модели данных.

Поскольку нашу модель нужно будет сохранять на диск и читать с диска, сразу сделаем такую возможность. Для этого будем использовать бинарную сериализацию, разработав универсальный класс SaverLoader:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    /// <summary>
    /// Сохранение и чтение объектов в/из файла
    /// </summary>
    public class SaverLoader
    {
        /// <summary>
        /// Сохранение объекта в файл
        /// </summary>
        public static void Save<T>(T obj, string filePath)
        {
            using (var fs = File.OpenWrite(filePath))
                new BinaryFormatter().Serialize(fs, obj);
        }
 
        /// <summary>
        /// Чтение объекта из файла
        /// </summary>
        public static T Load<T>(string filePath)
        {
            using (var fs = File.OpenRead(filePath))
                return (T)new BinaryFormatter().Deserialize(fs);
        }
    }
Этот класс умеет сохранять и читать в/из файла любые объекты, помеченные атрибутом [Serializable].
Разместим этот класс также в папке Core.

Теперь протестируем нашу модель - хотелось бы убедиться, что можно создать опросник, вопросы, альтернативы, а также убедиться что опросник сохраняется в файл и читается из него.
Это можно было бы сделать через тестовое приложение, но вместо этого, воспользуемся технологией Unit - тестирования.
Для этого, создадим в VisualStudio еще один проект типа Unit Test Project.

Создадим следующий юнит-тест в классе UnitTest1:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
            //создаем опросник
            var questionnaire = new Questionnaire();
            //создаем вопрос
            var quest = new Quest { Title = "Заголовок вопроса" };
            //создаем альтернативу
            var alt = new Alternative {Code = 1, Title = "Вариант1"};
            //добавляем альтернативу в вопрос
            quest.Add(alt);
            //добавляем вопрос в опросник
            questionnaire.Add(quest);
 
            //сохраняем опросник в файл
            SaverLoader.Save(questionnaire, "c:\\temp.q");
 
            //читаем опросник из файла
            var loadedQuestionnaire = SaverLoader.Load<Questionnaire>("c:\\temp.q");
 
            //проверяем число вопросов и альтернатив в загруженном опроснике
            Assert.AreEqual(loadedQuestionnaire.Count, questionnaire.Count);
            Assert.AreEqual(loadedQuestionnaire[0].Count, questionnaire[0].Count);
        }
    }

Сейчас наш проект будет выглядеть следующим образом:


У меня проект называется WindowsFormsApplication396. Разумеется, у вас он может называться по-другому. В дальнейшем мы переименуем его на более осмысленное название.

Запускаем Unit-тест (выпадающее меню на проекте UnitTestProject/Run Unit Test). Убеждаемся, что все работает:


Полный код проекта на данном этапе:
Вложения
Тип файла: zip WindowsFormsApplication396.zip (114.8 Кб, 231 просмотров)
12
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 11:42  [ТС]
Шаг 3: Развиваем модель.

На Шаге 1, мы не рассмотрели такую сущность как "ответ".
В нашей предметной области есть два объекта высшего уровня - это сам опросник и заполненная анкета, которая состоит из ответов респондента на опросник.
Оформим заполненную анкету с ответами как отдельный класс Anketa.

На самом деле, такого слова как Anketa в английском языке нет, и правильнее было бы называть заполненную анкету как Form. Но с таким названием мы будем путаться с формами WinForms, поэтому я сделал такое название. В общем случае, это конечно же неправильно.

Класс Anketa будет состоять из ответов респондента - Answer.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    /// <summary>
    /// Заполненная анкета.
    /// Содержит список ответов.
    /// </summary>
    [Serializable]
    public class Anketa : List<Answer>
    {
    }
 
    /// <summary>
    /// Ответ респондента в конкретном вопросе
    /// </summary>
    [Serializable]
    public class Answer
    {
        /// <summary>
        /// Идентификатор вопроса
        /// </summary>
        public string QuestId { get; set; }
 
        /// <summary>
        /// Код выбранной альтернативы
        /// </summary>
        public int AlternativeCode { get; set; }
    }
Заметим, что класс Answer содержит поля QuestId и AlternativeCode которые являются идентификаторами вопроса и выбранной альтерантивы. Это одна из причин, по которой мы вводили эти поля в модели опросника.

Так же как и остальные классы модели, пометим классы атрибутом [Serializable], что бы иметь возможность сериализовать анкету.

Для новых классов создадим Unit - тест, который также проверит создание анкеты, добавление ответов и чтение/сохранение анкеты в/из файла:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
        [TestMethod]
        public void TestMethod2()
        {
            //создаем анкету
            var anketa = new Anketa();
            //создаем ответ
            var answer = new Answer{ QuestId = "A1", AlternativeCode = 1 };
            //добавляем ответ в анкету
            anketa.Add(answer);
 
            //сохраняем анкету в файл
            SaverLoader.Save(anketa, "c:\\temp.a");
 
            //читаем анкету из файла
            var loadedAnketa = SaverLoader.Load<Anketa>("c:\\temp.a");
 
            //проверяем число ответов в загруженной анкете
            Assert.AreEqual(loadedAnketa.Count, anketa.Count);
        }
Запускаем юнит-тестирование, убеждаемся, что все работает:


Полный код проекта на этом шаге:
Вложения
Тип файла: zip WindowsFormsApplication396 (2).zip (116.8 Кб, 119 просмотров)
9
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 12:24  [ТС]
Шаг 3: Создание пользовательского интерфейса

Мы бы могли и дальше развивать нашу модель, но на этом этапе нужно создать пользовательский интерфейс.
И вот почему. Здесь мы будем применять такую методику разработки как непрерывная интеграция. Смысл заключается в том, что бы мы постоянно имели на руках минимальную рабочую версию приложения - Minimal Valuable Product - MVP (не путать с аббревиатурой для Model-View-Presenter, а также с Most Valueable Professional, все они MVP).

MVP создается для того, что бы мы всегда могли посмотреть и пощупать наше приложение, показать его будущему пользователю, выявить недостатки модели и интерфейса на ранних этапах разработки.

Цикл разработки при непрерывной интеграции выглядит примерно следующим образом:

разработка модели -> разработка интерфейса -> тестирование -> рефакторинг -> переход к первому пункту

Таким образом, разработка заключается в постоянном внесении небольших изменений в модель и интерфейс, тестировании (возможно с привлечением заказчика, ведь у нас есть рабочая демка), рефакторинге и снова переходе на новый цикл.

Зачем это все нужно? Лучше иметь простую и примитивную рабочую программу, чем сложную, навороченную но неработающую. Если в конце разработки окажется, что мы вообще неправильно поняли предметную область, и вообще заказчик имел ввиду совсем другое, то переделка огромной программы будет гораздо дороже, чем переделка маленькой.

Наш интерфейс конечно не будет содержать всех возможностей, часть будет представлена заглушками. Но это будет рабочая программа.

Итак. В нашем главном проекте переименуем форму в MainForm, кинем на нее тулбар, несколько кнопок и FlowLayoutPanel, которая будет контейнером для нашего конструктора опросника.

В главной форме создадим поле Questionnaire questionnaire;, которое будет содержать текущий опросник.
Полный код главной формы выглядит так:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
using System;
using System.Windows.Forms;
using WindowsFormsApplication396.Controls;
using WindowsFormsApplication396.Core;
 
namespace WindowsFormsApplication396
{
    public partial class MainForm : Form
    {
        // Текущий опросник
        private Questionnaire questionnaire = new Questionnaire();
 
        public MainForm()
        {
            InitializeComponent();
 
            //строим интерфейс
            Build();
        }
 
        private void Build()
        {
            //очищаем центральную панель
            pnMain.Controls.Clear();
            //создаем контролы для каждого вопроса
            foreach (var quest in questionnaire)
            {
                //создаем usercontrol для вопроса
                var pn = new QuestPanel();
                //строим его
                pn.Build(quest);
                //добавляем на главную панель
                pnMain.Controls.Add(pn);
            }
            //обновляем интерфейс
            UpdateInterface();
        }
 
        private void UpdateInterface()
        {
        }
 
        private void LoadQuestionnaireFromFile(string filePath)
        {
            //загружаем из файла
            questionnaire = SaverLoader.Load<Questionnaire>(filePath);
            //перестриваем интерфейс
            Build();
        }
 
        private void SaveQuestionnaireToFile(string filePath)
        {
            //сохраняем в файл
            SaverLoader.Save(questionnaire, filePath);
        }
 
        private void btOpen_Click(object sender, EventArgs e)
        {
            var ofd = new OpenFileDialog() {Filter = "Опросник|*.q"};
            if (ofd.ShowDialog() == DialogResult.OK)
                LoadQuestionnaireFromFile(ofd.FileName);
        }
 
        private void btSave_Click(object sender, EventArgs e)
        {
            var sfd = new SaveFileDialog() { Filter = "Опросник|*.q" };
            if (sfd.ShowDialog() == DialogResult.OK)
                SaveQuestionnaireToFile(sfd.FileName);
        }
 
        private void btAddQuest_Click(object sender, EventArgs e)
        {
            //добавляем новый вопрос в опросник
            new QuestionnaireManipulator().AddNewQuest(questionnaire);
            //перестраиваем интерфейс
            Build();
        }
    }
}
Для связи модели c пользовательским интерфейсом, я буду использовать вариацию паттерна Model-View-Presenter, который более подробно описан в этой теме.

Далее, создаем в проекте папку Controls. Создаем UserControl, который назовем QuestPanel. Этот контрол будет ответственен за отображение Quest из модели опросника. Он пока что будет пустым, без кода.

Запускаем нашу форму, клацаем на кнопочки, убеждаемся, что все работает.

На данном этапе интерфейс программы выглядит так:

Полный код проекта:
Вложения
Тип файла: zip WindowsFormsApplication396 (3).zip (157.9 Кб, 209 просмотров)
8
Эксперт .NET
 Аватар для Usaga
14122 / 9341 / 1350
Регистрация: 21.01.2016
Сообщений: 35,099
30.10.2018, 12:55
Storm23, ох, не знаю. Не нравится мне, что опросник унаследован от коллекции вопросов. И идентификаторы тут как симуляция реляционной базы. Надо ли здесь такое?

Опросник не является списком вопросов. Он из них состоит, содержит их. Так же, он может и ответы содержать. И ещё что (к примеру, замеренное время прохождения опроса). К тому же, наследование от List<T> даёт возможность любому коду менять состав вопросов этого опросника. Тоже самое с вопросами.

И зачем Code и Id? Это же не база данных, тут всё прямыми связями можно разрулить. Это будет сильно проще и явнее.
2
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 13:09  [ТС]
Цитата Сообщение от Usaga Посмотреть сообщение
И зачем Code и Id? Это же не база данных, тут всё прямыми связями можно разрулить. Это будет сильно проще и явнее.
Немного терпения, эти поля нужны.
Цитата Сообщение от Usaga Посмотреть сообщение
Не нравится мне, что опросник унаследован от коллекции вопросов.
Я знаю (и пишу про это), что это не очень хорошо. Но так проще. В крайнем случае сделаем рефакторинг. Там переделывать не много придется.
3
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 13:09  [ТС]
Шаг 4: Добавление сервисных классов, дальнейшее развитие интерфейса

Существует два варианта реализации доменной модели: модель Rich и модель Anemic. Основная разница в том, что в Rich объекты доменной модели содержат функционал, а в Anemic - нет. В Anemic логика и функционал выносятся в отдельные сервисные классы.

В данном приложении я буду использовать Anemic модель. На самом деле это не имеет каких-либо обоснований. Ну вот просто так мне захотелось. В реальности чаще применяется Rich, она же является более правильной с точки зрения ООП. С другой стороны, Anemic тоже имеет свои преимущества. В данной задаче Anemic мне показалась более подходящей.

Итак. Для работы программы, нам нужно обеспечить простейшие действия с опросником: добавление и удаление вопросов, изменение порядка вопросов. Для реализации этих действий разработаем сервисный класс QuestionnaireManipulator:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    /// <summary>
    /// Производит манипуляции с опросником
    /// </summary>
    class QuestionnaireManipulator
    {
        /// <summary>
        /// Добавить новый вопрос
        /// </summary>
        public Quest AddNewQuest(Questionnaire questionnaire)
        {
            var quest = new Quest() {Id = "A1", Title = "Текст вопроса"};
            questionnaire.Add(quest);
            return quest;
        }
 
        /// <summary>
        /// Удалить вопрос
        /// </summary>
        public void RemoveQuest(Questionnaire questionnaire, Quest quest)
        {
            questionnaire.Remove(quest);
        }
 
        /// <summary>
        /// Перемещение вопроса
        /// </summary>
        public void MoveQuest(Questionnaire questionnaire, Quest quest, int dir)
        {
            //получаем текущий индекс
            var i = questionnaire.IndexOf(quest);
            if (i < 0) return;//хм...
 
            if (i == 0 && dir < 0) return; //не можем переместить вверх, потому что вопрос и так первый
            if (i >= questionnaire.Count - 1 && dir > 0) return; //не можем переместить вниз, потому что вопрос и так последний
 
            //перемещаем вверх
            if (dir < 0)
            {
                questionnaire.RemoveAt(i);
                questionnaire.Insert(i - 1, quest);
            }
 
            //перемещаем вниз
            if (dir > 0)
            {
                questionnaire.RemoveAt(i);
                questionnaire.Insert(i + 1, quest);
            }
        }
    }
Поместим этот класс в папку Core нашего проекта.

Далее продолжаем развивать наш интерфейс. Добавим на панель QuestPanel несколько контролов для отображения вопроса:


Реализуем в панели следующий код:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
    public partial class QuestPanel : UserControl
    {
        private Questionnaire questionnaire;
        private Quest quest;
        private int updating;
 
        public event Action QuestionnaireListChanged = delegate { };
        public event Action Changed = delegate { };
 
        public QuestPanel()
        {
            InitializeComponent();
        }
 
        /// <summary>
        /// Строим интерфейс
        /// </summary>
        public void Build(Questionnaire questionnaire, Quest quest)
        {
            this.questionnaire = questionnaire;
            this.quest = quest;
 
            //выставляем флажок обновления
            updating++;
 
            //переносим данные из объекта в интерфейс
            tbId.Text = quest.Id;
            tbTitle.Text = quest.Title;
 
            //сбрасываем флажок обновления
            updating--;
        }
 
        /// <summary>
        /// Обновляем объект из интерфеса
        /// </summary>
        private void UpdateObject()
        {
            //если режим обновления интерфейса - не реагируем
            if (updating > 0) return;
 
            //переносим данные из интерфейса в объект
            quest.Id = tbId.Text;
            quest.Title = tbTitle.Text;
 
            //сигнализируем наверх о том, что объект изменился
            Changed();
        }
 
        private void btDelete_Click(object sender, EventArgs e)
        {
            if (MessageBox.Show("Удалить вопрос?", "Удаление вопроса", MessageBoxButtons.OKCancel) == DialogResult.OK)
            {
                //удаляем вопрос
                new QuestionnaireManipulator().RemoveQuest(questionnaire, quest);
                //сигнализируем наверх о том, что список вопросов поменялся
                QuestionnaireListChanged();
            }
        }
 
        private void btUp_Click(object sender, EventArgs e)
        {
            //передвигаем вопрос вверх по списку
            new QuestionnaireManipulator().MoveQuest(questionnaire, quest, -1);
            //сигнализируем наверх о том, что список вопросов поменялся
            QuestionnaireListChanged();
        }
 
        private void btDown_Click(object sender, EventArgs e)
        {
            //передвигаем вопрос вниз по списку
            new QuestionnaireManipulator().MoveQuest(questionnaire, quest, +1);
            //сигнализируем наверх о том, что список вопросов поменялся
            QuestionnaireListChanged();
        }
 
        private void tb_TextChanged(object sender, EventArgs e)
        {
            //обновляем объект
            UpdateObject();
        }
    }
Это типичный код для UserControl, который мы будем использовать и в дальнейшем.
Содержит два основных метода:
public void Build(...) - вызывается для построения интерфейса. В этот метод передаются объекты доменной модели, которые нужно отобразить. Этот метод - единственный public метод, который может вызываться внешним кодом.
private voic UpdateObject() - метод который передает данные обратно - из интерфейса в доменный объект.

Также, есть два события:
public event Action Changed - вызывается когда вопрос был изменен из интерфейса
public event Action QuestionnaireListChanged - вызывается когда был изменен список вопросов

Контрол содержит также пару обработчиков кликов кнопок и текстбоксов.

Запускаем наше приложение, убеждаемся что работает добавление/удаление вопросов, перемещение вопросов в списке, редактирование полей вопросов, сохранение и чтение из файла.

Вложения
Тип файла: zip WindowsFormsApplication396 (4).zip (186.8 Кб, 162 просмотров)
6
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 13:44  [ТС]
Шаг 5: Дальнейшее развитие интерфейса и сервисов

Переходим на следующий цикл разработки.

В предыдущем шаге мы разработали сервисный класс для Questionnaire. Теперь сделаем аналогичный класс для Quest:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    /// <summary>
    /// Производит манипуляции с вопросом
    /// </summary>
    class QuestManipulator
    {
        /// <summary>
        /// Добавить новую альтернативу
        /// </summary>
        public Alternative AddNewAlt(Quest quest)
        {
            var alt = new Alternative() { Code = 1, Title = "Вариант 1" };
            quest.Add(alt);
            return alt;
        }
 
        /// <summary>
        /// Удалить альтренативу
        /// </summary>
        public void RemoveAlt(Quest quest, Alternative alt)
        {
            quest.Remove(alt);
        }
 
        /// <summary>
        /// Перемещение альтернативы
        /// </summary>
        public void MoveAlt(Quest quest, Alternative alt, int dir)
        {
            //получаем текущий индекс
            var i = quest.IndexOf(alt);
            if (i < 0) return;//хм...
 
            if (i == 0 && dir < 0) return; //не можем переместить вверх, потому что альтернатива и так первая
            if (i >= quest.Count - 1 && dir > 0) return; //не можем переместить вниз, потому что альтернатива и так последняя
 
            //перемещаем вверх
            if (dir < 0)
            {
                quest.RemoveAt(i);
                quest.Insert(i - 1, alt);
            }
 
            //перемещаем вниз
            if (dir > 0)
            {
                quest.RemoveAt(i);
                quest.Insert(i + 1, alt);
            }
        }
    }
Этот класс очень похож на QuestionnaireManipulator. Но конечно есть некоторые отличия и в дальнейшем эти различия будут увеличиваться.

Также создаем новый UserControl, который назовем AlternativePanel. Этот контрол предназначен для отображения альтернативы вопроса.

AlternativePanel выглядит так:


Код AlternativePanel следующий:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
    public partial class AlternativePanel : UserControl
    {
        private Quest quest;
        private Alternative alt;
        private int updating;
 
        public event Action AlternativeListChanged = delegate { };
        public event Action Changed = delegate { };
 
        public AlternativePanel()
        {
            InitializeComponent();
        }
 
        /// <summary>
        /// Строим интерфейс
        /// </summary>
        public void Build(Quest quest, Alternative alt)
        {
            this.quest = quest;
            this.alt = alt;
 
            //выставляем флажок обновления
            updating++;
 
            //переносим данные из объекта в интерфейс
            tbId.Text = alt.Code.ToString();
            tbTitle.Text = alt.Title;
 
            //сбрасываем флажок обновления
            updating--;
        }
 
        /// <summary>
        /// Обновляем объект из интерфеса
        /// </summary>
        private void UpdateObject()
        {
            //если режим обновления интерфейса - не реагируем
            if (updating > 0) return;
 
            //переносим данные из интерфейса в объект
            alt.Code = int.Parse(tbId.Text);
            alt.Title = tbTitle.Text;
 
            //сигнализируем наверх о том, что объект изменился
            Changed();
        }
 
        private void btDelete_Click(object sender, EventArgs e)
        {
            if (MessageBox.Show("Удалить альтернативу?", "Удаление альтернативы", MessageBoxButtons.OKCancel) == DialogResult.OK)
            {
                //удаляем альтернативу
                new QuestManipulator().RemoveAlt(quest, alt);
                //сигнализируем наверх о том, что список поменялся
                AlternativeListChanged();
            }
        }
 
        private void btUp_Click(object sender, EventArgs e)
        {
            //передвигаем альтернативу вверх по списку
            new QuestManipulator().MoveAlt(quest, alt, -1);
            //сигнализируем наверх о том, что список поменялся
            AlternativeListChanged();
        }
 
        private void btDown_Click(object sender, EventArgs e)
        {
            //передвигаем альтернативу вниз по списку
            new QuestManipulator().MoveAlt(quest, alt, +1);
            //сигнализируем наверх о том, что список поменялся
            AlternativeListChanged();
        }
 
        private void tb_TextChanged(object sender, EventArgs e)
        {
            //обновляем объект
            UpdateObject();
        }
    }
Код AlternativePanel очень похож на код для QuestPanel. Настолько похож, что я просто скопировал QuestPanel и делал небольшие правки в скопированном коде.

Иконки для интерфейса можно подобрать здесь и здесь. Иконки в стиле material здесь.

Теперь наш интерфейс выглядит следующим образом:


Клацаем на кнопочки, убеждаемся, что все работает.

Полный код проекта на данный момент:
Вложения
Тип файла: zip WindowsFormsApplication396 (5).zip (225.5 Кб, 102 просмотров)
5
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 14:59  [ТС]
Шаг 6: Добавление бизнес-логики, рефакторинг

Бизнес-логика это набор правил по которым функционирует доменная модель. Часто бизнес-логика это собственно то, что делает программа, помимо хранения данных. До этого момента у нас в программе практически не было бизнес-логики.

Внимательно посмотрим на модель данных и на поля-идентификаторы Quest.Id и Alternative.Code. Исходя из бизнес-логики предметной области, понятно, что идентификаторы должны быть уникальными. Quest.Id должен быть уникальным в пределах опросника, а Alternative.Code должен быть уникальным в пределах одного вопроса.

Однако, наш интерфейс позволяет вбивать произвольные идентификаторы, и возможна ситуация, когда будет несколько вопросов с одинаковыми Id, или несколько альтернатив с одинаковыми кодами.

Постараемся этого избежать. Во-первых, сделаем правки в методах добавления вопроса и добавления альтернативы. Пусть эти методы сами генерируют уникальное имя:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    /// <summary>
    /// Производит манипуляции с опросником
    /// </summary>
    class QuestionnaireManipulator
    {
        private const string DefaultQuestPrefix = "A";
 
        /// <summary>
        /// Добавить новый вопрос
        /// </summary>
        public Quest AddNewQuest(Questionnaire questionnaire)
        {
            //подбираем уникальное имя вопроса
            var counter = 1;
            var name = DefaultQuestPrefix + counter;
 
            while (questionnaire.Any(q => q.Id == name))//увеличиваем счетчик, пока не найдем имени, которого еще нет в опроснике
            {
                counter++;
                name = DefaultQuestPrefix + counter;
            }
            //
            var quest = new Quest() {Id = name, Title = "Текст вопроса"};
            questionnaire.Add(quest);
            return quest;
        }
 
        //....
    }
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    /// <summary>
    /// Производит манипуляции с вопросом
    /// </summary>
    class QuestManipulator
    {
        /// <summary>
        /// Добавить новую альтернативу
        /// </summary>
        public Alternative AddNewAlt(Quest quest)
        {
            //подбираем уникальный код альтернативы
            var code = 1;
 
            while (quest.Any(a => a.Code == code))//увеличиваем счетчик, пока не найдем кода, которого еще нет
                code++;
 
            //
            var alt = new Alternative() { Code = code, Title = "Вариант "  + code };
            quest.Add(alt);
            return alt;
        }
 
        //....
    }
Теперь сервисы для опросника и вопроса сами генерируют уникальные идентификаторы. Однако этого недостаточно. Потому что мы хотим сохранить для пользователя возможность изменить имя вопроса или код альтернативы. Поэтому, после изменений может возникнуть ситуация, что возникнут дубликаты идентификаторов.

Что бы этого избежать, создадим еще один сервис QuestionnaireValidator, который будет проверять корректность всех идентификаторов в опроснике и генерировать исключение, если что-то не в порядке:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    /// <summary>
    /// Проверяет корректность опросника.
    /// Генерирует исключение с текстом, описывающим суть проблемы.
    /// </summary>
    class QuestionnaireValidator
    {
        public void Validate(Questionnaire questionnaire)
        {
            //проверяем уникальность имен вопросов
            var names = new HashSet<string>();
            foreach(var q in questionnaire)
            if(!names.Add(q.Id))
                throw new Exception("Дублируется имя вопроса " + q.Id);
 
            //проверяем уникальность кодов альтернатив
            foreach (var quest in questionnaire)
            {
                var codes = new HashSet<int>();
                foreach (var a in quest)
                if (!codes.Add(a.Code))
                    throw new Exception("В вопросе " + quest.Id + " дублируется код альтернативы " + a.Code);
            }
        }
    }
Будем вызывать этот метод каждый раз перед сохранением опросника в файл и при запуске опросника на исполнение:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
        private void SaveQuestionnaireToFile(string filePath)
        {
            //проверяем опросник
            new QuestionnaireValidator().Validate(questionnaire);
            //сохраняем в файл
            SaverLoader.Save(questionnaire, filePath);
        }
 
        private void RunQuestionnaire()
        {
            //проверяем опросник
            new QuestionnaireValidator().Validate(questionnaire);
            //todo ...
        }
Это гарантирует, что сохраненный файл опросника или опросник отправленный на выполнение - всегда будут корректны, и в них будет соблюдаться целостность данных.

Обратим внимание на то, что QuestionnaireValidator генерирует исключение. Это исключение нужно отлавливать и выдавать пользователю сообщение. Однако, таких мест будет много, и мне лень везде писать try/catch.
Для того, что бы этого избежать, будем использовать глобальный обработчик исключений, который реализуется в классе ExceptionHandler:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
    public static class ExceptionHandler
    {
        public static void Init()
        {
            Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
            Application.ThreadException -= Handle;
            Application.ThreadException += Handle;
            AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
            AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
        }
 
        static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
        {
            //здесь обрабатываются исключения не UI потоков
            var ex = e.ExceptionObject as Exception;
            if (ex != null)
            {
                while (ex.InnerException != null)
                    ex = ex.InnerException;
                MessageBox.Show(ex.Message, "Thread exception");
            }
            else
                MessageBox.Show(e.ExceptionObject.ToString(), "Thread exception");
        }
 
        static void Handle(object sender, ThreadExceptionEventArgs e)
        {
            var ex = e.Exception;
            while (ex.InnerException != null)
                ex = ex.InnerException;
 
#if DEBUG
            using (var exceptionDlg = new ThreadExceptionDialog(ex))
            {
                var res = exceptionDlg.ShowDialog();
                if (res == DialogResult.Abort)
                    Application.Exit();
            }
#else
            MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
#endif
        }
    }
В начале работы программы (прямо в Main), нужно вызвать метод ExceptionHandler.Init(), после чего все исключения в интерфейсе можно не отлавливать, все они будет обработаны в ExceptionHandler, и пользователю будет выдано сообщение об ошибке.

Далее. Посмотрим внимательно на код метода QuestionnaireManipulator.MoveQuest() и код QuestManipulator.MoveAlt(). Видим, что код этих методов практически одинаков, а различия только в типах данных. Поскольку, согласно принципу DRY, код не должен дублироваться, то вынесем этот метод в отдельный метод отдельного класса. Дадим ему имя ListHelper. Сделаем там обобщенный метод, который может передвигать элементы в списке любого типа:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    static class ListHelper
    {        
        /// <summary>
        /// Перемещение элемента вверх/вниз по списку
        /// </summary>
        public static void MoveElement<T>(IList<T> list, T element, int dir)
        {
            //получаем текущий индекс
            var i = list.IndexOf(element);
            if (i < 0) return;//хм...
 
            if (i == 0 && dir < 0) return; //не можем переместить вверх, потому что вопрос и так первый
            if (i >= list.Count - 1 && dir > 0) return; //не можем переместить вниз, потому что вопрос и так последний
 
            //перемещаем
            list.RemoveAt(i);
            list.Insert(i + dir, element);
        }
    }
В методах QuestionnaireManipulator.MoveQuest() и QuestManipulator.MoveAlt() сделаем просто перевызов метода хелпера:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        /// <summary>
        /// Перемещение вопроса
        /// </summary>
        public void MoveQuest(Questionnaire questionnaire, Quest quest, int dir)
        {
            ListHelper.MoveElement(questionnaire, quest, dir);
        }
 
        //....
 
        /// <summary>
        /// Перемещение альтернативы
        /// </summary>
        public void MoveAlt(Quest quest, Alternative alt, int dir)
        {
            ListHelper.MoveElement(quest, alt, dir);
        }
Обратите внимание, я не удаляю эти методы MoveQuest и MoveAlt, хотя они теперь кажутся лишними. Дело в том, что в них позже может появиться дополнительная логика, и поэтому лучше их оставить. Кроме того, лучше что бы манипуляции с доменными объектами происходили из одного сервисного класса.

То что мы сделали только что - называется рефакторинг. Рефакторинг не меняет функционала программы, но при рефакторинге меняется код программы, с целью его оптимизации, перегруппировки, очистки от мусора, дублирования кода и т.д.

Запускаем программу. Замечаем, что новые вопросы и альтернативы теперь автоматически нумеруются. А если их изменить и создать несколько вопросов или альтернатив с одинаковыми кодами, то программа выдает сообщение об ошибке и файл не сохраняется, до тех пор, пока дублирования не будут исправлены.

Текущий вариант программы - в присоединенном файле.
Вложения
Тип файла: zip WindowsFormsApplication396 (6).zip (234.9 Кб, 97 просмотров)
6
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 15:29  [ТС]
Шаг 7: Продолжаем рефакторинг

Модель данных и логику лучше выделить в отдельный проект. Это нужно потому, что модель может использоваться в нескольких разных приложениях. Кроме того, вынеся модель в отдельный проект мы гарантированно изолируем ее от интерфейса.

Создадим отдельный проект типа Class Library. Назовем его QuestCore. Зададим неймспейс по-умолчанию QuestCoreNS.
Обратите внимание, что лучше когда имя неймспейса не совпадает с именем проекта. В противном случае, в будущем, могут возникнуть некоторые проблемы из-за совпадений имен.

Перенесем целиком папку Core из основного проекта в новый проект. Кроме того, разделим классы на две папки. В папку Model положим классы модели (Questionnaire, Quest, Alternative и т.д.). А в папку Services положим классы-сервисы (QuestionnaireManipulator, QuestionnaireValidator и т.д.). Кроме того, создадим еще папку Helpers, куда вынесем классы ListHelper и SaverLoader.

Папки Services и Helpers отличаются тем, что в Services хранятся классы, которые связаны с доменной моделью, а папка Helpers содержит универсальные классы, которые никак не связаны с моделью.

Теперь проект выглядит следующим образом:


Далее, на предыдущем шаге мы добавляли некоторый функционал, но не создали юнит-тестов для него.
Создаем Unit - тест для QuestionnaireValidator:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
        //проверка валидатора
        [TestMethod]
        public void TestMethod3()
        {
            //создаем опросник
            var questionnaire = new Questionnaire();
            //создаем вопросы с одинаковыми именами
            questionnaire.Add(new Quest { Id = "A1", Title = "Заголовок вопроса" });
            questionnaire.Add(new Quest { Id = "A1", Title = "Заголовок вопроса" });
 
            //запускаем валидатор
            try
            {
                new QuestionnaireValidator().Validate(questionnaire);
                Assert.Fail("Validator passes duplicates");
            }
            catch
            {
                //все ок
            }
        }
Запускаем Unit - тесты, убеждаемся что все работает.

Текущий исходник:
Вложения
Тип файла: zip WindowsFormsApplication396 (7).zip (292.8 Кб, 79 просмотров)
4
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 19:00  [ТС]
Шаг 8: Создаем модель и интерфейс для интервью

Все что мы разрабатывали в предыдущих шагах было ориентировано на конструктор опросника. Теперь разработаем приложение для ответов на вопросы.

Поскольку прохождение интервью - довольно сложный процесс со своей бизнес-логикой, то для него нужно создать свою модель и свои сервисы.

Но прежде доработаем модель самого опросника. Вспоминаем, что в техническом задании говорилось о том, что вопросы и альтернативы показываются только при заданных пользователем условиях.

Для того, что бы отобразить этот факт, создадим класс Condition, который инкапсулирует в себе проверку условий. Мы пока не знаем внутреннюю реализацию этого класса, поэтому пока сделаем заглушку в методе проверки условий:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
    /// <summary>
    /// Условие, которое может возвращать true или false в зависимости от ответов в Anketa
    /// </summary>
    public class Condition
    {
        /// <summary>
        /// Выполняется ли условие для данной анкеты?
        /// </summary>
        public bool Check(Anketa anketa)
        {
            //todo
            return true;//temp!
        }
    }
Добавим поле класса Condition в классы Quest и Alternative:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    /// <summary>
    /// Вопрос.
    /// Содержит список альтернатив.
    /// </summary>
    [Serializable]
    public class Quest : List<Alternative>
    {
        //....
 
        /// <summary>
        /// Условие показа
        /// </summary>
        public Condition Condition { get; set; }
    }
 
    /// <summary>
    /// Альтернатива вопроса
    /// </summary>
    [Serializable]
    public class Alternative
    {
        //....
 
        /// <summary>
        /// Условие показа
        /// </summary>
        public Condition Condition { get; set; }
    }
Теперь мы можем проверять, нужно ли показывать вопрос или альтернативу, в зависимости от уже отвеченных вопросов - вызвав метод Condition.Check(Anketa anketa).

Далее, вспоминаем, что в ТЗ говорилось о том, что вопросы бывают разных типов. Отобразим этот факт, создав тип QuestType и создав поле этого типа в классе Quest:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    /// <summary>
    /// Вопрос.
    /// Содержит список альтернатив.
    /// </summary>
    [Serializable]
    public class Quest : List<Alternative>
    {
        // ....
 
        /// <summary>
        /// Тип вопроса
        /// </summary>
        public QuestType QuestType { get; set; }
    }
 
    /// <summary>
    /// Тип вопроса
    /// </summary>
    [Serializable]
    public enum QuestType
    {
        /// <summary>
        /// Выбор одной альтернативы из фиксированного списка альтернатив
        /// </summary>
        SingleAnswer,
        /// <summary>
        /// Пользователь может вбить произвольный ответ в текстовое поле
        /// </summary>
        OpenQuestion
    }
Идем далее. Создадим модель для процесса прохождения интервью.
Создадим класс Interview:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    /// <summary>
    /// Процесс прохождения интервью
    /// </summary>
    public class Interview
    {
        internal Questionnaire questionnaire { get; private set; }
        internal Anketa anketa { get; private set; }
 
        /// <summary>
        /// Ответы, уже данные респондентом
        /// </summary>
        public List<Answer> PassedAnswers { get; set; } = new List<Answer>();
 
        /// <summary>
        /// Текущий вопрос, на который отвечает респондент в данный момент
        /// </summary>
        public Answer CurrentAnswer { get; set; }
 
        /// <summary>
        /// Интервью завершено?
        /// </summary>
        public bool IsFinished { get; internal set; }
 
        public Interview(Questionnaire questionnaire, Anketa anketa)
        {
            this.questionnaire = questionnaire;
            this.anketa = anketa;
        }
    }
Класс содержит текущий вопрос, на который отвечает пользователь в данный момент CurrentAnswer и список уже отвеченных вопросов PassedAnswers.

Создадим также сервис InterviewManipulator который будет реализовывать бизнес-логику интервью:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
    /// <summary>
    /// Функционал для Interview
    /// </summary>
    public class InterviewManipulator
    {
        private Interview interview;
 
        public InterviewManipulator(Interview interview)
        {
            this.interview = interview;
        }
 
        /// <summary>
        /// Переходим к следующему вопросу
        /// </summary>
        public void GoToNextQuestion()
        {
            if (interview.IsFinished) return;//интервью уже завершено
 
            //получаем индекс текущего вопроса
            var currentQuestIndex = interview.CurrentAnswer == null ? -1 : interview.questionnaire.FindIndex(q => q.Id == interview.CurrentAnswer.QuestId);
 
            //ищем следующий вопрос, у которого выполняется условие показа
            for (int i = currentQuestIndex + 1; i < interview.questionnaire.Count; i++)
            {
                //получаем условие отображения вопроса
                var condition = interview.questionnaire[i].Condition;
 
                //условие выполняется?
                if (condition == null || condition.Check(interview.anketa))
                {
                    //нашли
                    var quest = interview.questionnaire[i];
 
                    //добавляем текущий ответ в список отвеченных вопросов
                    if(interview.CurrentAnswer != null)
                        interview.PassedAnswers.Add(interview.CurrentAnswer);
 
                    //создаем новый Answer для нового вопроса
                    interview.CurrentAnswer = new Answer { QuestId = quest.Id };
                    interview.anketa.Add(interview.CurrentAnswer);
                    //
                    return;
                }
            }
 
            //нет следующего вопроса, анкета завершена
            if (interview.CurrentAnswer != null)
            {
                interview.PassedAnswers.Add(interview.CurrentAnswer);
                interview.CurrentAnswer = null;
            }
            interview.IsFinished = true;
        }
 
        /// <summary>
        /// Получение списка альтернатив, разрешенных к показу, для текущего вопроса
        /// </summary>
        public IEnumerable<Alternative> GetAllowedAlternatives()
        {
            if (interview.IsFinished) yield break;//интервью уже завершено
            if (interview.CurrentAnswer == null) yield break;//нет текущего вопроса
 
            //получаем текущий вопрос
            var quest = interview.questionnaire.First(q => q.Id == interview.CurrentAnswer.QuestId);
 
            //перебираем альтернативы, проверяем условие показа, возвращаем те, которые разрешены к показу
            foreach(var alt in quest)
            if (alt.Condition == null || alt.Condition.Check(interview.anketa))
                yield return alt;
        }
    }
Этот класс содержит два метода: GoToNextQuestion() - для перехода на следующий вопрос, и GetAllowedAlternatives() который возвращает список доступных к показу альтернатив для текущего вопроса. Обращаем внимание на то, что оба метода используют проверку условий показа вопросов и альтернатив.

Теперь можем приступать к созданию интерфейса для прохождения интервью.
Создадим новый проект типа Windows Forms. Назовем его QuestInterview.

В главной форме делаем следующий код:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
    public partial class MainForm : Form
    {
        //интервью
        private Interview interview;
 
        //функционал интервью
        private InterviewManipulator interviewManipulator;
 
        //текущий опросник
        private Questionnaire questionnaire;
 
        //текущая анкета
        private Anketa anketa;
 
        public MainForm()
        {
            InitializeComponent();
        }
 
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
 
            //создаем новую анкету
            anketa = new Anketa();
 
            //запрашиваем опросник, если он не задан
            if (questionnaire == null)
            {
                var ofd = new OpenFileDialog() { Filter = "Опросник|*.q", Title = "Выберите опросник" };
                if (ofd.ShowDialog(this) == DialogResult.OK)
                {
                    questionnaire = SaverLoader.Load<Questionnaire>(ofd.FileName);
                }
                else
                {
                    //если пользователь не выбрал опросник - просто завершаем приложение
                    Close();
                    return;
                }
            }
 
            //создаем процесс опроса (интервью)
            interview = new Interview(questionnaire, anketa);
            //создаем манипулятор для интервью
            interviewManipulator = new InterviewManipulator(interview);
 
            //переходим к первому вопросу
            interviewManipulator.GoToNextQuestion();
 
            //строим интерфейс
            Build();
        }
 
        /// <summary>
        /// Построение интерфейса для опросника
        /// </summary>
        public void Build()
        {
            //очищаем панель ответов
            pnAnswers.Controls.Clear();
 
            //отображаем уже отвеченные вопросы
            foreach (var answer in interview.PassedAnswers)
            {
                //создаем панель ответа
                var pn = new AnswerPanel();
                //строим
                pn.Build(interviewManipulator, questionnaire.First(q => q.Id == answer.QuestId), answer, true);
                //добавляем на форму
                pn.Parent = pnAnswers;
            }
 
            //отображаем вопрос, на который нужно ответить
            if (interview.CurrentAnswer != null)
            {
                //создаем панель ответа
                var pn = new AnswerPanel();
                //строим
                pn.Build(interviewManipulator, questionnaire.First(q => q.Id == interview.CurrentAnswer.QuestId), interview.CurrentAnswer, false);
                //добавляем на форму
                pn.Parent = pnAnswers;
            }
 
            //добавляем кнопку "далее"
            btNext.Parent = interview.IsFinished ? null : pnAnswers;
            btFinish.Parent = interview.IsFinished ? pnAnswers : null;
        }
 
        private void btNext_Click(object sender, EventArgs e)
        {
            //переходим к следующему вопросу
            interviewManipulator.GoToNextQuestion();
            //строим интерфейс
            Build();
        }
 
        private void btFinish_Click(object sender, EventArgs e)
        {
            //предлагаем сохранить анкету
            if (MessageBox.Show("Сохранить анкету?", "Сохранение анкеты", MessageBoxButtons.OKCancel) == DialogResult.OK)
            {
                var sfd = new SaveFileDialog() { Filter = "Анкета|*.a", FileName = Guid.NewGuid().ToString() };
                if (sfd.ShowDialog(this) == DialogResult.OK)
                {
                    //сохраняем анкету
                    SaverLoader.Save(anketa, sfd.FileName);
                }
            }
            //выходим
            Close();
        }
    }
Код содержит методы для открытия опросника, сохранения заполненной анкеты, а также обвязку для отображения текущего интервью.

Для отображения конкретного вопроса, разрабатываем отдельный UserControl, который будет отображать вопрос, и позволять пользователю отвечать на него:


Вот код этого контрола:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
    /// <summary>
    /// Отображение вопроса и ответа
    /// </summary>
    public partial class AnswerPanel : UserControl
    {
        private Quest quest;
        private Answer answer;
        private InterviewManipulator interviewManipulator;
 
        /// <summary>
        /// Пользователь указал/изменил ответ
        /// </summary>
        public event Action Changed = delegate { };
 
        public AnswerPanel()
        {
            InitializeComponent();
        }
 
        /// <summary>
        /// Построение интерфейса
        /// </summary>
        public void Build(InterviewManipulator interviewManipulator, Quest quest, Answer answer, bool readOnly)
        {
            this.interviewManipulator = interviewManipulator;
            this.quest = quest;
            this.answer = answer;
 
            lbQuestTitle.Text = quest.Title;
 
            //очищаем панель альтернатив
            pnMain.Controls.Clear();
 
            if (readOnly)
            {
                //строим ответы в режиме readonly
                BuildReadOnlyAnswerInterface();
            }
            else
            { 
                //строим альтернативы, в зависимости от типа вопроса
                switch (quest.QuestType)
                {
                    case QuestType.SingleAnswer: BuildSingleAnswerInterface(); break;
                    case QuestType.OpenQuestion: BuildOpenAnswerInterface(); break;
                }
            }
        }
 
        private void BuildSingleAnswerInterface()
        {
            //создаем комбобокс
            var cb = new ComboBox();
            cb.DataSource = interviewManipulator.GetAllowedAlternatives().ToList();//отображаем список разрешенных к показу альтернатив
            cb.ValueMember = "Code";
            cb.DisplayMember = "Title";
            cb.DropDownStyle = ComboBoxStyle.DropDownList;
            cb.Parent = pnMain;
            cb.SelectedValueChanged += (o, O) => OnValueSelected((int)cb.SelectedValue);//обрабатываем выбор
        }
 
        private void BuildOpenAnswerInterface()
        {
            //создаем текстбокс
            var tb = new TextBox();
            tb.Parent = pnMain;
            tb.TextChanged += (o, O) => OnValueSelected(tb.Text);//обрабатываем выбор
        }
 
        private void BuildReadOnlyAnswerInterface()
        {
            //создаем лейбу
            var lb = new Label();
            //получаем альтернативу
            var alt = quest.FirstOrDefault(a => a.Code == answer.AlternativeCode);
            lb.Text = alt?.Title + " " + answer.Text;
            lb.Parent = pnMain;
        }
 
        private void OnValueSelected(int alternativeCode)
        {
            answer.AlternativeCode = alternativeCode; //отправляем выбранное значение в доменный объект
            Changed();//сигнализируем наверх, о том, что пользователь что-то выбрал
        }
 
        private void OnValueSelected(string text)
        {
            answer.Text = text; //отправляем выбранное значение в доменный объект
            Changed();//сигнализируем наверх, о том, что пользователь что-то выбрал
        }
    }
Контрол позволяет отображать комбобокс для ответа на вопрос с фиксированными альтернативами, либо текстбокс для ответа на открытый вопрос. Также контрол может работать в режиме readonly, при котором он только отображает уже отвеченный вопрос, но не позволяет его менять. Этот режим нужен для отображения уже отвеченных вопросов интервью.

Запускаем приложение, открываем заранее созданную анкету, убеждаемся, что все работает:


Полный код проекта на этом шаге:
Вложения
Тип файла: zip WindowsFormsApplication396 (8).zip (437.8 Кб, 88 просмотров)
4
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 21:09  [ТС]
Шаг 9: Калькулятор условий, редактор условий

В интерфейсе конструктора была кнопочка запуска интервью Run, которая не имела функционала. Теперь, когда у нас есть интерфейс для прохождения интервью, мы можем сделать запуск тестового интервью прямо из конструктора:
C#
1
2
3
4
5
6
7
        private void RunQuestionnaire()
        {
            //проверяем опросник
            new QuestionnaireValidator().Validate(questionnaire);
            //запускаем интервью
            new QuestInterviewNS.MainForm(questionnaire).ShowDialog(this);
        }
Далее, доработаем интерфейс конструктора. На предыдущем шаге мы добавили в модель тип вопроса и условия отображения. Теперь сделаем поддержку этих свойств в интерфейсе:


Вопрос и альтернативы теперь содержат ссылку "Если", при нажатии на которую, открывается окно конструктора условий. Само окно конструктора пока пустое. Если условие было сформировано, вместо ссылки будет показан текст самого условия.

Также появился комбобокс с выбором типа вопроса.

Теперь разработаем редактор условий.
Я долго думал, как реализовать условия в этом проекте, так что бы было максимально просто. У меня есть как минимум четыре разных варианта реализации. Но я выбрал тот, который требует минимум кода и в тоже время достаточно универсален - через метод DateTable.Compute(). Этот метод может вычислять логические выражения с поддержкой скобок, связок and/or/not, операторов =, <> и т.д.

Для вычисления выражений создадим сервисный класс ConditionCalculator:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    /// <summary>
    /// Вычисляет условия
    /// </summary>
    public class ConditionCalculator
    {
        private const string QuestNamePattern = @"\b[A-Z]\d+\b";
 
        public bool Calculate(Anketa anketa, Condition condition)
        {
            if (condition == null || string.IsNullOrEmpty(condition.Expression))
                return true;
 
            //заменяем имена вопросов на значения
            var expression = Regex.Replace(condition.Expression, QuestNamePattern,
                (m) =>
                {
                    var questId = m.Value;
                    //ищем ответ
                    var answer = anketa.FirstOrDefault(a => a.QuestId == questId);
                    if (answer != null)
                    {
                        //заменяем имя вопроса на ответ
                        if (string.IsNullOrEmpty(answer.Text))
                            return answer.AlternativeCode.ToString();
 
                        return "'" + answer.Text + "'";
                    }
                    return "0";//код отсутствия ответа
                });
 
            //вычислем выражение
            return (bool) new DataTable().Compute(expression, null);
        }
 
        public void Check(Questionnaire questionnaire, string expression)
        {
            if (string.IsNullOrEmpty(expression))
                return;
 
            //проверяем имена вопросов
            foreach(Match m in Regex.Matches(expression, QuestNamePattern))
                if (questionnaire.All(q => q.Id != m.Value))
                    throw new Exception("Вопрос " + m.Value + " не найден");
 
            //проверяем синтаксис
            Calculate(new Anketa(), new Condition {Expression = expression});
        }
    }
Класс содержит два метода - Calculate, который вычисляет выражение и возвращает bool. И метод Check, который определяет корректность выражения и генерирует исключение, если вопрос, указанный в выражении - не найден в опроснике. Также генерируется исключение, в случае синтаксической ошибки в выражении.

Теперь можем написать реализацию интерфейса редактора условий:


Код редактора:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
    public partial class ConditionForm : Form
    {
        private Condition condition;
        private Questionnaire questionnaire;
 
        public event Action Changed = delegate { };
 
        public ConditionForm()
        {
            InitializeComponent();
        }
 
        public void Build(Questionnaire questionnaire, Condition condition)
        {
            this.questionnaire = questionnaire;
            this.condition = condition;
 
            tbExpression.Text = condition.Expression;
        }
 
        private void btOk_Click(object sender, EventArgs e)
        {
            var expression = tbExpression.Text.Trim();
 
            //проверяем корректность выражения
            try
            {
                new ConditionCalculator().Check(questionnaire, expression);
            }
            catch(Exception ex)
            {
                MessageBox.Show(ex.Message);
                return;
            }
            //обновляем доменный объект
            condition.Expression = expression;
            //сигнализируем наверх о том, что объект поменялся
            Changed();
            //закрываем окно
            DialogResult = DialogResult.OK;
        }
    }
Как видим, при нажатии кнопки OK происходит проверка корректности выражения, и если все правильно - выражение заносится в доменный объект Condition. В противном случае - выдается сообщение пользователю об ошибки в выражении.

Создаем тестовую анкету с условиями:


Запускаем анкету на выполнение, убеждаемся что условия работают:


Создаем Unit-тест на калькулятор:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
        //проверка калькулятора условий
        [TestMethod]
        public void TestMethod4()
        {
            //создаем опросник
            var questionnaire = new Questionnaire();
            //создаем вопросы
            questionnaire.Add(new Quest { Id = "A1"});
            questionnaire.Add(new Quest { Id = "A2"});
 
            //создакем калькулятор
            var calc = new ConditionCalculator();
 
            //проверка синтаксиса с ошибкой
            try
            {
                calc.Check(questionnaire, "A1 - 23");
                calc.Check(questionnaire, "A1 = 2 + ");
                Assert.Fail("Calculator.Check() false positive");
            }
            catch
            {
                //все ок
            }
 
            //проверка несуществующих вопросов
            try
            {
                calc.Check(questionnaire, "A23 = 1");
                Assert.Fail("Calculator.Check() false positive");
            }
            catch
            {
                //все ок
            }
 
            //проверка корректных выражений
            var anketa = new Anketa();
            anketa.Add(new Answer {QuestId = "A1", AlternativeCode = 1});
            anketa.Add(new Answer { QuestId = "A2", AlternativeCode = 1 });
            anketa.Add(new Answer { QuestId = "A3", Text = "YES" });
            Assert.IsTrue(calc.Calculate(anketa, new Condition {Expression = "A1 = 1"}));
            Assert.IsTrue(calc.Calculate(anketa, new Condition { Expression = "A1 = A2" }));
            Assert.IsTrue(calc.Calculate(anketa, new Condition { Expression = "A3 <> 'NO'" }));
            Assert.IsTrue(calc.Calculate(anketa, new Condition { Expression = "A1 + A2 = 2" }));
            Assert.IsTrue(calc.Calculate(anketa, new Condition { Expression = "(A1 = 1) and (A3 = 'YES')" }));
            Assert.IsTrue(calc.Calculate(anketa, new Condition { Expression = "(A1 = 5) or (A3 = 'YES')" }));
        }
Запускаем тест, убеждаемся что тесты проходят нормально.

Полный код проекта на этом этапе:
Вложения
Тип файла: zip WindowsFormsApplication396 (9).zip (509.0 Кб, 103 просмотров)
4
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
30.10.2018, 23:58  [ТС]
Шаг 10: Окончательная доводка приложения

В начальном ТЗ было указано, что должен быть экспорт данных во внешние форматы.
Сделаем экспорт заполненных анкет в CSV файл.
Для этого создадим сервисный класс Export:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
    /// <summary>
    /// Экспорт анкет во внешние форматы
    /// </summary>
    public class Export
    {
        public string Separator { get; set; } = ";";
        public Encoding Encoding { get; set; } = Encoding.UTF8;
 
        /// <summary>
        /// Экспорт заполненых анкет в CSV
        /// </summary>
        public void ExportToCSV(IEnumerable<Anketa> anketas, string fileName)
        {
            //получаем имена всех вопросов
            var questNames = anketas.SelectMany(a => a).Select(a => a.QuestId).Distinct().ToArray();
            //открываем файл на запись
            using (var sw = new StreamWriter(fileName, false, Encoding))
            {
                //пишем шапку CSV
                sw.WriteLine(string.Join(Separator, questNames));
                //перебираем анкеты
                foreach (var anketa in anketas)
                {
                    var nameToAnswer = new Dictionary<string, string>();
                    //перебираем ответы
                    foreach (var answer in anketa)
                        nameToAnswer[answer.QuestId] = answer.ToString();
 
                    //пишем ответы
                    for (int i = 0; i < questNames.Length; i++)
                    {
                        if (i > 0) sw.Write(Separator);
                        if (nameToAnswer.ContainsKey(questNames[i]))
                            sw.Write(nameToAnswer[questNames[i]]);
                    }
 
                    sw.WriteLine();
                }
            }
        }
    }
В главной форме сделаем кнопочку Экспорт в CSV, по нажатию на которую пользователь должен выбрать папку, в которой находятся файлы анкет (с расширением .a), после чего программа читает все найденные в папке анкеты и экспортирует их в csv файл:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
        private void btExportCSV_Click(object sender, EventArgs e)
        {
            var ofd = new OpenFolderDialog();
            if (ofd.ShowDialog(this) == DialogResult.OK)
            {
                //получаем список анкет в выбранной папке
                var anketas = Directory.GetFiles(ofd.Folder, "*.a").Select(path => SaverLoader.Load<Anketa>(path)).ToList();
                if (anketas.Count == 0)
                {
                    MessageBox.Show("В этой папке не найдены анкеты");
                    return;
                }
 
                //запрашиваем имя выходного csv файла
                var sfd = new SaveFileDialog() {Filter = "CSV|*.csv"};
                if (sfd.ShowDialog() == DialogResult.OK)
                {
                    //экспортируем
                    new Export().ExportToCSV(anketas, sfd.FileName);
                    MessageBox.Show("Экспортировано " + anketas.Count + " анкет");
                }
            }
        }
После экспорта, мы можем просмотреть результаты анкетирования в Excel:


Далее. В процессе разработки валидатора опросника, я упустил из виду тот случай, когда у вопроса не создана ни одна альтернатива. Такого быть не должно, поэтому добавляем такую проверку в валидатор:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    /// <summary>
    /// Проверяет корректность опросника.
    /// Генерирует исключение с текстом, описывающим суть проблемы.
    /// </summary>
    public class QuestionnaireValidator
    {
        public void Validate(Questionnaire questionnaire)
        {
            //проверяем уникальность имен вопросов
            var names = new HashSet<string>();
            foreach(var q in questionnaire)
            if(!names.Add(q.Id))
                throw new Exception("Дублируется имя вопроса " + q.Id);
 
            //проверяем уникальность кодов альтернатив и их число
            foreach (var quest in questionnaire)
            {
                var codes = new HashSet<int>();
                foreach (var a in quest)
                if (!codes.Add(a.Code))
                    throw new Exception("В вопросе " + quest.Id + " дублируется код альтернативы " + a.Code);
                if(quest.Count == 0)
                    throw new Exception("В вопросе " + quest.Id + " нет альтернатив");
            }
        }
    }
Далее. Сделаем отслеживание изменений в документе. Для этого в главной форме создадим флажок bool changed, который будет равен true, если в опроснике есть несохраненные изменения.
Будем отлавливать событие Changed у панелей вопросов, и если событие срабатывает, то присваиваем флажку true.
При сохранении же файла, флажок changed сбрасывается.

Теперь, при выходе из программы или при попытке открыть новый файл, сначала будет проверяться есть ли изменения в текущем опроснике, и если изменения есть - пользователю будет предлагаться сохранить текущий опросник:


Кроме того, кнопка "Сохранить" теперь будет активна только тогда, когда в документе есть изменения.

Далее. Если вы запускали программу, то наверняка заметили, что при перестройке формы, контролы мигают и сбрасывается скорлл. Это очень неудобно. Для того, что бы этого избежать, создаем специальный хелпер, который будет запоминать позицию скролла, отключать прорисовку контрола на время перестройки, и затем восстанавливать скролл и отрисовку:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    public class ControlHelper
    {
        private int scrollValue;
        private ScrollableControl ctrl;
 
        [DllImport("user32.dll")]
        private static extern int SendMessage(IntPtr hWnd, Int32 wMsg, bool wParam, Int32 lParam);
        private const int WM_SETREDRAW = 11;
 
        public ControlHelper(ScrollableControl ctrl)
        {
            this.ctrl = ctrl;
            StopDrawing();
        }
 
        private void StopDrawing()
        {
            SendMessage(ctrl.Handle, WM_SETREDRAW, false, 0);
            scrollValue = ctrl.VerticalScroll.Value;
            ctrl.SuspendLayout();
        }
 
        public void ResumeDrawing()
        {
            ctrl.VerticalScroll.Value = Math.Min(ctrl.VerticalScroll.Maximum, scrollValue);
            ctrl.ResumeLayout();
            SendMessage(ctrl.Handle, WM_SETREDRAW, true, 0);
            ctrl.Refresh();
        }
    }
Добавляем вызов этого класса в методы Build для MainForm и QuestPanel.
Теперь панели не мигает и скролл сохраняет свое положение при перестройке.

Сделаем окончательные правки:
- Зададим иконки для приложений и для форм.
- Переименуем проект в QuestConstructor. Переименуем также неймспейс на QuestConstructorNS.
- Удалим лишние неиспользуемые иконки из ресурсов.
- Сделаем небольшие изменения в интерфейсе, удалим лишние надписи, уменьшим некоторые иконки.

Приложение готово:


Полный код проекта:
Вложения
Тип файла: zip QuestConstructor.zip (904.1 Кб, 448 просмотров)
4
187 / 100 / 19
Регистрация: 15.09.2011
Сообщений: 801
31.10.2018, 05:41
Здравствуйте.

Цитата Сообщение от Storm23 Посмотреть сообщение
Кликните здесь для просмотра всего текста

C#
1
2
3
4
5
6
7
8
//подбираем уникальное имя вопроса
var counter = 1;
var name = DefaultQuestPrefix + counter;
while (questionnaire.Any(q => q.Id == name))//увеличиваем счетчик, пока не найдем имени, которого еще нет в опроснике
{
counter++;
name = DefaultQuestPrefix + counter;
}
name = DefaultQuestPrefix + Guid.NewGuid().ToString().Split('-').First(); /*не следует особо париться*/ /*предположил пессимистичный вариант, что количество вопросов - 10*10^10*/

Цитата Сообщение от Storm23 Посмотреть сообщение
Кликните здесь для просмотра всего текста

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void Validate(Questionnaire questionnaire)
{
//проверяем уникальность имен вопросов
var names = new HashSet<string>();
foreach(var q in questionnaire)
if(!names.Add(q.Id))
throw new Exception("Дублируется имя вопроса " + q.Id);
//проверяем уникальность кодов альтернатив и их число
foreach (var quest in questionnaire)
{
var codes = new HashSet<int>();
foreach (var a in quest)
if (!codes.Add(a.Code))
throw new Exception("В вопросе " + quest.Id + " дублируется код альтернативы " + a.Code);
if(quest.Count == 0)
throw new Exception("В вопросе " + quest.Id + " нет альтернатив");
}
}
Понятно, что для демонстрации - вот тут выводится элемент проверки. Если идут проверки на вводе, то можно не добавлять, конечно. Тогда, для полной реализации ООП следует наследоваться от этого класса и при вводе использовать его методы. Естественно, класс расширится. Вообще-то желательно не усложнять алгоритм именно на уникальностях - если есть альтернатива, которая, в принципе не требует проверок вообще.

Я переделал вот так:
Кликните здесь для просмотра всего текста

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
[Serializable]
    public class Quest : List<Alternative>
    {
        private string _defaultPrefix { get; set; }
        public Quest(string defaultPrefix) {
            _defaultPrefix = defaultPrefix;
            GenId();
        }
 
        public void GenId() {
            Id = Guid.NewGuid().ToString().Split('-').First();
        }
 
        private string _id { get; set; }
 
        /// <summary>
        /// Идентификатор вопроса
        /// </summary>
        public string Id { get { return _id; } set { _id = string.Format("{0}_{1}", _defaultPrefix ,value); } }
....................................................................................................................................................
 
    public class QuestionnaireValidator
    {
        protected internal bool CheckQuestByUnique(Questionnaire questionnaire, Quest quest) {
 
            return !questionnaire.Any(q => q.Id == quest.Id);
        }
....................................................................................................................................................
 
        private const string DefaultQuestPrefix = "A";
 
        /// <summary>
        /// Добавить новый вопрос
        /// </summary>
        public Quest AddNewQuest(Questionnaire questionnaire)
        {
            var quest = new Quest(DefaultQuestPrefix) { Title = "Текст вопроса"};
 
            while(!CheckQuestByUnique(questionnaire, quest))
            {
                quest.GenId();
            }
 
            questionnaire.Add(quest);
            return quest;
        }

Просто как идею преподнёс.
Единственно, потребуется проверка на этапе, когда идёт загрузка из файла - там могут быть косяки просто.
0
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
31.10.2018, 09:20  [ТС]
Цитата Сообщение от Umatkot_Primtep Посмотреть сообщение
name = DefaultQuestPrefix + Guid.NewGuid().ToString().Split('-').First(); /*не следует особо париться*/ /*предположил пессимистичный вариант, что количество вопросов - 10*10^10*/
Нет, нет. Это не подходит. Имя вопроса и код альтернатив это не просто уникальные идентификаторы. Это human readable идентификаторы. То есть они должны быть легко читающиеся.

Дело в том, что эти идентификаторы используются:
1 ) При написании условий. В условиях пишется выражения типа A1 = 12. Это означает, что текущий вопрос или альтернатива задается только если в вопросе A1 был указан код альтернативы 12. Никто не будет писать условия типа A-12fe56-54784543432-ef898a = 12. Понимаете? Идентификатор здесь играет ту же роль что и имя переменной в программировании. Он должен быть достаточно простым.
2) При экспорте. Обратите внимание на экспорт в CSV. Там экспортируются идентификаторы вопросов в шапке и коды альтернатив в теле таблицы. Разумеется никто не хочет видеть там километровые бессмысленные имена типа GUID.
3) Так же при экспорте в программы обработки статистики. Дело в том, что профессиональные статистические пакеты(SPSS, Statistica) работают только с числами. То есть коды альтернатив должны быть только числа, а идентификаторы вопросов - не более 8 символов (если не изменяет память).
4) При написании отчетов. В отчете вы не будете писать фразы типа "20% респондентов положительно ответили на вопрос A1221121-342434-43243243243".
5) При написании скриптов и SQL запросов. Для вывода стат таблиц в том же SPSS или для вывода статистик из того-же MSSQL- вам нужно указать имена вопросов, по которым выводится статистика. Опять же нужно краткое, простое имя.
6) Ну и удобство пользователя. Пользователю удобно когда номера вопросов идут подряд. Даже если пользователь переместил вопрос по списку, все равно удобно, потому что он видит что номера идут не по порядку и вспоминает, что он делал перемещение. Как ни крути это лучше чем случайные незапоминающиеся коды.

И еще несколько моментов. Обычно, те, кто пишет такого рода программы первый раз, завязываются на сам текст вопроса или альтернативы. То есть сама формулировка вопроса и сам текст альтернативы выступают идентификатором. Это неправильно. Во-первых, потому, что формулировки могут повторяться и не быть уникальными. Во-вторых, присутствует та же проблема очень длинных названий и нечисловых кодов. И в-третьих есть проблема обратной совместимости. Если вы вдруг решили поменять формулировку вопроса (например нашли там ошибки, или звучит неоднозначно) - то анкеты, которые были пройдены ДО этого окажутся несовместимыми с новыми анкетами, потому что один и тот же вопрос будет иметь уже другое название. То же касается альтернатив. А вот если использовать идентификаторы, то такой проблемы нет, потому что формулировка вопроса может меняться сколько угодно, а идентификатор то не меняется.

Цитата Сообщение от Umatkot_Primtep Посмотреть сообщение
Я переделал вот так
Вы здесь просто растоптали Anemic модель, в которой написано все остальное
Доменные объекты в Anemic не должны содержать логику.
1
187 / 100 / 19
Регистрация: 15.09.2011
Сообщений: 801
31.10.2018, 09:50
Цитата Сообщение от Storm23 Посмотреть сообщение
Пользователю удобно когда номера вопросов идут подряд
Хорошо, просто я так глубоко не вникал.
Если все вопросы идут по порядку, тогда можно просто получить номер последнего вопроса и начинать отсчёт от него.
А если вопросы будут удаляться, то тогда будет белиберда и нарушится порядок вопросов.

Да, и ещё, я в основном досконально не просматривал, но кажется, что автор имел ввиду ту структуру, что каждый ответ будет основой для новых вопросов, т.е. будет идти как дерево, которое приведёт к конечной точке или конечному состоянию. Прилажуху запустил, а там просто вопросы и варианты в линейной структуре. По сути, ему надо будет переработать логику QuestionManipulator и немного поменять структуру Quest?
Например, если выбрал вариант 2, то возможно, перезатереть все предыдущие варианты и отвечать на вопрос, который уже касается именно варианта 2.
0
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
31.10.2018, 10:29  [ТС]
Цитата Сообщение от Umatkot_Primtep Посмотреть сообщение
Если все вопросы идут по порядку, тогда можно просто получить номер последнего вопроса и начинать отсчёт от него.
А если вопросы будут удаляться, то тогда будет белиберда и нарушится порядок вопросов.
Нет, не будет белиберда. Возможно в нумерации будут пробелы, типа A1, A3, A4, но номера будут идти по порядку (пока пользователь специально их не пересортирует).

В более навороченных системах есть автоматическая перенумерация вопросов. При запуске которой все вопросы нумеруются заново, при этом выражения в условиях автоматически заменяют старые имена на новые. Но это усложнение, я его не делал.

Цитата Сообщение от Umatkot_Primtep Посмотреть сообщение
Да, и ещё, я в основном досконально не просматривал, но кажется, что автор имел ввиду ту структуру, что каждый ответ будет основой для новых вопросов, т.е. будет идти как дерево, которое приведёт к конечной точке или конечному состоянию
Я в той теме подробно объяснял почему нельзя делать древовидную структуру. Дерево - это просто прихоть автора, который решил что оно там зачем-то нужно. Оно там НЕ нужно. Если какой либо вопрос задается только при каком-то условии, то это решается условными выражениями, которые реализованы в Condition.

PS А впрочем, вы можете реализовать через дерево. Только все вместе - с экспортом, со всем функционалом. Интересно посмотреть на эту реализацию. Я свою реализация предложил и аргументировал. Сделайте другую реализацию - я с удовольствием ее посмотрю. А так, это пока просто слова - про дерево и т.п.
1
187 / 100 / 19
Регистрация: 15.09.2011
Сообщений: 801
03.11.2018, 12:01
Еле нашёл тему. Теперь у меня есть свободное время. Давайте конктретизируем задачу. Надо больше кода? Предлагаю воспользоваться уже имеющимся проектом - я добавлю изменения. Интересно? У меня необычный ход мыслей, но в рамках данной задачи, я могу работать по шаблону

Добавлено через 30 секунд
что от меня требуется?

Добавлено через 1 минуту
надо уточнить ТЗ, но я хотел бы показать силу именно в ООП в том, что над проектом может работать сразу несколько людей - чтобы автор задачи, наконец, понял, что его задача без классов нерешаема

Добавлено через 1 минуту
Вы бы его на ГИТ забросили для удобства?

Добавлено через 38 секунд
Цитата Сообщение от Storm23 Посмотреть сообщение
Только все вместе - с экспортом, со всем функционалом.
не смешите

Добавлено через 1 минуту
Storm23, забросите свою версию в СКВ?

Добавлено через 1 минуту
Наперёд скажу, что буду работать через парадигму TDD - как раз потренируюсь, а то уже подзабывать начал

Добавлено через 1 минуту
Форум плодит сообщения - модераторы не спите там

Добавлено через 54 секунды
Storm23, создавайте репозиторий, в личку бросайте доступ, будем работать над проектом
0
Эксперт .NETАвтор FAQ
 Аватар для Storm23
10425 / 5155 / 1825
Регистрация: 11.01.2015
Сообщений: 6,226
Записей в блоге: 34
03.11.2018, 12:36  [ТС]
Лучший ответ Сообщение было отмечено REALIST07 как решение

Решение

umatkot,
Вот репозиторий https://github.com/Storm-23/QuestConstructor

что от меня требуется?
Не знаю. А что бы вы хотели сделать?
Можно например добавить функционал по автоматическому перенумерованию вопросов. Так, что бы номера вопросов постоянно шли подряд, даже если пользователь их перемешал или удалил.
2
187 / 100 / 19
Регистрация: 15.09.2011
Сообщений: 801
03.11.2018, 18:48
Storm23, создавайте репозиторий, в личку бросайте доступ, будем работать над проектом
Цитата Сообщение от Storm23 Посмотреть сообщение
Можно например добавить функционал по автоматическому перенумерованию вопросов. Так, что бы номера вопросов постоянно шли подряд, даже если пользователь их перемешал или удалил.
Последний
думаю это не проблема, если я буду брать адрес вопроса, а не его порядковый номер

Добавлено через 2 минуты
Storm23, щас глянем))

Добавлено через 3 минуты
Мне же надо сделать так, чтобы вопросы шли псевдорекурсивно? Постараюсь использовать класс Expression

Добавлено через 14 минут
Так. Через ГИТ подключиться я не сумел(видно, руки из жопы). Проект загрузил. Я сам сформирую цель - нам надо сделать так, чтобы каждый ответ на вопрос вёл к новому вопросу. Нумерация вопросов меня пока не волнует

Добавлено через 41 секунду
потом разберусь с этим

Добавлено через 2 минуты
Сразу напишу, что для удобства я загружу в проект библиотеку FluentAssertions - она просто лучше поможет мне определить, что мне потребуется проверить? Так?

Добавлено через 7 минут
Нихрена, молодец, топикстартер с тебя галлон пива. В адрес Шторма. Он там так всё подробно описал...

Добавлено через 4 минуты
Препод отвалится - гарант, нихрена она там проработал...

Добавлено через 46 секунд
Storm23, тебе заняться нечем что-ли?

Добавлено через 2 минуты
это не коммерческий проект - это ПРОТОТИП
ты чё там сделал?

Добавлено через 1 минуту
Люди вы гляньте как надо делать прилажухи

Добавлено через 5 минут
Брат, снимаю шляпу, ты реально крут))))

Добавлено через 1 час 35 минут
Фсё?
Кликните здесь для просмотра всего текста
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
namespace QuestCoreNS
{
    /// <summary>
    /// Процесс прохождения интервью
    /// </summary>
    public class Interview
    {
        internal Questionnaire questionnaire { get; private set; }
        internal Anketa anketa { get; private set; }
 
        /// <summary>
        /// Ответы, уже данные респондентом
        /// </summary>
        public List<Answer> PassedAnswers { get; set; } = new List<Answer>();
 
        /// <summary>
        /// Текущий вопрос, на который отвечает респондент в данный момент
        /// </summary>
        public Answer CurrentAnswer { get; set; }
 
        /// <summary>
        /// Интервью завершено?
        /// </summary>
        public bool IsFinished { get; internal set; }
 
        public Interview(Questionnaire questionnaire, Anketa anketa)
        {
            this.questionnaire = questionnaire;
            this.anketa = anketa;
        }
    }
}


Добавлено через 15 секунд
А почему???

Добавлено через 1 минуту
ТОпикстартер, ты можешь наблюдать зависимости.

Добавлено через 1 минуту
Ладно, задачу надо довести до конца

Добавлено через 9 минут
Да я просто кодом любуюсь.

Добавлено через 7 минут
))))) Ты крут, брат, супер) Всё так красиво, я бы был бы не против с тобой работать) Это почти Монна Лиза. Я ещё всё не просмотрел, но я просто в ступоре)))) Ладно про меня

Добавлено через 1 минуту
Это красивый код - все смотрите и учитесь

Добавлено через 4 минуты
Думаю, что за день не осилю, но это великолепно, ты оправдал все ожидания, просто супер!!! Пока всё не изучил, но это что-то. Ребята, реал.

Добавлено через 1 минуту

Не по теме:

чувствую себя дауном



Добавлено через 6 минут
Storm23, Запуск пустого вопросника невозможен - это тупо проверка. Я завтра на свежую голову поработаю над кодом
0
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
inter-admin
Эксперт
29715 / 6470 / 2152
Регистрация: 06.03.2009
Сообщений: 28,500
Блог
03.11.2018, 18:48
Помогаю со студенческими работами здесь

Модель ООП
Помоги сделать, пожалуйста работу! Возможно у кого нибудь есть примеры работы с классами! БУду рада всему, что есть! У меня тема...

Закрепить модель ООП
Добрый день. Ситуация такая: Я знаю и представляю себе модель ООП, классы, объекты, наследование и т.д. Но так как до чисто...

Модель работы светодиода ооп
здравствуйте, буду очень благодарна за хотя бы примерный код. никак не могу понять зачем тут использовать ооп примерный интерфейс

ООП модель системы спама
Срочно нужна помощь! Помогите пожалуйста реализовать программу с помощью классов. Спамер рассылает по сети Internet недобросовестную...

Ооп модель телефонного справочника
Нужен код на тему в заголовке с добавлением классов.Га с++ Буду очень благодарна!!


Искать еще темы с ответами

Или воспользуйтесь поиском по форуму:
20
Ответ Создать тему
Опции темы

Новые блоги и статьи
http://iceja.net/ сервер решения полиномов
iceja 18.01.2026
Выкатила http:/ / iceja. net/ сервер решения полиномов (находит действительные корни полиномов методом Штурма). На сайте документация по API, но скажу прямо VPS слабенький и 200 000 полиномов. . .
Первый деплой
lagorue 17.01.2026
Не спеша развернул своё 1ое приложение в kubernetes. А дальше мне интересно создать 1фронтэнд приложения и 2 бэкэнд приложения развернуть 2 деплоя в кубере получится 2 сервиса и что-бы они. . .
Расчёт переходных процессов в цепи постоянного тока
igorrr37 16.01.2026
/ * Дана цепь постоянного тока с R, L, C, k(ключ), U, E, J. Программа составляет систему уравнений по 1 и 2 законам Кирхгофа, решает её и находит: токи, напряжения и их 1 и 2 производные при t = 0;. . .
Восстановить юзерскрипты Greasemonkey из бэкапа браузера
damix 15.01.2026
Если восстановить из бэкапа профиль Firefox после переустановки винды, то список юзерскриптов в Greasemonkey будет пустым. Но восстановить их можно так. Для этого понадобится консольная утилита. . .
Изучаю kubernetes
lagorue 14.01.2026
А пригодятся-ли мне знания kubernetes в России?
Сукцессия микоризы: основная теория в виде двух уравнений.
anaschu 11.01.2026
https:/ / rutube. ru/ video/ 7a537f578d808e67a3c6fd818a44a5c4/
WordPad для Windows 11
Jel 10.01.2026
WordPad для Windows 11 — это приложение, которое восстанавливает классический текстовый редактор WordPad в операционной системе Windows 11. После того как Microsoft исключила WordPad из. . .
Classic Notepad for Windows 11
Jel 10.01.2026
Old Classic Notepad for Windows 11 Приложение для Windows 11, позволяющее пользователям вернуть классическую версию текстового редактора «Блокнот» из Windows 10. Программа предоставляет более. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru