textual icon indicating copy to clipboard operation
textual copied to clipboard

Mock time

Open willmcgugan opened this issue 3 years ago • 7 comments

We should make sure that there is only one source of time in Textual.

Ideally App should have get_time function which returns the current time.

We should have a mechanism to replace the system time with an alternative time, which we can use to ensure tests are deterministic.

Suggest that App accepts a get_time callable which will replace monotonic

willmcgugan avatar May 11 '22 13:05 willmcgugan

We already use time_machine in the tests, is that suitable for whatever use case you're thinking of here?

darrenburns avatar May 11 '22 13:05 darrenburns

Maybe. @DrBenton Do you think time_machine would be adequate to make the tests quicker / deterministic?

willmcgugan avatar May 11 '22 13:05 willmcgugan

ah, I knew I had seen a time-mocking library in Textual or Rich's dev dependencies, but I was not sure which project it was and whether if was still used or not! ^_^ If we have time_machine already on board we can likely use it indeed :slightly_smiling_face:

olivierphi avatar May 11 '22 14:05 olivierphi

Ok. I have a feeling that a passing a get_time callable may also be worthwhile, and its a small change anyway. Will leave this ticket up for graphs, but sounds like time_machine is the way to go for integration tests.

@DrBenton Do you think it would be worthwhile making that change to your recent integration test?

willmcgugan avatar May 11 '22 14:05 willmcgugan

@willmcgugan yes, I'll try to integrate such a "time control" machinery to our integration test classes! :slightly_smiling_face:

olivierphi avatar May 11 '22 16:05 olivierphi

@willmcgugan @darrenburns From my attempts to mock time in a Textual app so far, it seems to me that the current machinery makes it quite difficult...

TLDR; A centralised "Textual time" orchestrator could allow us not only to mock time properly in our integration tests, but also potentially improve performance (by avoiding too many calls to the system clock)

The problem

In the test of a Django app for example, almost everything is pretty stateless and the database is the single source of truth, which makes time mocking rather easy to use. However, in our case our Timers (which are the workhorse of Textual's lifecycle) are stateful units, without connection between them, and all running their own endless loop and triggering stuff in this loop when certain conditions are met. During these loops there is a combination of calls to asyncio.sleep(delay) and checks of the monotonic clock. So even if I mock the source of monotonic time, it's really hard to have consistent results as the Timers use various time-based patterns to do their stuff :grimacing:

Potential alternative implementation

One option I could see to solve this would be to have a single time-based loop in a Textual app, running a loop cycle 60 times per second for example, and this single loop would iterate though all the active Timer instances instances 60 times per second, check what the own ticking period of each Timer is, and execute their tick if it's their turn to go.

In such an architecture each Timer would rely on this orchestrator to be triggered, and not do any "clock wall time"-related check themselves. By having a single orchestrator of the time, which would act as as single source of truth for time that passes, we would be able to mock animations more easily for example - by simply running ticks in a accelerated way in the orchestrator.

As a side effect such an implementation could make our Timers perform better overall, as the monotonic clock would be used only once per event loop cycle? The Octopus Energy's Python conventions guide for example warns against using "system clocks" - and even though I don't know the details of how the underlying monotonic clock is implemented, I reckon it does such calls under the hood?

Avoid calls to the system clock in the domain layer of the application. That is, calls to localtime.now(), localtime.today() etc. Think of such calls like network or database calls.

olivierphi avatar May 12 '22 14:05 olivierphi

I don't think there is any performance issue in getting the time, it's a fairly lean operation:

>>> monotonic()-monotonic()
-1.1669471859931946e-06

I think I see where you are going with an orchestrator. I hadn't considered asyncio.sleep as a sneaky source of time that we can't control.

But I suspect 60fps is going to be a performance drain. If I have understood you correctly, it would have to be running all the time. Even if it wasn't doing anything, thats still a cpu drain.

How about we move all explicit calls to get the time in to app, which we can mock. We also move the responsibility for asyncio.sleep in to App. In normal operation we defer to asyncio.sleep, but for testing we base it on our mocked time.

I'm thinking a method like App.advance_time(delta) which moves the clock forward and wakes up any sleeping cooroutines.

Let's have a chat about it tomorrow. It's a tricky topic, and it's worth having the 3 of us on this.

willmcgugan avatar May 12 '22 15:05 willmcgugan

https://github.com/Textualize/textual/wiki/Sorry-we-closed-your-issue

willmcgugan avatar Oct 25 '22 09:10 willmcgugan

Did we solve your problem?

Glad we could help!

github-actions[bot] avatar Oct 25 '22 09:10 github-actions[bot]