samedi 27 mars 2021

Choosing a design pattern and data structure for multiple sets of questions with varying difficulties

I have a Question class:

class Question 
{
    int Id { get; set; };
    string Text { get; set; }
    QuestionDifficulty Difficulty { get; set; }
    List<Answer> Answers { get; set; }
}

And an enumeration QuestionDifficulty:

enum QuestionDifficulty { Easy, Medium, Hard, VeryHard }

I have to pick multiple question sets of the same size of each difficulty level from a container collection called Repository for a test:

class Repository { List<Question> Questions { get; set; } /*other fields omitted for brevity*/ }

The number of questions to be picked for each difficulty level will be specified by a dictionary IDictionary<QuestionDifficulty, int>. Since there are multiple ways it can be picked, I have written an interface IQuestionPicker that defines this behavior. :

interface IQuestionPicker
{ 
    public QResult Pick(Repository repo, 
                               int setCount, 
                               IDictionary<QuestionDifficulty, int> requiredQuestionCounts);
}

One of the implementations is called UniqueQuestionPicker that picks a different collection of questions for each set. So no two sets will have any common questions. Hence, the Repository container needs to have, for each difficulty level, at least requiredQuestionCounts[difficulty] * setCount questions. So it performs the validation first. I wanted to move this validation to its own method HasEnoughQuestions() returning a bool. But I realized then I cannot include the data about which difficulty level has the problem in my exception.

class UniqueQuestionPicker : IQuestionPicker
{
    public List<QuestionResult> Pick(Repository repo, 
                               int setCount, 
                               IDictionary<QuestionDifficulty, int> requiredQuestionCounts)
    {
         var questions = repo.Questions ?? 
                throw new NullReferenceException($"{nameof(repo.Questions)} of argument {nameof(repo)} is null");

            var groups = questions.GroupBy(question => question.Difficulty);
            
            // Get actual number of questions for each difficulty level
            Dictionary<QuestionDifficulty, int> actualQuestionCounts = groups
                .ToDictionary(group => group.Key, group => group.Count());
            
            // Validate if each difficulty level has enough questions
            foreach (var difficulty in actualQuestionCounts.Keys)
            {
                int requiredQuestions = requiredQuestionCounts[difficulty] * setCount;
                int actualQuestions = actualQuestionCounts[difficulty];
                if (actualQuestions < requiredQuestions)
                {
                    throw new InvalidOperationException(
                        $"Not enough questions of difficulty \"{difficulty}\"")
                    {
                        Data =
                        {
                            ["RequiredQs"] = requiredQuestions,
                            ["ActualQs"] = actualQuestions
                        }
                    };
                }

            Random random = new();
            List<QuestionResult> results = new();
            foreach(var group in groups)
            {
                var difficulty = group.Key;
                QuestionResult result = 
                    new(difficulty, setCount, questionsPerSet: requiredQuestionCounts[difficulty]);

                var shuffledQuestions = group.OrderBy(q => random.Next());
                
                for(int i = 0; !result.Full; i++)
                {
                    result.Add(shuffledQuestions.ElementAt(i));
                }
                results.Add(result);
            }
            return results;
            }  
    }
}

I'm a bit unsure about how my result data structure QResult should look like. It should contain the specified number of question sets, each set containing the specified number of easy, medium, hard and very hard questions. I had first planned to use IDictionary<QuestionDifficulty, List<HashSet<Question>>>, or IList<IDictionary<QuestionDifficulty, HashSet<Question>>> but they both look a bit clumsy. I want to also allow combining two QResults picked from different repositories to be merged to a new QResult.

I plan to include more picker implementations like ShuffledQuestionPicker which would include the same questions in each set but they will be shuffled.


This is what the current QResult I've written called QuestionResult looks like. Once the SetCount and QuestionsPerSet are passed through its constructor, the Add() method automatically creates a new HashSet<Question> and advances an internal pointer called currentSet when the QuestionSets[currentSet].Count becomes equal to QuestionsPerSet. I have used a HashSet since I have to check before adding the question, that none of the previous sets contain it already and this makes it O(1). A QuestionResult object will exist for each difficulty level, hence my picker returns List<QuestionResult>.

public class QuestionResult
    {
        public IEnumerable<IEnumerable<Question>> QuestionSets { get => questionSets; }

        public int QuestionsPerSet { get; }

        public int SetCount { get; }

        public bool Full { get; private set;  }

        private readonly List<HashSet<Question>> questionSets = new();

        private int currentSet = 0;

        public QuestionDifficulty Difficulty { get; }

        public QuestionResult(QuestionDifficulty difficulty, int setCount, int questionsPerSet)
        {
            Difficulty = difficulty;
            QuestionsPerSet = questionsPerSet;
            SetCount = setCount;
        }

        public void Add(Question question)
        {
            if (questionSets[currentSet].Count == QuestionsPerSet)
            {
                questionSets.Add(new HashSet<Question>());
                currentSet++;
            }
            if (questionSets.Any(set => set.Contains(question)))
            {
                throw new InvalidOperationException("Duplicate addition attempted");
            }
            if(questionSets.Count == SetCount && questionSets[currentSet].Count == QuestionsPerSet)
            {
                Full = true;
            }
        }

        public bool TryAdd(Question question)
        {
            try 
            {
                questionSets[currentSet].Add(question);
                return true;
            }
            catch(InvalidOperationException)
            {
                return false;
            }
        }
    }

Something about this approach does not feel right, it has gotten quite complex. Can someone recommend a better way to do this, a better data structure and design pattern?

Aucun commentaire:

Enregistrer un commentaire